added more share options and collabs
This commit is contained in:
@@ -0,0 +1,31 @@
|
|||||||
|
-- Collaboration + shared cross-off ("claim") support.
|
||||||
|
|
||||||
|
-- Per-list toggle: may anonymous holders of the share link cross items off?
|
||||||
|
ALTER TABLE lists ADD COLUMN allow_guest_crossoff BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- Shared claim on an item: who crossed it off, and when. Visible to everyone
|
||||||
|
-- who can see the list (gift-registry "this one's taken" semantics).
|
||||||
|
ALTER TABLE items ADD COLUMN claimed_at TIMESTAMPTZ;
|
||||||
|
ALTER TABLE items ADD COLUMN claimed_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE items ADD COLUMN claimed_by_name TEXT;
|
||||||
|
|
||||||
|
-- Collaboration invite links. Each link carries the role it grants; revoking
|
||||||
|
-- a link is just deleting the row. Accepting one creates a collaborator.
|
||||||
|
CREATE TABLE list_invites (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
list_id UUID NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
role TEXT NOT NULL CHECK (role IN ('editor', 'crosser')),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX list_invites_list ON list_invites (list_id);
|
||||||
|
|
||||||
|
-- Accepted collaborators on a list (besides the owner).
|
||||||
|
CREATE TABLE list_collaborators (
|
||||||
|
list_id UUID NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
role TEXT NOT NULL CHECK (role IN ('editor', 'crosser')),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (list_id, user_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX list_collaborators_user ON list_collaborators (user_id);
|
||||||
@@ -63,3 +63,29 @@ impl FromRequestParts<AppState> for AuthUser {
|
|||||||
Ok(AuthUser(user))
|
Ok(AuthUser(user))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extractor that yields the user if logged in, or None for anonymous callers.
|
||||||
|
/// Never rejects — used on routes that serve both (e.g. public share links).
|
||||||
|
pub struct OptionalUser(pub Option<User>);
|
||||||
|
|
||||||
|
impl FromRequestParts<AppState> for OptionalUser {
|
||||||
|
type Rejection = AppError;
|
||||||
|
|
||||||
|
async fn from_request_parts(
|
||||||
|
parts: &mut Parts,
|
||||||
|
state: &AppState,
|
||||||
|
) -> Result<Self, Self::Rejection> {
|
||||||
|
let Some(session) = parts.extensions.get::<Session>().cloned() else {
|
||||||
|
return Ok(OptionalUser(None));
|
||||||
|
};
|
||||||
|
let id = session
|
||||||
|
.get::<Uuid>(USER_ID_KEY)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(anyhow::anyhow!("session read: {e}")))?;
|
||||||
|
let user = match id {
|
||||||
|
Some(id) => load_user(&state.pool, id).await?,
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
Ok(OptionalUser(user))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -56,11 +56,17 @@ pub struct List {
|
|||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
/// Unguessable secret for public read-only sharing; None = private.
|
/// Unguessable secret for public read-only sharing; None = private.
|
||||||
pub share_token: Option<String>,
|
pub share_token: Option<String>,
|
||||||
|
/// May anonymous holders of the share link cross items off?
|
||||||
|
pub allow_guest_crossoff: bool,
|
||||||
pub position: i32,
|
pub position: i32,
|
||||||
#[serde(with = "time::serde::rfc3339")]
|
#[serde(with = "time::serde::rfc3339")]
|
||||||
pub created_at: OffsetDateTime,
|
pub created_at: OffsetDateTime,
|
||||||
#[serde(with = "time::serde::rfc3339")]
|
#[serde(with = "time::serde::rfc3339")]
|
||||||
pub updated_at: OffsetDateTime,
|
pub updated_at: OffsetDateTime,
|
||||||
|
/// Caller's role on this list: "owner" | "editor" | "crosser". Computed per
|
||||||
|
/// query (not a stored column); queries that omit it leave it None.
|
||||||
|
#[sqlx(default)]
|
||||||
|
pub role: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A coveted thing inside a list. Price/metadata columns are filled by the
|
/// A coveted thing inside a list. Price/metadata columns are filled by the
|
||||||
@@ -90,6 +96,11 @@ pub struct Item {
|
|||||||
#[serde(with = "time::serde::rfc3339::option")]
|
#[serde(with = "time::serde::rfc3339::option")]
|
||||||
pub checked_at: Option<OffsetDateTime>,
|
pub checked_at: Option<OffsetDateTime>,
|
||||||
|
|
||||||
|
/// Shared "crossed off"/claimed state. `claimed_at` non-null = taken.
|
||||||
|
#[serde(with = "time::serde::rfc3339::option")]
|
||||||
|
pub claimed_at: Option<OffsetDateTime>,
|
||||||
|
pub claimed_by_name: Option<String>,
|
||||||
|
|
||||||
#[serde(with = "time::serde::rfc3339")]
|
#[serde(with = "time::serde::rfc3339")]
|
||||||
pub created_at: OffsetDateTime,
|
pub created_at: OffsetDateTime,
|
||||||
#[serde(with = "time::serde::rfc3339")]
|
#[serde(with = "time::serde::rfc3339")]
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
//! Collaboration: per-list invite links that grant a role, and the
|
||||||
|
//! collaborators who have accepted them.
|
||||||
|
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::routing::{get, post};
|
||||||
|
use axum::{Json, Router};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
|
use time::OffsetDateTime;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::auth::session::AuthUser;
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
pub fn router() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route(
|
||||||
|
"/lists/{id}/invites",
|
||||||
|
get(list_invites).post(create_invite),
|
||||||
|
)
|
||||||
|
.route("/lists/{id}/invites/{invite_id}", axum::routing::delete(revoke_invite))
|
||||||
|
.route("/lists/{id}/collaborators", get(list_collaborators))
|
||||||
|
.route(
|
||||||
|
"/lists/{id}/collaborators/{user_id}",
|
||||||
|
axum::routing::delete(remove_collaborator),
|
||||||
|
)
|
||||||
|
.route("/invites/{token}", get(preview_invite))
|
||||||
|
.route("/invites/{token}/accept", post(accept_invite))
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROLES: &[&str] = &["editor", "crosser"];
|
||||||
|
|
||||||
|
/// Confirm the caller owns the list, else NotFound (hides others' lists).
|
||||||
|
async fn assert_owner(state: &AppState, list_id: Uuid, user_id: Uuid) -> AppResult<()> {
|
||||||
|
let owns = sqlx::query_scalar::<_, bool>(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM lists WHERE id = $1 AND user_id = $2)",
|
||||||
|
)
|
||||||
|
.bind(list_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await?;
|
||||||
|
if owns {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(AppError::NotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||||
|
struct Invite {
|
||||||
|
id: Uuid,
|
||||||
|
token: String,
|
||||||
|
role: String,
|
||||||
|
#[serde(with = "time::serde::rfc3339")]
|
||||||
|
created_at: OffsetDateTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_invites(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthUser(user): AuthUser,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> AppResult<Json<Vec<Invite>>> {
|
||||||
|
assert_owner(&state, id, user.id).await?;
|
||||||
|
let invites = sqlx::query_as::<_, Invite>(
|
||||||
|
"SELECT id, token, role, created_at FROM list_invites
|
||||||
|
WHERE list_id = $1 ORDER BY created_at",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(invites))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct CreateInviteReq {
|
||||||
|
role: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_invite(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthUser(user): AuthUser,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(req): Json<CreateInviteReq>,
|
||||||
|
) -> AppResult<Json<Invite>> {
|
||||||
|
assert_owner(&state, id, user.id).await?;
|
||||||
|
if !ROLES.contains(&req.role.as_str()) {
|
||||||
|
return Err(AppError::Validation(format!("unknown role: {}", req.role)));
|
||||||
|
}
|
||||||
|
let token = Uuid::new_v4().simple().to_string();
|
||||||
|
let invite = sqlx::query_as::<_, Invite>(
|
||||||
|
"INSERT INTO list_invites (list_id, token, role) VALUES ($1, $2, $3)
|
||||||
|
RETURNING id, token, role, created_at",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(token)
|
||||||
|
.bind(&req.role)
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(invite))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn revoke_invite(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthUser(user): AuthUser,
|
||||||
|
Path((id, invite_id)): Path<(Uuid, Uuid)>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
assert_owner(&state, id, user.id).await?;
|
||||||
|
let res = sqlx::query("DELETE FROM list_invites WHERE id = $1 AND list_id = $2")
|
||||||
|
.bind(invite_id)
|
||||||
|
.bind(id)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await?;
|
||||||
|
if res.rows_affected() == 0 {
|
||||||
|
return Err(AppError::NotFound);
|
||||||
|
}
|
||||||
|
Ok(Json(json!({ "deleted": invite_id })))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||||
|
struct Collaborator {
|
||||||
|
user_id: Uuid,
|
||||||
|
role: String,
|
||||||
|
display_name: Option<String>,
|
||||||
|
email: String,
|
||||||
|
#[serde(with = "time::serde::rfc3339")]
|
||||||
|
created_at: OffsetDateTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_collaborators(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthUser(user): AuthUser,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> AppResult<Json<Vec<Collaborator>>> {
|
||||||
|
assert_owner(&state, id, user.id).await?;
|
||||||
|
let rows = sqlx::query_as::<_, Collaborator>(
|
||||||
|
"SELECT c.user_id, c.role, u.display_name, u.email, c.created_at
|
||||||
|
FROM list_collaborators c JOIN users u ON u.id = c.user_id
|
||||||
|
WHERE c.list_id = $1 ORDER BY c.created_at",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_collaborator(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthUser(user): AuthUser,
|
||||||
|
Path((id, target)): Path<(Uuid, Uuid)>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
assert_owner(&state, id, user.id).await?;
|
||||||
|
let res = sqlx::query("DELETE FROM list_collaborators WHERE list_id = $1 AND user_id = $2")
|
||||||
|
.bind(id)
|
||||||
|
.bind(target)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await?;
|
||||||
|
if res.rows_affected() == 0 {
|
||||||
|
return Err(AppError::NotFound);
|
||||||
|
}
|
||||||
|
Ok(Json(json!({ "removed": target })))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What an invitee sees before accepting: which list, what role, and whether
|
||||||
|
/// they're the owner or already a collaborator.
|
||||||
|
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||||
|
struct InvitePreview {
|
||||||
|
list_id: Uuid,
|
||||||
|
list_name: String,
|
||||||
|
emoji: Option<String>,
|
||||||
|
role: String,
|
||||||
|
owner_id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn preview_invite(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(token): Path<String>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let p = sqlx::query_as::<_, InvitePreview>(
|
||||||
|
"SELECT l.id AS list_id, l.name AS list_name, l.emoji, inv.role, l.user_id AS owner_id
|
||||||
|
FROM list_invites inv JOIN lists l ON l.id = inv.list_id
|
||||||
|
WHERE inv.token = $1",
|
||||||
|
)
|
||||||
|
.bind(&token)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await?
|
||||||
|
.ok_or(AppError::NotFound)?;
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"list_id": p.list_id,
|
||||||
|
"list_name": p.list_name,
|
||||||
|
"emoji": p.emoji,
|
||||||
|
"role": p.role,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accept an invite: become a collaborator on its list with the invite's role.
|
||||||
|
/// Idempotent — re-accepting updates the role. Owners are a no-op success.
|
||||||
|
async fn accept_invite(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthUser(user): AuthUser,
|
||||||
|
Path(token): Path<String>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let (list_id, role, owner_id) = sqlx::query_as::<_, (Uuid, String, Uuid)>(
|
||||||
|
"SELECT l.id, inv.role, l.user_id
|
||||||
|
FROM list_invites inv JOIN lists l ON l.id = inv.list_id
|
||||||
|
WHERE inv.token = $1",
|
||||||
|
)
|
||||||
|
.bind(&token)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await?
|
||||||
|
.ok_or(AppError::NotFound)?;
|
||||||
|
|
||||||
|
if owner_id != user.id {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO list_collaborators (list_id, user_id, role) VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (list_id, user_id) DO UPDATE SET role = EXCLUDED.role",
|
||||||
|
)
|
||||||
|
.bind(list_id)
|
||||||
|
.bind(user.id)
|
||||||
|
.bind(&role)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(json!({ "list_id": list_id, "role": role })))
|
||||||
|
}
|
||||||
+246
-52
@@ -6,7 +6,7 @@ use serde::Deserialize;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use validator::Validate;
|
use validator::Validate;
|
||||||
|
|
||||||
use crate::auth::session::AuthUser;
|
use crate::auth::session::{AuthUser, OptionalUser};
|
||||||
use crate::error::{AppError, AppResult};
|
use crate::error::{AppError, AppResult};
|
||||||
use crate::models::{Item, List, PricePoint};
|
use crate::models::{Item, List, PricePoint};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
@@ -21,28 +21,41 @@ pub fn router() -> Router<AppState> {
|
|||||||
)
|
)
|
||||||
.route("/lists/{id}/share", post(share_list).delete(unshare_list))
|
.route("/lists/{id}/share", post(share_list).delete(unshare_list))
|
||||||
.route("/shared/{token}", get(shared_view))
|
.route("/shared/{token}", get(shared_view))
|
||||||
|
.route(
|
||||||
|
"/shared/{token}/items/{item_id}/history",
|
||||||
|
get(shared_item_history),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/shared/{token}/items/{item_id}/claim",
|
||||||
|
post(guest_claim).delete(guest_unclaim),
|
||||||
|
)
|
||||||
.route("/lists/{id}/items", get(list_items).post(create_item))
|
.route("/lists/{id}/items", get(list_items).post(create_item))
|
||||||
.route(
|
.route(
|
||||||
"/items/{id}",
|
"/items/{id}",
|
||||||
axum::routing::patch(update_item).delete(delete_item),
|
axum::routing::patch(update_item).delete(delete_item),
|
||||||
)
|
)
|
||||||
|
.route("/items/{id}/claim", post(claim_item).delete(unclaim_item))
|
||||||
.route("/items/{id}/refetch", post(refetch_item))
|
.route("/items/{id}/refetch", post(refetch_item))
|
||||||
.route("/items/{id}/history", get(item_history))
|
.route("/items/{id}/history", get(item_history))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const ITEM_COLS: &str = "id, list_id, title, url, note, status::text AS status, target_price, \
|
pub const ITEM_COLS: &str = "id, list_id, title, url, note, status::text AS status, target_price, \
|
||||||
position, title_fetched, current_price, currency, image_url, in_stock, source, fetched_at, \
|
position, title_fetched, current_price, currency, image_url, in_stock, source, fetched_at, \
|
||||||
track_enabled, last_error, checked_at, created_at, updated_at";
|
track_enabled, last_error, checked_at, claimed_at, claimed_by_name, created_at, updated_at";
|
||||||
|
|
||||||
// Same columns, qualified with the `i` alias for use in UPDATE … FROM lists,
|
// Same columns, qualified with the `i` alias for use in UPDATE … FROM lists,
|
||||||
// where bare `id`/`position`/`created_at` would be ambiguous across both tables.
|
// where bare `id`/`position`/`created_at` would be ambiguous across both tables.
|
||||||
const ITEM_COLS_I: &str = "i.id, i.list_id, i.title, i.url, i.note, i.status::text AS status, \
|
const ITEM_COLS_I: &str = "i.id, i.list_id, i.title, i.url, i.note, i.status::text AS status, \
|
||||||
i.target_price, i.position, i.title_fetched, i.current_price, i.currency, i.image_url, \
|
i.target_price, i.position, i.title_fetched, i.current_price, i.currency, i.image_url, \
|
||||||
i.in_stock, i.source, i.fetched_at, i.track_enabled, i.last_error, i.checked_at, \
|
i.in_stock, i.source, i.fetched_at, i.track_enabled, i.last_error, i.checked_at, \
|
||||||
i.created_at, i.updated_at";
|
i.claimed_at, i.claimed_by_name, i.created_at, i.updated_at";
|
||||||
|
|
||||||
const LIST_COLS: &str =
|
const LIST_COLS: &str = "id, user_id, name, emoji, description, share_token, allow_guest_crossoff, \
|
||||||
"id, user_id, name, emoji, description, share_token, position, created_at, updated_at";
|
position, created_at, updated_at";
|
||||||
|
|
||||||
|
// Same, prefixed with the `l` alias (for the collaborator UNION arm).
|
||||||
|
const LIST_COLS_L: &str = "l.id, l.user_id, l.name, l.emoji, l.description, l.share_token, \
|
||||||
|
l.allow_guest_crossoff, l.position, l.created_at, l.updated_at";
|
||||||
|
|
||||||
const ALLOWED_STATUS: &[&str] = &["coveted", "acquired", "renounced"];
|
const ALLOWED_STATUS: &[&str] = &["coveted", "acquired", "renounced"];
|
||||||
|
|
||||||
@@ -52,9 +65,16 @@ async fn list_lists(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
AuthUser(user): AuthUser,
|
AuthUser(user): AuthUser,
|
||||||
) -> AppResult<Json<Vec<List>>> {
|
) -> AppResult<Json<Vec<List>>> {
|
||||||
|
// Lists you own, plus lists you've been invited to collaborate on. The
|
||||||
|
// `role` column tells the client what controls to show.
|
||||||
let lists = sqlx::query_as::<_, List>(&format!(
|
let lists = sqlx::query_as::<_, List>(&format!(
|
||||||
"SELECT {LIST_COLS}
|
"SELECT {LIST_COLS}, 'owner'::text AS role
|
||||||
FROM lists WHERE user_id = $1 ORDER BY position, created_at"
|
FROM lists WHERE user_id = $1
|
||||||
|
UNION ALL
|
||||||
|
SELECT {LIST_COLS_L}, c.role
|
||||||
|
FROM lists l JOIN list_collaborators c ON c.list_id = l.id
|
||||||
|
WHERE c.user_id = $1
|
||||||
|
ORDER BY position, created_at"
|
||||||
))
|
))
|
||||||
.bind(user.id)
|
.bind(user.id)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
@@ -84,7 +104,7 @@ async fn create_list(
|
|||||||
"INSERT INTO lists (user_id, name, emoji, description, position)
|
"INSERT INTO lists (user_id, name, emoji, description, position)
|
||||||
VALUES ($1, $2, $3, $4,
|
VALUES ($1, $2, $3, $4,
|
||||||
COALESCE((SELECT MAX(position) + 1 FROM lists WHERE user_id = $1), 0))
|
COALESCE((SELECT MAX(position) + 1 FROM lists WHERE user_id = $1), 0))
|
||||||
RETURNING {LIST_COLS}"
|
RETURNING {LIST_COLS}, 'owner'::text AS role"
|
||||||
))
|
))
|
||||||
.bind(user.id)
|
.bind(user.id)
|
||||||
.bind(req.name.trim())
|
.bind(req.name.trim())
|
||||||
@@ -104,6 +124,8 @@ struct UpdateListReq {
|
|||||||
#[validate(length(max = 500))]
|
#[validate(length(max = 500))]
|
||||||
description: Option<String>,
|
description: Option<String>,
|
||||||
position: Option<i32>,
|
position: Option<i32>,
|
||||||
|
/// List setting: allow anonymous share-link holders to cross items off.
|
||||||
|
allow_guest_crossoff: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn update_list(
|
async fn update_list(
|
||||||
@@ -117,12 +139,13 @@ async fn update_list(
|
|||||||
|
|
||||||
let list = sqlx::query_as::<_, List>(&format!(
|
let list = sqlx::query_as::<_, List>(&format!(
|
||||||
"UPDATE lists SET
|
"UPDATE lists SET
|
||||||
name = COALESCE($3, name),
|
name = COALESCE($3, name),
|
||||||
emoji = COALESCE($4, emoji),
|
emoji = COALESCE($4, emoji),
|
||||||
description = COALESCE($5, description),
|
description = COALESCE($5, description),
|
||||||
position = COALESCE($6, position)
|
position = COALESCE($6, position),
|
||||||
|
allow_guest_crossoff = COALESCE($7, allow_guest_crossoff)
|
||||||
WHERE id = $1 AND user_id = $2
|
WHERE id = $1 AND user_id = $2
|
||||||
RETURNING {LIST_COLS}"
|
RETURNING {LIST_COLS}, 'owner'::text AS role"
|
||||||
))
|
))
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.bind(user.id)
|
.bind(user.id)
|
||||||
@@ -130,6 +153,7 @@ async fn update_list(
|
|||||||
.bind(opt_trim(req.emoji))
|
.bind(opt_trim(req.emoji))
|
||||||
.bind(opt_trim(req.description))
|
.bind(opt_trim(req.description))
|
||||||
.bind(req.position)
|
.bind(req.position)
|
||||||
|
.bind(req.allow_guest_crossoff)
|
||||||
.fetch_optional(&state.pool)
|
.fetch_optional(&state.pool)
|
||||||
.await?
|
.await?
|
||||||
.ok_or(AppError::NotFound)?;
|
.ok_or(AppError::NotFound)?;
|
||||||
@@ -165,7 +189,7 @@ async fn share_list(
|
|||||||
let list = sqlx::query_as::<_, List>(&format!(
|
let list = sqlx::query_as::<_, List>(&format!(
|
||||||
"UPDATE lists SET share_token = COALESCE(share_token, $3)
|
"UPDATE lists SET share_token = COALESCE(share_token, $3)
|
||||||
WHERE id = $1 AND user_id = $2
|
WHERE id = $1 AND user_id = $2
|
||||||
RETURNING {LIST_COLS}"
|
RETURNING {LIST_COLS}, 'owner'::text AS role"
|
||||||
))
|
))
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.bind(user.id)
|
.bind(user.id)
|
||||||
@@ -185,7 +209,7 @@ async fn unshare_list(
|
|||||||
let list = sqlx::query_as::<_, List>(&format!(
|
let list = sqlx::query_as::<_, List>(&format!(
|
||||||
"UPDATE lists SET share_token = NULL
|
"UPDATE lists SET share_token = NULL
|
||||||
WHERE id = $1 AND user_id = $2
|
WHERE id = $1 AND user_id = $2
|
||||||
RETURNING {LIST_COLS}"
|
RETURNING {LIST_COLS}, 'owner'::text AS role"
|
||||||
))
|
))
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.bind(user.id)
|
.bind(user.id)
|
||||||
@@ -219,30 +243,63 @@ async fn shared_view(
|
|||||||
Ok(Json(serde_json::json!({ "list": list, "items": items })))
|
Ok(Json(serde_json::json!({ "list": list, "items": items })))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Items ----------------------------------------------------------------
|
// ---- Access control -------------------------------------------------------
|
||||||
|
|
||||||
/// Confirm the list exists and belongs to the user. Returns NotFound otherwise.
|
/// The caller's role on a list: "owner", "editor", "crosser", or None (no
|
||||||
async fn assert_list_owner(state: &AppState, list_id: Uuid, user_id: Uuid) -> AppResult<()> {
|
/// access). Owner wins over any collaborator row.
|
||||||
let owns = sqlx::query_scalar::<_, bool>(
|
async fn list_role(
|
||||||
"SELECT EXISTS(SELECT 1 FROM lists WHERE id = $1 AND user_id = $2)",
|
state: &AppState,
|
||||||
|
list_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
) -> AppResult<Option<String>> {
|
||||||
|
let role = sqlx::query_scalar::<_, String>(
|
||||||
|
"SELECT 'owner' FROM lists WHERE id = $1 AND user_id = $2
|
||||||
|
UNION ALL
|
||||||
|
SELECT role FROM list_collaborators WHERE list_id = $1 AND user_id = $2
|
||||||
|
LIMIT 1",
|
||||||
)
|
)
|
||||||
.bind(list_id)
|
.bind(list_id)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.fetch_one(&state.pool)
|
.fetch_optional(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
if owns {
|
Ok(role)
|
||||||
Ok(())
|
}
|
||||||
} else {
|
|
||||||
Err(AppError::NotFound)
|
/// Owner/editor/crosser may view. NotFound hides lists you can't see.
|
||||||
|
async fn assert_can_view(state: &AppState, list_id: Uuid, user_id: Uuid) -> AppResult<()> {
|
||||||
|
match list_role(state, list_id, user_id).await? {
|
||||||
|
Some(_) => Ok(()),
|
||||||
|
None => Err(AppError::NotFound),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Owner/editor may add/edit/delete items. Crossers see Forbidden (they can
|
||||||
|
/// view but not mutate the catalogue), strangers NotFound.
|
||||||
|
async fn assert_can_edit(state: &AppState, list_id: Uuid, user_id: Uuid) -> AppResult<()> {
|
||||||
|
match list_role(state, list_id, user_id).await?.as_deref() {
|
||||||
|
Some("owner") | Some("editor") => Ok(()),
|
||||||
|
Some(_) => Err(AppError::Forbidden),
|
||||||
|
None => Err(AppError::NotFound),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The list an item belongs to, or NotFound.
|
||||||
|
async fn item_list_id(state: &AppState, item_id: Uuid) -> AppResult<Uuid> {
|
||||||
|
sqlx::query_scalar::<_, Uuid>("SELECT list_id FROM items WHERE id = $1")
|
||||||
|
.bind(item_id)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await?
|
||||||
|
.ok_or(AppError::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Items ----------------------------------------------------------------
|
||||||
|
|
||||||
async fn list_items(
|
async fn list_items(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
AuthUser(user): AuthUser,
|
AuthUser(user): AuthUser,
|
||||||
Path(list_id): Path<Uuid>,
|
Path(list_id): Path<Uuid>,
|
||||||
) -> AppResult<Json<Vec<Item>>> {
|
) -> AppResult<Json<Vec<Item>>> {
|
||||||
assert_list_owner(&state, list_id, user.id).await?;
|
assert_can_view(&state, list_id, user.id).await?;
|
||||||
let items = sqlx::query_as::<_, Item>(&format!(
|
let items = sqlx::query_as::<_, Item>(&format!(
|
||||||
"SELECT {ITEM_COLS} FROM items WHERE list_id = $1 ORDER BY position, created_at"
|
"SELECT {ITEM_COLS} FROM items WHERE list_id = $1 ORDER BY position, created_at"
|
||||||
))
|
))
|
||||||
@@ -271,7 +328,7 @@ async fn create_item(
|
|||||||
) -> AppResult<Json<Item>> {
|
) -> AppResult<Json<Item>> {
|
||||||
req.validate()
|
req.validate()
|
||||||
.map_err(|e| AppError::Validation(e.to_string()))?;
|
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||||
assert_list_owner(&state, list_id, user.id).await?;
|
assert_can_edit(&state, list_id, user.id).await?;
|
||||||
|
|
||||||
let item = sqlx::query_as::<_, Item>(&format!(
|
let item = sqlx::query_as::<_, Item>(&format!(
|
||||||
"INSERT INTO items (list_id, title, url, note, target_price, position)
|
"INSERT INTO items (list_id, title, url, note, target_price, position)
|
||||||
@@ -323,6 +380,10 @@ async fn update_item(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Owner or editor only (crossers cross off, they don't edit).
|
||||||
|
let list_id = item_list_id(&state, id).await?;
|
||||||
|
assert_can_edit(&state, list_id, user.id).await?;
|
||||||
|
|
||||||
let currency = req
|
let currency = req
|
||||||
.currency
|
.currency
|
||||||
.map(|c| c.trim().to_uppercase())
|
.map(|c| c.trim().to_uppercase())
|
||||||
@@ -332,8 +393,8 @@ async fn update_item(
|
|||||||
let set_target = req.target_price.is_some();
|
let set_target = req.target_price.is_some();
|
||||||
let target_val = req.target_price.flatten();
|
let target_val = req.target_price.flatten();
|
||||||
|
|
||||||
// Ownership enforced via the join to lists.user_id. A currency override
|
// Access already checked above. A currency override both latches
|
||||||
// both latches (currency_override) and takes effect immediately (currency).
|
// (currency_override) and takes effect immediately (currency).
|
||||||
let item = sqlx::query_as::<_, Item>(&format!(
|
let item = sqlx::query_as::<_, Item>(&format!(
|
||||||
"UPDATE items i SET
|
"UPDATE items i SET
|
||||||
title = COALESCE($3, i.title),
|
title = COALESCE($3, i.title),
|
||||||
@@ -345,7 +406,7 @@ async fn update_item(
|
|||||||
currency_override = COALESCE($10, i.currency_override),
|
currency_override = COALESCE($10, i.currency_override),
|
||||||
currency = COALESCE($10, i.currency)
|
currency = COALESCE($10, i.currency)
|
||||||
FROM lists l
|
FROM lists l
|
||||||
WHERE i.id = $1 AND i.list_id = l.id AND l.user_id = $2
|
WHERE i.id = $1 AND i.list_id = l.id
|
||||||
RETURNING {ITEM_COLS_I}"
|
RETURNING {ITEM_COLS_I}"
|
||||||
))
|
))
|
||||||
.bind(id)
|
.bind(id)
|
||||||
@@ -369,14 +430,13 @@ async fn delete_item(
|
|||||||
AuthUser(user): AuthUser,
|
AuthUser(user): AuthUser,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> AppResult<Json<serde_json::Value>> {
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
let res = sqlx::query(
|
let list_id = item_list_id(&state, id).await?;
|
||||||
"DELETE FROM items i USING lists l
|
assert_can_edit(&state, list_id, user.id).await?;
|
||||||
WHERE i.id = $1 AND i.list_id = l.id AND l.user_id = $2",
|
|
||||||
)
|
let res = sqlx::query("DELETE FROM items WHERE id = $1")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.bind(user.id)
|
.execute(&state.pool)
|
||||||
.execute(&state.pool)
|
.await?;
|
||||||
.await?;
|
|
||||||
if res.rows_affected() == 0 {
|
if res.rows_affected() == 0 {
|
||||||
return Err(AppError::NotFound);
|
return Err(AppError::NotFound);
|
||||||
}
|
}
|
||||||
@@ -385,22 +445,20 @@ async fn delete_item(
|
|||||||
|
|
||||||
// ---- Tracking -------------------------------------------------------------
|
// ---- Tracking -------------------------------------------------------------
|
||||||
|
|
||||||
/// Owned item's URL, or NotFound. Inner Option is the (nullable) url.
|
/// A viewable item's URL (owner or any collaborator), or NotFound. Inner
|
||||||
async fn owned_item_url(
|
/// Option is the (nullable) url.
|
||||||
|
async fn viewable_item_url(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
item_id: Uuid,
|
item_id: Uuid,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
) -> AppResult<Option<String>> {
|
) -> AppResult<Option<String>> {
|
||||||
let row = sqlx::query_as::<_, (Option<String>,)>(
|
let list_id = item_list_id(state, item_id).await?;
|
||||||
"SELECT i.url FROM items i JOIN lists l ON l.id = i.list_id
|
assert_can_view(state, list_id, user_id).await?;
|
||||||
WHERE i.id = $1 AND l.user_id = $2",
|
let url = sqlx::query_scalar::<_, Option<String>>("SELECT url FROM items WHERE id = $1")
|
||||||
)
|
.bind(item_id)
|
||||||
.bind(item_id)
|
.fetch_one(&state.pool)
|
||||||
.bind(user_id)
|
.await?;
|
||||||
.fetch_optional(&state.pool)
|
Ok(url)
|
||||||
.await?
|
|
||||||
.ok_or(AppError::NotFound)?;
|
|
||||||
Ok(row.0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Refetch a single item's price on demand. Surfaces fetch errors to the user.
|
/// Refetch a single item's price on demand. Surfaces fetch errors to the user.
|
||||||
@@ -409,7 +467,7 @@ async fn refetch_item(
|
|||||||
AuthUser(user): AuthUser,
|
AuthUser(user): AuthUser,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> AppResult<Json<Item>> {
|
) -> AppResult<Json<Item>> {
|
||||||
let url = owned_item_url(&state, id, user.id).await?.ok_or_else(|| {
|
let url = viewable_item_url(&state, id, user.id).await?.ok_or_else(|| {
|
||||||
AppError::BadRequest("this temptation has no URL to keep vigil over".into())
|
AppError::BadRequest("this temptation has no URL to keep vigil over".into())
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@@ -431,7 +489,7 @@ async fn item_history(
|
|||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> AppResult<Json<Vec<PricePoint>>> {
|
) -> AppResult<Json<Vec<PricePoint>>> {
|
||||||
// Ownership: NotFound if the item isn't the user's.
|
// Ownership: NotFound if the item isn't the user's.
|
||||||
owned_item_url(&state, id, user.id).await?;
|
viewable_item_url(&state, id, user.id).await?;
|
||||||
|
|
||||||
let history = sqlx::query_as::<_, PricePoint>(
|
let history = sqlx::query_as::<_, PricePoint>(
|
||||||
"SELECT price, currency, in_stock, fetched_at
|
"SELECT price, currency, in_stock, fetched_at
|
||||||
@@ -444,6 +502,142 @@ async fn item_history(
|
|||||||
Ok(Json(history))
|
Ok(Json(history))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Cross off / claim ----------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ClaimReq {
|
||||||
|
/// Display name to record. Required for anonymous guests; logged-in users
|
||||||
|
/// fall back to their account name.
|
||||||
|
#[serde(default)]
|
||||||
|
name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set or clear an item's shared claim, returning the updated row.
|
||||||
|
async fn set_claim(
|
||||||
|
state: &AppState,
|
||||||
|
item_id: Uuid,
|
||||||
|
user_id: Option<Uuid>,
|
||||||
|
name: Option<String>,
|
||||||
|
) -> AppResult<Item> {
|
||||||
|
let claimed = name.is_some();
|
||||||
|
let item = sqlx::query_as::<_, Item>(&format!(
|
||||||
|
"UPDATE items SET
|
||||||
|
claimed_at = CASE WHEN $2 THEN now() ELSE NULL END,
|
||||||
|
claimed_by_user_id = $3,
|
||||||
|
claimed_by_name = $4
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING {ITEM_COLS}"
|
||||||
|
))
|
||||||
|
.bind(item_id)
|
||||||
|
.bind(claimed)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(name)
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cross an item off via the list (any collaborator: owner/editor/crosser).
|
||||||
|
async fn claim_item(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthUser(user): AuthUser,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(req): Json<ClaimReq>,
|
||||||
|
) -> AppResult<Json<Item>> {
|
||||||
|
let list_id = item_list_id(&state, id).await?;
|
||||||
|
assert_can_view(&state, list_id, user.id).await?;
|
||||||
|
|
||||||
|
let name = opt_trim(req.name)
|
||||||
|
.or_else(|| user.display_name.clone())
|
||||||
|
.unwrap_or_else(|| user.email.clone());
|
||||||
|
Ok(Json(set_claim(&state, id, Some(user.id), Some(name)).await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn unclaim_item(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthUser(user): AuthUser,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> AppResult<Json<Item>> {
|
||||||
|
let list_id = item_list_id(&state, id).await?;
|
||||||
|
assert_can_view(&state, list_id, user.id).await?;
|
||||||
|
Ok(Json(set_claim(&state, id, None, None).await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if the item belongs to the shared list named by `token` and that list
|
||||||
|
/// has guest cross-off enabled.
|
||||||
|
async fn guest_crossoff_ok(state: &AppState, token: &str, item_id: Uuid) -> AppResult<()> {
|
||||||
|
let ok = sqlx::query_scalar::<_, bool>(
|
||||||
|
"SELECT EXISTS(
|
||||||
|
SELECT 1 FROM items i JOIN lists l ON l.id = i.list_id
|
||||||
|
WHERE i.id = $1 AND l.share_token = $2 AND l.allow_guest_crossoff)",
|
||||||
|
)
|
||||||
|
.bind(item_id)
|
||||||
|
.bind(token)
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await?;
|
||||||
|
if ok {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(AppError::Forbidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cross off via a public share link (anonymous or logged-in). Gated by the
|
||||||
|
/// list's `allow_guest_crossoff` setting.
|
||||||
|
async fn guest_claim(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
OptionalUser(user): OptionalUser,
|
||||||
|
Path((token, item_id)): Path<(String, Uuid)>,
|
||||||
|
Json(req): Json<ClaimReq>,
|
||||||
|
) -> AppResult<Json<Item>> {
|
||||||
|
guest_crossoff_ok(&state, &token, item_id).await?;
|
||||||
|
// Don't leak an account email on a public list; require a typed name if the
|
||||||
|
// user has no display name set.
|
||||||
|
let name = opt_trim(req.name)
|
||||||
|
.or_else(|| user.as_ref().and_then(|u| u.display_name.clone()))
|
||||||
|
.ok_or_else(|| AppError::BadRequest("add your name so others know who claimed it".into()))?;
|
||||||
|
let uid = user.as_ref().map(|u| u.id);
|
||||||
|
Ok(Json(set_claim(&state, item_id, uid, Some(name)).await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn guest_unclaim(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
OptionalUser(_user): OptionalUser,
|
||||||
|
Path((token, item_id)): Path<(String, Uuid)>,
|
||||||
|
) -> AppResult<Json<Item>> {
|
||||||
|
guest_crossoff_ok(&state, &token, item_id).await?;
|
||||||
|
Ok(Json(set_claim(&state, item_id, None, None).await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public price history for an item on a shared list (drives the chart on the
|
||||||
|
/// shared view). Holding the share token is the only credential.
|
||||||
|
async fn shared_item_history(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path((token, item_id)): Path<(String, Uuid)>,
|
||||||
|
) -> AppResult<Json<Vec<PricePoint>>> {
|
||||||
|
let belongs = sqlx::query_scalar::<_, bool>(
|
||||||
|
"SELECT EXISTS(
|
||||||
|
SELECT 1 FROM items i JOIN lists l ON l.id = i.list_id
|
||||||
|
WHERE i.id = $1 AND l.share_token = $2)",
|
||||||
|
)
|
||||||
|
.bind(item_id)
|
||||||
|
.bind(token)
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await?;
|
||||||
|
if !belongs {
|
||||||
|
return Err(AppError::NotFound);
|
||||||
|
}
|
||||||
|
let history = sqlx::query_as::<_, PricePoint>(
|
||||||
|
"SELECT price, currency, in_stock, fetched_at
|
||||||
|
FROM price_history WHERE item_id = $1
|
||||||
|
ORDER BY fetched_at DESC LIMIT 200",
|
||||||
|
)
|
||||||
|
.bind(item_id)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(history))
|
||||||
|
}
|
||||||
|
|
||||||
fn opt_trim(s: Option<String>) -> Option<String> {
|
fn opt_trim(s: Option<String>) -> Option<String> {
|
||||||
s.map(|s| s.trim().to_string()).filter(|s| !s.is_empty())
|
s.map(|s| s.trim().to_string()).filter(|s| !s.is_empty())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use crate::error::{AppError, AppResult};
|
|||||||
use crate::models::UserSettings;
|
use crate::models::UserSettings;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
mod collab;
|
||||||
mod lists;
|
mod lists;
|
||||||
mod subs;
|
mod subs;
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ pub fn router() -> Router<AppState> {
|
|||||||
.route("/settings", patch(update_settings))
|
.route("/settings", patch(update_settings))
|
||||||
.route("/profile", patch(update_profile))
|
.route("/profile", patch(update_profile))
|
||||||
.merge(lists::router())
|
.merge(lists::router())
|
||||||
|
.merge(collab::router())
|
||||||
.merge(subs::router())
|
.merge(subs::router())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,19 +2,46 @@ import { api } from "./api";
|
|||||||
|
|
||||||
export type ItemStatus = "coveted" | "acquired" | "renounced";
|
export type ItemStatus = "coveted" | "acquired" | "renounced";
|
||||||
|
|
||||||
|
export type ListRole = "owner" | "editor" | "crosser";
|
||||||
|
|
||||||
export type List = {
|
export type List = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
emoji: string | null;
|
emoji: string | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
share_token: string | null;
|
share_token: string | null;
|
||||||
|
allow_guest_crossoff: boolean;
|
||||||
position: number;
|
position: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
/** Caller's role on this list; null on public views. */
|
||||||
|
role: ListRole | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SharedView = { list: List; items: Item[] };
|
export type SharedView = { list: List; items: Item[] };
|
||||||
|
|
||||||
|
export type Invite = {
|
||||||
|
id: string;
|
||||||
|
token: string;
|
||||||
|
role: "editor" | "crosser";
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Collaborator = {
|
||||||
|
user_id: string;
|
||||||
|
role: "editor" | "crosser";
|
||||||
|
display_name: string | null;
|
||||||
|
email: string;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InvitePreview = {
|
||||||
|
list_id: string;
|
||||||
|
list_name: string;
|
||||||
|
emoji: string | null;
|
||||||
|
role: "editor" | "crosser";
|
||||||
|
};
|
||||||
|
|
||||||
export type Subscription = {
|
export type Subscription = {
|
||||||
id: string;
|
id: string;
|
||||||
kind: "list" | "item";
|
kind: "list" | "item";
|
||||||
@@ -52,6 +79,9 @@ export type Item = {
|
|||||||
track_enabled: boolean;
|
track_enabled: boolean;
|
||||||
last_error: string | null;
|
last_error: string | null;
|
||||||
checked_at: string | null;
|
checked_at: string | null;
|
||||||
|
// Shared "crossed off"/claim state.
|
||||||
|
claimed_at: string | null;
|
||||||
|
claimed_by_name: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
};
|
};
|
||||||
@@ -81,12 +111,41 @@ export type NewItem = {
|
|||||||
export const listsApi = {
|
export const listsApi = {
|
||||||
all: () => api.get<List[]>("/lists"),
|
all: () => api.get<List[]>("/lists"),
|
||||||
create: (b: NewList) => api.post<List>("/lists", b),
|
create: (b: NewList) => api.post<List>("/lists", b),
|
||||||
update: (id: string, b: Partial<NewList> & { position?: number }) =>
|
update: (
|
||||||
api.patch<List>(`/lists/${id}`, b),
|
id: string,
|
||||||
|
b: Partial<NewList> & { position?: number; allow_guest_crossoff?: boolean },
|
||||||
|
) => api.patch<List>(`/lists/${id}`, b),
|
||||||
remove: (id: string) => api.del<{ deleted: string }>(`/lists/${id}`),
|
remove: (id: string) => api.del<{ deleted: string }>(`/lists/${id}`),
|
||||||
share: (id: string) => api.post<List>(`/lists/${id}/share`, {}),
|
share: (id: string) => api.post<List>(`/lists/${id}/share`, {}),
|
||||||
unshare: (id: string) => api.del<List>(`/lists/${id}/share`),
|
unshare: (id: string) => api.del<List>(`/lists/${id}/share`),
|
||||||
shared: (token: string) => api.get<SharedView>(`/shared/${token}`),
|
shared: (token: string) => api.get<SharedView>(`/shared/${token}`),
|
||||||
|
sharedHistory: (token: string, itemId: string) =>
|
||||||
|
api.get<PricePoint[]>(`/shared/${token}/items/${itemId}/history`),
|
||||||
|
|
||||||
|
// Cross off / claim (collaborator route).
|
||||||
|
claim: (id: string, name?: string) =>
|
||||||
|
api.post<Item>(`/items/${id}/claim`, name ? { name } : {}),
|
||||||
|
unclaim: (id: string) => api.del<Item>(`/items/${id}/claim`),
|
||||||
|
// Cross off via public share link (guest crossoff must be enabled).
|
||||||
|
guestClaim: (token: string, itemId: string, name: string) =>
|
||||||
|
api.post<Item>(`/shared/${token}/items/${itemId}/claim`, { name }),
|
||||||
|
guestUnclaim: (token: string, itemId: string) =>
|
||||||
|
api.del<Item>(`/shared/${token}/items/${itemId}/claim`),
|
||||||
|
|
||||||
|
// Collaboration: invites + collaborators.
|
||||||
|
invites: (listId: string) => api.get<Invite[]>(`/lists/${listId}/invites`),
|
||||||
|
createInvite: (listId: string, role: "editor" | "crosser") =>
|
||||||
|
api.post<Invite>(`/lists/${listId}/invites`, { role }),
|
||||||
|
revokeInvite: (listId: string, inviteId: string) =>
|
||||||
|
api.del<{ deleted: string }>(`/lists/${listId}/invites/${inviteId}`),
|
||||||
|
collaborators: (listId: string) =>
|
||||||
|
api.get<Collaborator[]>(`/lists/${listId}/collaborators`),
|
||||||
|
removeCollaborator: (listId: string, userId: string) =>
|
||||||
|
api.del<{ removed: string }>(`/lists/${listId}/collaborators/${userId}`),
|
||||||
|
previewInvite: (token: string) =>
|
||||||
|
api.get<InvitePreview>(`/invites/${token}`),
|
||||||
|
acceptInvite: (token: string) =>
|
||||||
|
api.post<{ list_id: string; role: string }>(`/invites/${token}/accept`, {}),
|
||||||
|
|
||||||
items: (listId: string) => api.get<Item[]>(`/lists/${listId}/items`),
|
items: (listId: string) => api.get<Item[]>(`/lists/${listId}/items`),
|
||||||
addItem: (listId: string, b: NewItem) =>
|
addItem: (listId: string, b: NewItem) =>
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { ApiError } from '$lib/api';
|
||||||
|
import { auth } from '$lib/auth.svelte';
|
||||||
|
import { lists, listsApi, type InvitePreview } from '$lib/lists.svelte';
|
||||||
|
|
||||||
|
const token = $derived(page.params.token);
|
||||||
|
|
||||||
|
let preview = $state<InvitePreview | null>(null);
|
||||||
|
let loaded = $state(false);
|
||||||
|
let error = $state('');
|
||||||
|
let accepting = $state(false);
|
||||||
|
|
||||||
|
let lastToken = '';
|
||||||
|
$effect(() => {
|
||||||
|
if (token && token !== lastToken) {
|
||||||
|
lastToken = token;
|
||||||
|
load(token);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function load(t: string) {
|
||||||
|
loaded = false;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
preview = await listsApi.previewInvite(t);
|
||||||
|
} catch (e) {
|
||||||
|
error =
|
||||||
|
e instanceof ApiError && e.status === 404
|
||||||
|
? 'this invite is invalid or was revoked'
|
||||||
|
: e instanceof ApiError
|
||||||
|
? e.message
|
||||||
|
: 'failed to load invite';
|
||||||
|
} finally {
|
||||||
|
loaded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleBlurb = (r: string) =>
|
||||||
|
r === 'editor' ? 'add, edit & remove items' : 'cross items off (but not edit them)';
|
||||||
|
|
||||||
|
function login() {
|
||||||
|
goto(`/login?next=${encodeURIComponent(page.url.pathname)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function accept() {
|
||||||
|
if (!token) return;
|
||||||
|
accepting = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const res = await listsApi.acceptInvite(token);
|
||||||
|
// Refresh the lists store so the new collab list shows up immediately.
|
||||||
|
await lists.load();
|
||||||
|
goto(`/lists/${res.list_id}`);
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof ApiError ? e.message : 'failed to accept';
|
||||||
|
accepting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>collaborate · consume·rs</title></svelte:head>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-md">
|
||||||
|
{#if !loaded}
|
||||||
|
<p class="text-center text-mute flicker">loading…</p>
|
||||||
|
{:else if error}
|
||||||
|
<div class="panel p-8 text-center">
|
||||||
|
<p class="gospel text-2xl">invite broken</p>
|
||||||
|
<p class="mt-2 text-mute">{error}</p>
|
||||||
|
<a href="/lists" class="mt-4 inline-block text-iris hover:text-rose">← your lists</a>
|
||||||
|
</div>
|
||||||
|
{:else if preview}
|
||||||
|
<div class="panel p-8">
|
||||||
|
<p class="label">you're invited</p>
|
||||||
|
<div class="mt-2 flex items-start gap-3">
|
||||||
|
<span class="text-4xl leading-none">{preview.emoji ?? '✦'}</span>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h1 class="font-display text-3xl font-bold">{preview.list_name}</h1>
|
||||||
|
<p class="gospel mt-1 text-lg">collaborate as {preview.role}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mt-4 text-sm text-mute">
|
||||||
|
accepting lets you <span class="text-ink">{roleBlurb(preview.role)}</span> on this list.
|
||||||
|
it'll appear under your lists.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if auth.loaded && auth.user}
|
||||||
|
<button class="btn btn-acid mt-6 w-full" disabled={accepting} onclick={accept}>
|
||||||
|
{accepting ? 'joining…' : `join as ${preview.role}`}
|
||||||
|
</button>
|
||||||
|
{:else if auth.loaded}
|
||||||
|
<p class="mt-6 text-sm text-mute">log in or sign up to accept this invite.</p>
|
||||||
|
<button class="btn btn-acid mt-3 w-full" onclick={login}>log in / sign up</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -7,9 +7,12 @@
|
|||||||
import {
|
import {
|
||||||
lists,
|
lists,
|
||||||
listsApi,
|
listsApi,
|
||||||
|
type Collaborator,
|
||||||
|
type Invite,
|
||||||
type Item,
|
type Item,
|
||||||
type ItemStatus,
|
type ItemStatus,
|
||||||
type List,
|
type List,
|
||||||
|
type ListRole,
|
||||||
type PricePoint
|
type PricePoint
|
||||||
} from '$lib/lists.svelte';
|
} from '$lib/lists.svelte';
|
||||||
|
|
||||||
@@ -20,6 +23,12 @@
|
|||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
let loadError = $state('');
|
let loadError = $state('');
|
||||||
|
|
||||||
|
// Role gates the controls: owners see everything, editors can edit items,
|
||||||
|
// crossers can only cross items off.
|
||||||
|
const role = $derived<ListRole>(list?.role ?? 'owner');
|
||||||
|
const canEdit = $derived(role === 'owner' || role === 'editor');
|
||||||
|
const isOwner = $derived(role === 'owner');
|
||||||
|
|
||||||
// form
|
// form
|
||||||
let title = $state('');
|
let title = $state('');
|
||||||
let url = $state('');
|
let url = $state('');
|
||||||
@@ -77,6 +86,112 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cross off / claim
|
||||||
|
let claimBusy = $state<string | null>(null);
|
||||||
|
async function toggleClaim(item: Item) {
|
||||||
|
claimBusy = item.id;
|
||||||
|
formError = '';
|
||||||
|
try {
|
||||||
|
const updated = item.claimed_at
|
||||||
|
? await listsApi.unclaim(item.id)
|
||||||
|
: await listsApi.claim(item.id);
|
||||||
|
const i = items.findIndex((x) => x.id === item.id);
|
||||||
|
if (i >= 0) items[i] = updated;
|
||||||
|
} catch (err) {
|
||||||
|
formError = err instanceof ApiError ? err.message : 'failed to update';
|
||||||
|
} finally {
|
||||||
|
claimBusy = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// list settings + collaboration (owner only)
|
||||||
|
let showSettings = $state(false);
|
||||||
|
let settingsBusy = $state(false);
|
||||||
|
let invites = $state<Invite[]>([]);
|
||||||
|
let collaborators = $state<Collaborator[]>([]);
|
||||||
|
let collabLoaded = $state(false);
|
||||||
|
let copiedInvite = $state<string | null>(null);
|
||||||
|
const inviteUrl = (token: string) => `${page.url.origin}/invite/${token}`;
|
||||||
|
|
||||||
|
async function loadCollab() {
|
||||||
|
if (!list || collabLoaded) return;
|
||||||
|
try {
|
||||||
|
[invites, collaborators] = await Promise.all([
|
||||||
|
listsApi.invites(list.id),
|
||||||
|
listsApi.collaborators(list.id)
|
||||||
|
]);
|
||||||
|
collabLoaded = true;
|
||||||
|
} catch (err) {
|
||||||
|
formError = err instanceof ApiError ? err.message : 'failed to load collaborators';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleSettings() {
|
||||||
|
showSettings = !showSettings;
|
||||||
|
if (showSettings) await loadCollab();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleGuestCrossoff() {
|
||||||
|
if (!list) return;
|
||||||
|
settingsBusy = true;
|
||||||
|
formError = '';
|
||||||
|
try {
|
||||||
|
const updated = await listsApi.update(list.id, {
|
||||||
|
allow_guest_crossoff: !list.allow_guest_crossoff
|
||||||
|
});
|
||||||
|
list = updated;
|
||||||
|
lists.replace(updated);
|
||||||
|
} catch (err) {
|
||||||
|
formError = err instanceof ApiError ? err.message : 'failed to save setting';
|
||||||
|
} finally {
|
||||||
|
settingsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeInvite(r: 'editor' | 'crosser') {
|
||||||
|
if (!list) return;
|
||||||
|
settingsBusy = true;
|
||||||
|
formError = '';
|
||||||
|
try {
|
||||||
|
const inv = await listsApi.createInvite(list.id, r);
|
||||||
|
invites.push(inv);
|
||||||
|
} catch (err) {
|
||||||
|
formError = err instanceof ApiError ? err.message : 'failed to create invite';
|
||||||
|
} finally {
|
||||||
|
settingsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dropInvite(inviteId: string) {
|
||||||
|
if (!list) return;
|
||||||
|
try {
|
||||||
|
await listsApi.revokeInvite(list.id, inviteId);
|
||||||
|
invites = invites.filter((i) => i.id !== inviteId);
|
||||||
|
} catch (err) {
|
||||||
|
formError = err instanceof ApiError ? err.message : 'failed to revoke';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dropCollaborator(userId: string) {
|
||||||
|
if (!list || !confirm('remove this collaborator? they lose access immediately.')) return;
|
||||||
|
try {
|
||||||
|
await listsApi.removeCollaborator(list.id, userId);
|
||||||
|
collaborators = collaborators.filter((c) => c.user_id !== userId);
|
||||||
|
} catch (err) {
|
||||||
|
formError = err instanceof ApiError ? err.message : 'failed to remove';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyInvite(token: string) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(inviteUrl(token));
|
||||||
|
copiedInvite = token;
|
||||||
|
setTimeout(() => (copiedInvite = null), 1500);
|
||||||
|
} catch {
|
||||||
|
/* clipboard blocked */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// inline edit
|
// inline edit
|
||||||
let editingId = $state<string | null>(null);
|
let editingId = $state<string | null>(null);
|
||||||
let edit = $state({ title: '', url: '', note: '', target: '', currency: '' });
|
let edit = $state({ title: '', url: '', note: '', target: '', currency: '' });
|
||||||
@@ -296,19 +411,35 @@
|
|||||||
{#if list?.description}<p class="gospel mt-1 text-lg">{list.description}</p>{/if}
|
{#if list?.description}<p class="gospel mt-1 text-lg">{list.description}</p>{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if list}
|
{#if list}
|
||||||
{#if list.share_token}
|
<div class="flex shrink-0 items-center gap-2">
|
||||||
<button
|
{#if !isOwner}
|
||||||
class="tag shrink-0 border-mint text-mint"
|
<span class="tag border-iris text-iris" title="you collaborate on this list">
|
||||||
title="this list is shared — manage below"
|
{role === 'editor' ? '✎ editor' : '☑ cross-off'}
|
||||||
onclick={() => document.getElementById('share-box')?.scrollIntoView({ behavior: 'smooth' })}
|
</span>
|
||||||
>
|
{/if}
|
||||||
◈ shared
|
{#if isOwner}
|
||||||
</button>
|
{#if list.share_token}
|
||||||
{:else}
|
<button
|
||||||
<button class="tag shrink-0 border-smoke text-mute hover:text-iris" disabled={sharing} onclick={share}>
|
class="tag border-mint text-mint"
|
||||||
{sharing ? '…' : '◈ share'}
|
title="this list is shared — manage below"
|
||||||
</button>
|
onclick={() => document.getElementById('share-box')?.scrollIntoView({ behavior: 'smooth' })}
|
||||||
{/if}
|
>
|
||||||
|
◈ shared
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button class="tag border-smoke text-mute hover:text-iris" disabled={sharing} onclick={share}>
|
||||||
|
{sharing ? '…' : '◈ share'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
class="tag border-smoke text-mute hover:text-iris"
|
||||||
|
title="list settings, invites & collaborators"
|
||||||
|
onclick={toggleSettings}
|
||||||
|
>
|
||||||
|
⚙ settings
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -325,9 +456,86 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if isOwner && showSettings}
|
||||||
|
<div class="panel mt-4 space-y-5 p-4 text-sm">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<p class="label">list settings</p>
|
||||||
|
<button class="text-xs text-mute hover:text-iris" onclick={() => (showSettings = false)}>close</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Guest cross-off -->
|
||||||
|
<div class="flex items-start justify-between gap-4 border-t border-smoke pt-4">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="font-bold">let anyone with the link cross items off</p>
|
||||||
|
<p class="mt-0.5 text-xs text-mute">
|
||||||
|
for gift/birthday lists — visitors don't need an account to mark something as taken.
|
||||||
|
{#if !list?.share_token}<span class="text-rose/80">share the list first to use this.</span>{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="tag shrink-0 {list?.allow_guest_crossoff ? 'border-mint text-mint' : 'border-smoke text-mute hover:text-iris'}"
|
||||||
|
disabled={settingsBusy || !list?.share_token}
|
||||||
|
onclick={toggleGuestCrossoff}
|
||||||
|
>
|
||||||
|
{list?.allow_guest_crossoff ? '● on' : '○ off'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Invite links -->
|
||||||
|
<div class="border-t border-smoke pt-4">
|
||||||
|
<p class="font-bold">invite collaborators</p>
|
||||||
|
<p class="mt-0.5 text-xs text-mute">
|
||||||
|
create a link, then share it. whoever opens it (and logs in) joins with that role.
|
||||||
|
</p>
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
|
<button class="rounded border border-smoke px-3 py-1.5 text-xs text-mute transition hover:border-iris hover:text-iris" disabled={settingsBusy} onclick={() => makeInvite('editor')}>
|
||||||
|
+ editor link <span class="text-mute/70">(add & edit items)</span>
|
||||||
|
</button>
|
||||||
|
<button class="rounded border border-smoke px-3 py-1.5 text-xs text-mute transition hover:border-iris hover:text-iris" disabled={settingsBusy} onclick={() => makeInvite('crosser')}>
|
||||||
|
+ cross-off link <span class="text-mute/70">(only tick items off)</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if invites.length}
|
||||||
|
<ul class="mt-3 space-y-2">
|
||||||
|
{#each invites as inv (inv.id)}
|
||||||
|
<li class="flex flex-wrap items-center gap-2 rounded border border-smoke p-2">
|
||||||
|
<span class="tag shrink-0 {inv.role === 'editor' ? 'border-iris text-iris' : 'border-mint text-mint'}">{inv.role}</span>
|
||||||
|
<input class="field flex-1 text-xs" readonly value={inviteUrl(inv.token)} onclick={(e) => e.currentTarget.select()} />
|
||||||
|
<button class="rounded border border-smoke px-2 py-1 text-xs text-mute hover:border-iris hover:text-iris" onclick={() => copyInvite(inv.token)}>
|
||||||
|
{copiedInvite === inv.token ? '✓' : 'copy'}
|
||||||
|
</button>
|
||||||
|
<button class="rounded border border-smoke px-2 py-1 text-xs text-mute hover:border-rose hover:text-rose" onclick={() => dropInvite(inv.id)}>revoke</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Current collaborators -->
|
||||||
|
{#if collaborators.length}
|
||||||
|
<div class="border-t border-smoke pt-4">
|
||||||
|
<p class="font-bold">collaborators</p>
|
||||||
|
<ul class="mt-2 space-y-2">
|
||||||
|
{#each collaborators as c (c.user_id)}
|
||||||
|
<li class="flex items-center justify-between gap-2 rounded border border-smoke p-2">
|
||||||
|
<span class="min-w-0 truncate">
|
||||||
|
{c.display_name ?? c.email}
|
||||||
|
<span class="tag ml-1 {c.role === 'editor' ? 'border-iris text-iris' : 'border-mint text-mint'}">{c.role}</span>
|
||||||
|
</span>
|
||||||
|
<button class="shrink-0 rounded border border-smoke px-2 py-1 text-xs text-mute hover:border-rose hover:text-rose" onclick={() => dropCollaborator(c.user_id)}>remove</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add item — compact quick-add; tracking details on demand -->
|
<!-- Add item — compact quick-add; tracking details on demand -->
|
||||||
|
{#if canEdit}
|
||||||
<form class="panel space-y-3 p-4" onsubmit={addItem}>
|
<form class="panel space-y-3 p-4" onsubmit={addItem}>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<input class="field" bind:value={title} maxlength="200" placeholder="add an item — what do you want?" />
|
<input class="field" bind:value={title} maxlength="200" placeholder="add an item — what do you want?" />
|
||||||
@@ -351,6 +559,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if formError}<p class="border-2 border-rose bg-rose/10 px-3 py-2 text-sm text-rose">{formError}</p>{/if}
|
{#if formError}<p class="border-2 border-rose bg-rose/10 px-3 py-2 text-sm text-rose">{formError}</p>{/if}
|
||||||
</form>
|
</form>
|
||||||
|
{:else if formError}
|
||||||
|
<p class="border-2 border-rose bg-rose/10 px-3 py-2 text-sm text-rose">{formError}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if !loaded}
|
{#if !loaded}
|
||||||
<p class="text-center text-mute flicker">loading items…</p>
|
<p class="text-center text-mute flicker">loading items…</p>
|
||||||
@@ -377,7 +588,10 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<h3 class="truncate font-display font-bold" class:line-through={item.status === 'renounced'}>
|
<h3
|
||||||
|
class="truncate font-display font-bold"
|
||||||
|
class:line-through={item.status === 'renounced' || !!item.claimed_at}
|
||||||
|
>
|
||||||
{item.title_fetched ?? item.title}
|
{item.title_fetched ?? item.title}
|
||||||
</h3>
|
</h3>
|
||||||
{#if item.in_stock === true}
|
{#if item.in_stock === true}
|
||||||
@@ -406,16 +620,35 @@
|
|||||||
<span class="text-mute" title="last price check">checked {fmtDate(item.checked_at)}</span>
|
<span class="text-mute" title="last price check">checked {fmtDate(item.checked_at)}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{#if item.claimed_at}
|
||||||
|
<p class="mt-1 text-xs font-bold text-mint">
|
||||||
|
☑ taken{#if item.claimed_by_name} by {item.claimed_by_name}{/if}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Status is the primary control: a real, cyclable badge. -->
|
<!-- Right controls: cross-off (everyone) + status (editors). -->
|
||||||
<button
|
<div class="flex shrink-0 flex-col items-end gap-2">
|
||||||
class="tag shrink-0 cursor-pointer transition hover:brightness-125 {STATUS_STYLE[item.status]}"
|
<button
|
||||||
title="click to cycle: want → bought → skip"
|
class="tag cursor-pointer transition hover:brightness-125 {item.claimed_at ? 'border-mint bg-mint/10 text-mint' : 'border-smoke text-mute hover:text-iris'}"
|
||||||
onclick={() => cycleStatus(item)}
|
title={item.claimed_at ? 'crossed off — click to undo' : 'cross off / claim this item'}
|
||||||
>
|
disabled={claimBusy === item.id}
|
||||||
⇄ {STATUS_LABEL[item.status]}
|
onclick={() => toggleClaim(item)}
|
||||||
</button>
|
>
|
||||||
|
{claimBusy === item.id ? '…' : item.claimed_at ? '☑ taken' : '☐ cross off'}
|
||||||
|
</button>
|
||||||
|
{#if canEdit}
|
||||||
|
<button
|
||||||
|
class="tag cursor-pointer transition hover:brightness-125 {STATUS_STYLE[item.status]}"
|
||||||
|
title="click to cycle: want → bought → skip"
|
||||||
|
onclick={() => cycleStatus(item)}
|
||||||
|
>
|
||||||
|
⇄ {STATUS_LABEL[item.status]}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<span class="tag {STATUS_STYLE[item.status]}">{STATUS_LABEL[item.status]}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Line 2: utility actions — plain verbs, real button chrome -->
|
<!-- Line 2: utility actions — plain verbs, real button chrome -->
|
||||||
@@ -437,20 +670,22 @@
|
|||||||
{historyFor === item.id ? 'hide history' : 'history'}
|
{historyFor === item.id ? 'hide history' : 'history'}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<button
|
{#if canEdit}
|
||||||
class="rounded border border-smoke px-2 py-1 text-mute transition hover:border-iris hover:text-iris"
|
<button
|
||||||
title="edit this item"
|
class="rounded border border-smoke px-2 py-1 text-mute transition hover:border-iris hover:text-iris"
|
||||||
onclick={() => (editingId === item.id ? cancelEdit() : startEdit(item))}
|
title="edit this item"
|
||||||
>
|
onclick={() => (editingId === item.id ? cancelEdit() : startEdit(item))}
|
||||||
{editingId === item.id ? 'close' : '✎ edit'}
|
>
|
||||||
</button>
|
{editingId === item.id ? 'close' : '✎ edit'}
|
||||||
<button
|
</button>
|
||||||
class="rounded border border-smoke px-2 py-1 text-mute transition hover:border-rose hover:text-rose"
|
<button
|
||||||
title="remove from this list"
|
class="rounded border border-smoke px-2 py-1 text-mute transition hover:border-rose hover:text-rose"
|
||||||
onclick={() => removeItem(item)}
|
title="remove from this list"
|
||||||
>
|
onclick={() => removeItem(item)}
|
||||||
✕ remove
|
>
|
||||||
</button>
|
✕ remove
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if editingId === item.id}
|
{#if editingId === item.id}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { ApiError } from '$lib/api';
|
import { ApiError } from '$lib/api';
|
||||||
import { auth } from '$lib/auth.svelte';
|
import { auth } from '$lib/auth.svelte';
|
||||||
import { listsApi, subs, type Item, type List } from '$lib/lists.svelte';
|
import PriceChart from '$lib/PriceChart.svelte';
|
||||||
|
import { listsApi, subs, type Item, type List, type PricePoint } from '$lib/lists.svelte';
|
||||||
|
|
||||||
const token = $derived(page.params.token);
|
const token = $derived(page.params.token);
|
||||||
|
|
||||||
@@ -13,6 +14,61 @@
|
|||||||
let loadError = $state('');
|
let loadError = $state('');
|
||||||
let subBusy = $state<string | null>(null);
|
let subBusy = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Cross-off / claim (public route, gated by the list's guest-crossoff setting).
|
||||||
|
const canGuestCross = $derived(!!list?.allow_guest_crossoff);
|
||||||
|
let guestName = $state('');
|
||||||
|
$effect(() => {
|
||||||
|
if (typeof localStorage !== 'undefined' && !guestName) {
|
||||||
|
guestName = localStorage.getItem('guestName') ?? '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let claimBusy = $state<string | null>(null);
|
||||||
|
async function toggleClaim(item: Item) {
|
||||||
|
if (!token) return;
|
||||||
|
const name = (auth.user?.display_name ?? guestName).trim();
|
||||||
|
if (!item.claimed_at && !name) {
|
||||||
|
loadError = 'enter your name first so others know who took it';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (guestName.trim()) localStorage.setItem('guestName', guestName.trim());
|
||||||
|
claimBusy = item.id;
|
||||||
|
loadError = '';
|
||||||
|
try {
|
||||||
|
const updated = item.claimed_at
|
||||||
|
? await listsApi.guestUnclaim(token, item.id)
|
||||||
|
: await listsApi.guestClaim(token, item.id, name);
|
||||||
|
const i = items.findIndex((x) => x.id === item.id);
|
||||||
|
if (i >= 0) items[i] = updated;
|
||||||
|
} catch (e) {
|
||||||
|
loadError = e instanceof ApiError ? e.message : 'failed to update';
|
||||||
|
} finally {
|
||||||
|
claimBusy = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Price history chart (public endpoint).
|
||||||
|
let historyFor = $state<string | null>(null);
|
||||||
|
let history = $state<PricePoint[]>([]);
|
||||||
|
let historyLoading = $state(false);
|
||||||
|
async function toggleHistory(item: Item) {
|
||||||
|
if (!token) return;
|
||||||
|
if (historyFor === item.id) {
|
||||||
|
historyFor = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
historyFor = item.id;
|
||||||
|
history = [];
|
||||||
|
historyLoading = true;
|
||||||
|
try {
|
||||||
|
history = await listsApi.sharedHistory(token, item.id);
|
||||||
|
} catch {
|
||||||
|
/* chart just stays empty */
|
||||||
|
} finally {
|
||||||
|
historyLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let lastToken = '';
|
let lastToken = '';
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (token && token !== lastToken) {
|
if (token && token !== lastToken) {
|
||||||
@@ -144,6 +200,17 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if canGuestCross}
|
||||||
|
<div class="panel flex flex-wrap items-center gap-2 p-3 text-sm">
|
||||||
|
<span class="label shrink-0">cross items off</span>
|
||||||
|
{#if auth.user}
|
||||||
|
<span class="text-mute">you can tick items as <span class="text-mint">taken</span> below.</span>
|
||||||
|
{:else}
|
||||||
|
<input class="field flex-1" bind:value={guestName} maxlength="80" placeholder="your name (so others don't double-buy)" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if visible.length === 0}
|
{#if visible.length === 0}
|
||||||
<div class="panel p-10 text-center">
|
<div class="panel p-10 text-center">
|
||||||
<p class="gospel text-2xl">nothing here yet</p>
|
<p class="gospel text-2xl">nothing here yet</p>
|
||||||
@@ -153,59 +220,95 @@
|
|||||||
<ul class="space-y-3">
|
<ul class="space-y-3">
|
||||||
{#each visible as item (item.id)}
|
{#each visible as item (item.id)}
|
||||||
<li
|
<li
|
||||||
class="panel flex items-start gap-4 p-4"
|
class="panel flex flex-col gap-3 p-4"
|
||||||
class:ring-1={onSale(item)}
|
class:ring-1={onSale(item)}
|
||||||
class:ring-mint={onSale(item)}
|
class:ring-mint={onSale(item)}
|
||||||
>
|
>
|
||||||
{#if item.image_url}
|
<div class="flex items-start gap-4">
|
||||||
<img src={item.image_url} alt="" class="size-14 shrink-0 rounded-lg object-cover" />
|
{#if item.image_url}
|
||||||
{/if}
|
<img src={item.image_url} alt="" class="size-14 shrink-0 rounded-lg object-cover" />
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<h3 class="truncate font-display font-bold">{item.title_fetched ?? item.title}</h3>
|
|
||||||
{#if item.in_stock === true}
|
|
||||||
<span class="tag shrink-0 border-mint text-mint">in stock</span>
|
|
||||||
{:else if item.in_stock === false}
|
|
||||||
<span class="tag shrink-0 border-rose text-rose">sold out</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-mute">
|
|
||||||
{#if money(item.current_price, item.currency)}
|
|
||||||
<span class="text-sm font-bold" class:text-mint={onSale(item)} class:text-ink={!onSale(item)}>
|
|
||||||
{money(item.current_price, item.currency)}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{#if item.target_price != null}
|
|
||||||
<span>target {money(item.target_price, item.currency)}</span>
|
|
||||||
{/if}
|
|
||||||
{#if item.url}
|
|
||||||
<a href={item.url} target="_blank" rel="noopener noreferrer" class="hover:text-iris">visit ↗</a>
|
|
||||||
{/if}
|
|
||||||
{#if item.note}<span class="italic">“{item.note}”</span>{/if}
|
|
||||||
</div>
|
|
||||||
{#if onSale(item)}
|
|
||||||
<p class="gospel mt-1 text-sm text-mint">✦ on sale — below target</p>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h3 class="truncate font-display font-bold" class:line-through={!!item.claimed_at}>
|
||||||
|
{item.title_fetched ?? item.title}
|
||||||
|
</h3>
|
||||||
|
{#if item.in_stock === true}
|
||||||
|
<span class="tag shrink-0 border-mint text-mint">in stock</span>
|
||||||
|
{:else if item.in_stock === false}
|
||||||
|
<span class="tag shrink-0 border-rose text-rose">sold out</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-mute">
|
||||||
|
{#if money(item.current_price, item.currency)}
|
||||||
|
<span class="text-sm font-bold" class:text-mint={onSale(item)} class:text-ink={!onSale(item)}>
|
||||||
|
{money(item.current_price, item.currency)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if item.target_price != null}
|
||||||
|
<span>target {money(item.target_price, item.currency)}</span>
|
||||||
|
{/if}
|
||||||
|
{#if item.url}
|
||||||
|
<a href={item.url} target="_blank" rel="noopener noreferrer" class="hover:text-iris">visit ↗</a>
|
||||||
|
{/if}
|
||||||
|
{#if item.note}<span class="italic">“{item.note}”</span>{/if}
|
||||||
|
{#if item.url}
|
||||||
|
<button class="hover:text-iris" onclick={() => toggleHistory(item)}>
|
||||||
|
{historyFor === item.id ? 'hide chart' : 'price chart'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if item.claimed_at}
|
||||||
|
<p class="mt-1 text-xs font-bold text-mint">
|
||||||
|
☑ taken{#if item.claimed_by_name} by {item.claimed_by_name}{/if}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if onSale(item)}
|
||||||
|
<p class="gospel mt-1 text-sm text-mint">✦ on sale — below target</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex shrink-0 flex-col items-end gap-2">
|
||||||
|
{#if canGuestCross}
|
||||||
|
<button
|
||||||
|
class="tag cursor-pointer transition hover:brightness-125 {item.claimed_at ? 'border-mint bg-mint/10 text-mint' : 'border-smoke text-mute hover:text-iris'}"
|
||||||
|
title={item.claimed_at ? 'crossed off — click to undo' : 'cross off / claim this item'}
|
||||||
|
disabled={claimBusy === item.id}
|
||||||
|
onclick={() => toggleClaim(item)}
|
||||||
|
>
|
||||||
|
{claimBusy === item.id ? '…' : item.claimed_at ? '☑ taken' : '☐ cross off'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if auth.user && subs.forItem(item.id)}
|
||||||
|
<button
|
||||||
|
class="tag border-mint text-mint hover:brightness-125"
|
||||||
|
disabled={subBusy === `item:${item.id}`}
|
||||||
|
onclick={() => toggleItem(item)}
|
||||||
|
title="following this item — click to stop"
|
||||||
|
>
|
||||||
|
★
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="tag border-smoke text-mute hover:border-iris hover:text-iris"
|
||||||
|
disabled={subBusy === `item:${item.id}`}
|
||||||
|
onclick={() => toggleItem(item)}
|
||||||
|
title="follow this item for price-drop alerts"
|
||||||
|
>
|
||||||
|
☆ follow
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if auth.user && subs.forItem(item.id)}
|
{#if historyFor === item.id}
|
||||||
<button
|
<div class="border-t border-smoke pt-3">
|
||||||
class="tag shrink-0 self-start border-mint text-mint hover:brightness-125"
|
{#if historyLoading}
|
||||||
disabled={subBusy === `item:${item.id}`}
|
<p class="text-xs text-mute flicker">loading chart…</p>
|
||||||
onclick={() => toggleItem(item)}
|
{:else}
|
||||||
title="following this item — click to stop"
|
<PriceChart {history} target={item.target_price} currency={item.currency} />
|
||||||
>
|
{/if}
|
||||||
★
|
</div>
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<button
|
|
||||||
class="tag shrink-0 self-start border-smoke text-mute hover:border-iris hover:text-iris"
|
|
||||||
disabled={subBusy === `item:${item.id}`}
|
|
||||||
onclick={() => toggleItem(item)}
|
|
||||||
title="follow this item for price-drop alerts"
|
|
||||||
>
|
|
||||||
☆ follow
|
|
||||||
</button>
|
|
||||||
{/if}
|
{/if}
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
Reference in New Issue
Block a user