added more share options and collabs
This commit is contained in:
@@ -2,19 +2,46 @@ import { api } from "./api";
|
||||
|
||||
export type ItemStatus = "coveted" | "acquired" | "renounced";
|
||||
|
||||
export type ListRole = "owner" | "editor" | "crosser";
|
||||
|
||||
export type List = {
|
||||
id: string;
|
||||
name: string;
|
||||
emoji: string | null;
|
||||
description: string | null;
|
||||
share_token: string | null;
|
||||
allow_guest_crossoff: boolean;
|
||||
position: number;
|
||||
created_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 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 = {
|
||||
id: string;
|
||||
kind: "list" | "item";
|
||||
@@ -52,6 +79,9 @@ export type Item = {
|
||||
track_enabled: boolean;
|
||||
last_error: string | null;
|
||||
checked_at: string | null;
|
||||
// Shared "crossed off"/claim state.
|
||||
claimed_at: string | null;
|
||||
claimed_by_name: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
@@ -81,12 +111,41 @@ export type NewItem = {
|
||||
export const listsApi = {
|
||||
all: () => api.get<List[]>("/lists"),
|
||||
create: (b: NewList) => api.post<List>("/lists", b),
|
||||
update: (id: string, b: Partial<NewList> & { position?: number }) =>
|
||||
api.patch<List>(`/lists/${id}`, b),
|
||||
update: (
|
||||
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}`),
|
||||
share: (id: string) => api.post<List>(`/lists/${id}/share`, {}),
|
||||
unshare: (id: string) => api.del<List>(`/lists/${id}/share`),
|
||||
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`),
|
||||
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 {
|
||||
lists,
|
||||
listsApi,
|
||||
type Collaborator,
|
||||
type Invite,
|
||||
type Item,
|
||||
type ItemStatus,
|
||||
type List,
|
||||
type ListRole,
|
||||
type PricePoint
|
||||
} from '$lib/lists.svelte';
|
||||
|
||||
@@ -20,6 +23,12 @@
|
||||
let loaded = $state(false);
|
||||
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
|
||||
let title = $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
|
||||
let editingId = $state<string | null>(null);
|
||||
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}
|
||||
</div>
|
||||
{#if list}
|
||||
{#if list.share_token}
|
||||
<button
|
||||
class="tag shrink-0 border-mint text-mint"
|
||||
title="this list is shared — manage below"
|
||||
onclick={() => document.getElementById('share-box')?.scrollIntoView({ behavior: 'smooth' })}
|
||||
>
|
||||
◈ shared
|
||||
</button>
|
||||
{:else}
|
||||
<button class="tag shrink-0 border-smoke text-mute hover:text-iris" disabled={sharing} onclick={share}>
|
||||
{sharing ? '…' : '◈ share'}
|
||||
</button>
|
||||
{/if}
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
{#if !isOwner}
|
||||
<span class="tag border-iris text-iris" title="you collaborate on this list">
|
||||
{role === 'editor' ? '✎ editor' : '☑ cross-off'}
|
||||
</span>
|
||||
{/if}
|
||||
{#if isOwner}
|
||||
{#if list.share_token}
|
||||
<button
|
||||
class="tag border-mint text-mint"
|
||||
title="this list is shared — manage below"
|
||||
onclick={() => document.getElementById('share-box')?.scrollIntoView({ behavior: 'smooth' })}
|
||||
>
|
||||
◈ 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}
|
||||
</div>
|
||||
|
||||
@@ -325,9 +456,86 @@
|
||||
</button>
|
||||
</div>
|
||||
{/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>
|
||||
|
||||
<!-- Add item — compact quick-add; tracking details on demand -->
|
||||
{#if canEdit}
|
||||
<form class="panel space-y-3 p-4" onsubmit={addItem}>
|
||||
<div class="flex gap-2">
|
||||
<input class="field" bind:value={title} maxlength="200" placeholder="add an item — what do you want?" />
|
||||
@@ -351,6 +559,9 @@
|
||||
{/if}
|
||||
{#if formError}<p class="border-2 border-rose bg-rose/10 px-3 py-2 text-sm text-rose">{formError}</p>{/if}
|
||||
</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}
|
||||
<p class="text-center text-mute flicker">loading items…</p>
|
||||
@@ -377,7 +588,10 @@
|
||||
{/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.status === 'renounced'}>
|
||||
<h3
|
||||
class="truncate font-display font-bold"
|
||||
class:line-through={item.status === 'renounced' || !!item.claimed_at}
|
||||
>
|
||||
{item.title_fetched ?? item.title}
|
||||
</h3>
|
||||
{#if item.in_stock === true}
|
||||
@@ -406,16 +620,35 @@
|
||||
<span class="text-mute" title="last price check">checked {fmtDate(item.checked_at)}</span>
|
||||
{/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}
|
||||
</div>
|
||||
|
||||
<!-- Status is the primary control: a real, cyclable badge. -->
|
||||
<button
|
||||
class="tag shrink-0 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>
|
||||
<!-- Right controls: cross-off (everyone) + status (editors). -->
|
||||
<div class="flex shrink-0 flex-col items-end gap-2">
|
||||
<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 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>
|
||||
|
||||
<!-- Line 2: utility actions — plain verbs, real button chrome -->
|
||||
@@ -437,20 +670,22 @@
|
||||
{historyFor === item.id ? 'hide history' : 'history'}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="rounded border border-smoke px-2 py-1 text-mute transition hover:border-iris hover:text-iris"
|
||||
title="edit this item"
|
||||
onclick={() => (editingId === item.id ? cancelEdit() : startEdit(item))}
|
||||
>
|
||||
{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"
|
||||
title="remove from this list"
|
||||
onclick={() => removeItem(item)}
|
||||
>
|
||||
✕ remove
|
||||
</button>
|
||||
{#if canEdit}
|
||||
<button
|
||||
class="rounded border border-smoke px-2 py-1 text-mute transition hover:border-iris hover:text-iris"
|
||||
title="edit this item"
|
||||
onclick={() => (editingId === item.id ? cancelEdit() : startEdit(item))}
|
||||
>
|
||||
{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"
|
||||
title="remove from this list"
|
||||
onclick={() => removeItem(item)}
|
||||
>
|
||||
✕ remove
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if editingId === item.id}
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import { page } from '$app/state';
|
||||
import { ApiError } from '$lib/api';
|
||||
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);
|
||||
|
||||
@@ -13,6 +14,61 @@
|
||||
let loadError = $state('');
|
||||
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 = '';
|
||||
$effect(() => {
|
||||
if (token && token !== lastToken) {
|
||||
@@ -144,6 +200,17 @@
|
||||
</p>
|
||||
</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}
|
||||
<div class="panel p-10 text-center">
|
||||
<p class="gospel text-2xl">nothing here yet</p>
|
||||
@@ -153,59 +220,95 @@
|
||||
<ul class="space-y-3">
|
||||
{#each visible as item (item.id)}
|
||||
<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-mint={onSale(item)}
|
||||
>
|
||||
{#if item.image_url}
|
||||
<img src={item.image_url} alt="" class="size-14 shrink-0 rounded-lg object-cover" />
|
||||
{/if}
|
||||
<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>
|
||||
<div class="flex items-start gap-4">
|
||||
{#if item.image_url}
|
||||
<img src={item.image_url} alt="" class="size-14 shrink-0 rounded-lg object-cover" />
|
||||
{/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>
|
||||
|
||||
{#if auth.user && subs.forItem(item.id)}
|
||||
<button
|
||||
class="tag shrink-0 self-start 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 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 historyFor === item.id}
|
||||
<div class="border-t border-smoke pt-3">
|
||||
{#if historyLoading}
|
||||
<p class="text-xs text-mute flicker">loading chart…</p>
|
||||
{:else}
|
||||
<PriceChart {history} target={item.target_price} currency={item.currency} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
|
||||
Reference in New Issue
Block a user