Files
consume-rs/frontend/src/routes/lists/[id]/+page.svelte
T

745 lines
28 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 PriceChart from '$lib/PriceChart.svelte';
import {
lists,
listsApi,
type Collaborator,
type Invite,
type Item,
type ItemStatus,
type List,
type ListRole,
type PricePoint
} from '$lib/lists.svelte';
const id = $derived(page.params.id);
let list = $state<List | null>(null);
let items = $state<Item[]>([]);
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('');
let note = $state('');
let targetPrice = $state('');
let busy = $state(false);
let formError = $state('');
let showDetails = $state(false);
// sharing
let sharing = $state(false);
let copied = $state(false);
const shareUrl = $derived(
list?.share_token ? `${page.url.origin}/shared/${list.share_token}` : ''
);
async function share() {
if (!list) return;
sharing = true;
formError = '';
try {
const updated = await listsApi.share(list.id);
list = updated;
lists.replace(updated);
} catch (err) {
formError = err instanceof ApiError ? err.message : 'failed to share';
} finally {
sharing = false;
}
}
async function unshare() {
if (!list || !confirm('revoke the share link? anyone holding it loses access.')) return;
sharing = true;
formError = '';
try {
const updated = await listsApi.unshare(list.id);
list = updated;
lists.replace(updated);
} catch (err) {
formError = err instanceof ApiError ? err.message : 'failed to unshare';
} finally {
sharing = false;
}
}
async function copyShare() {
if (!shareUrl) return;
try {
await navigator.clipboard.writeText(shareUrl);
copied = true;
setTimeout(() => (copied = false), 1500);
} catch {
/* clipboard blocked — the field is selectable as a fallback */
}
}
// 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: '' });
let editError = $state('');
let editBusy = $state(false);
// tracking
let refetchingId = $state<string | null>(null);
let historyFor = $state<string | null>(null);
let history = $state<PricePoint[]>([]);
let historyLoading = $state(false);
let lastId = '';
$effect(() => {
if (auth.loaded && !auth.user) {
goto('/login');
return;
}
if (auth.loaded && auth.user && id && id !== lastId) {
lastId = id;
load(id);
}
});
async function load(listId: string) {
loaded = false;
loadError = '';
try {
if (!lists.loaded) await lists.load();
list = lists.items.find((l) => l.id === listId) ?? null;
items = await listsApi.items(listId);
} catch (e) {
loadError = e instanceof ApiError ? e.message : 'failed to load';
} finally {
loaded = true;
}
}
async function addItem(e: SubmitEvent) {
e.preventDefault();
if (!title.trim() || !id) return;
formError = '';
busy = true;
try {
const tp = targetPrice.trim() ? Number(targetPrice) : null;
const created = await listsApi.addItem(id, {
title,
url: url.trim() || null,
note: note.trim() || null,
target_price: Number.isFinite(tp as number) ? tp : null
});
items.push(created);
title = '';
url = '';
note = '';
targetPrice = '';
} catch (err) {
formError = err instanceof ApiError ? err.message : 'failed to add';
} finally {
busy = false;
}
}
const cycle: Record<ItemStatus, ItemStatus> = {
coveted: 'acquired',
acquired: 'renounced',
renounced: 'coveted'
};
async function cycleStatus(item: Item) {
const next = cycle[item.status];
try {
const updated = await listsApi.updateItem(item.id, { status: next });
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';
}
}
function startEdit(item: Item) {
editingId = item.id;
editError = '';
edit = {
title: item.title,
url: item.url ?? '',
note: item.note ?? '',
target: item.target_price != null ? String(item.target_price) : '',
currency: item.currency ?? ''
};
}
function cancelEdit() {
editingId = null;
editError = '';
}
async function saveEdit(item: Item) {
if (!edit.title.trim()) {
editError = 'title is required';
return;
}
editBusy = true;
editError = '';
try {
const t = edit.target.trim();
const tp = t ? Number(t) : null; // null clears the target → notify on any drop
if (t && !Number.isFinite(tp as number)) {
editError = 'target must be a number';
return;
}
const cur = edit.currency.trim().toUpperCase();
const updated = await listsApi.updateItem(item.id, {
title: edit.title.trim(),
url: edit.url.trim() || null,
note: edit.note.trim() || null,
target_price: tp,
...(cur ? { currency: cur } : {})
});
const i = items.findIndex((x) => x.id === item.id);
if (i >= 0) items[i] = updated;
editingId = null;
} catch (err) {
editError = err instanceof ApiError ? err.message : 'failed to save';
} finally {
editBusy = false;
}
}
async function removeItem(item: Item) {
if (!confirm(`remove “${item.title}”?`)) return;
try {
await listsApi.removeItem(item.id);
items = items.filter((x) => x.id !== item.id);
} catch (err) {
formError = err instanceof ApiError ? err.message : 'failed to delete';
}
}
async function refetchItem(item: Item) {
refetchingId = item.id;
formError = '';
try {
const updated = await listsApi.refetch(item.id);
const i = items.findIndex((x) => x.id === item.id);
if (i >= 0) items[i] = updated;
if (historyFor === item.id) history = await listsApi.history(item.id);
} catch (err) {
formError = err instanceof ApiError ? err.message : 'failed to check price';
} finally {
refetchingId = null;
}
}
async function toggleHistory(item: Item) {
if (historyFor === item.id) {
historyFor = null;
return;
}
historyFor = item.id;
history = [];
historyLoading = true;
try {
history = await listsApi.history(item.id);
} catch (err) {
formError = err instanceof ApiError ? err.message : 'failed to load history';
} finally {
historyLoading = false;
}
}
// A want is "answered" when its watched price falls to/under the target.
function onSale(item: Item): boolean {
return (
item.current_price != null &&
item.target_price != null &&
item.current_price <= item.target_price
);
}
function fmtDate(s: string): string {
return new Date(s).toLocaleDateString('en-GB', {
day: '2-digit',
month: 'short',
hour: '2-digit',
minute: '2-digit'
});
}
const STATUS_STYLE: Record<ItemStatus, string> = {
coveted: 'border-iris text-iris',
acquired: 'border-mint text-mint',
renounced: 'border-smoke text-mute'
};
const STATUS_LABEL: Record<ItemStatus, string> = {
coveted: 'want',
acquired: 'bought',
renounced: 'skip'
};
function money(v: number | null, cur: string | null) {
if (v == null) return null;
return `${cur ?? 'EUR'} ${v.toFixed(2)}`;
}
</script>
<svelte:head><title>{list?.name ?? 'list'} · consume·rs</title></svelte:head>
{#if auth.loaded && auth.user}
<section class="space-y-8">
<div>
<a href="/lists" class="label hover:text-iris">← all lists</a>
<div class="mt-2 flex items-start gap-3">
<span class="text-4xl leading-none">{list?.emoji ?? '✦'}</span>
<div class="min-w-0 flex-1">
<h1 class="font-display text-4xl font-bold">{list?.name ?? '…'}</h1>
{#if list?.description}<p class="gospel mt-1 text-lg">{list.description}</p>{/if}
</div>
{#if list}
<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>
{#if list?.share_token}
<div id="share-box" class="panel mt-4 flex flex-wrap items-center gap-2 p-3 text-sm">
<span class="label shrink-0">public link</span>
<input class="field flex-1 text-xs" readonly value={shareUrl} onclick={(e) => e.currentTarget.select()} />
<button class="rounded border border-smoke px-3 py-1.5 text-xs text-mute transition hover:border-iris hover:text-iris" onclick={copyShare}>
{copied ? '✓ copied' : 'copy'}
</button>
<a href={shareUrl} target="_blank" rel="noopener noreferrer" class="rounded border border-smoke px-3 py-1.5 text-xs text-mute transition hover:border-iris hover:text-iris">open ↗</a>
<button class="rounded border border-smoke px-3 py-1.5 text-xs text-mute transition hover:border-rose hover:text-rose" disabled={sharing} onclick={unshare}>
{sharing ? '…' : 'unshare'}
</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?" />
<button class="btn btn-acid shrink-0" disabled={busy || !title.trim()}>{busy ? '…' : 'add +'}</button>
</div>
<button
type="button"
class="label transition hover:text-iris"
onclick={() => (showDetails = !showDetails)}
>
{showDetails ? ' fewer' : '+ link, target price & note'}
</button>
{#if showDetails}
<div class="space-y-3 border-t border-smoke pt-3">
<div class="grid gap-3 sm:grid-cols-[1fr_8rem]">
<input class="field" bind:value={url} placeholder="product link (we'll track the price)" />
<input class="field" bind:value={targetPrice} inputmode="decimal" placeholder="target price" />
</div>
<input class="field" bind:value={note} maxlength="1000" placeholder="note to self (optional)" />
</div>
{/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>
{:else if loadError}
<p class="border-2 border-rose bg-rose/10 px-3 py-2 text-sm text-rose">{loadError}</p>
{:else if items.length === 0}
<div class="panel p-10 text-center">
<p class="gospel text-2xl">this list is empty</p>
<p class="mt-2 text-mute">add something you want above to begin.</p>
</div>
{:else}
<ul class="space-y-3">
{#each items as item (item.id)}
<li
class="panel flex flex-col gap-3 p-4"
class:opacity-60={item.status === 'renounced'}
class:ring-1={onSale(item)}
class:ring-mint={onSale(item)}
>
<!-- Line 1: identity + state -->
<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.status === 'renounced' || !!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" title="open product page">visit ↗</a>
{/if}
{#if item.note}<span class="italic">{item.note}</span>{/if}
{#if !item.url && item.current_price == null}
<span class="text-mute">not tracked</span>
{/if}
{#if item.checked_at}
<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>
<!-- 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 -->
<div class="flex flex-wrap items-center justify-end gap-2 text-xs">
{#if item.url}
<button
class="rounded border border-smoke px-2 py-1 text-mute transition hover:border-iris hover:text-iris disabled:opacity-40"
title="check the price now"
disabled={refetchingId === item.id}
onclick={() => refetchItem(item)}
>
{refetchingId === item.id ? '↻ checking…' : '↻ refresh'}
</button>
<button
class="rounded border border-smoke px-2 py-1 text-mute transition hover:border-iris hover:text-iris"
title="price history"
onclick={() => toggleHistory(item)}
>
{historyFor === item.id ? 'hide history' : 'history'}
</button>
{/if}
{#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}
<div class="space-y-3 border-t border-smoke pt-3">
<input class="field" bind:value={edit.title} maxlength="200" placeholder="title" />
<input class="field" bind:value={edit.url} placeholder="product URL" />
<div class="grid gap-3 sm:grid-cols-[1fr_8rem]">
<input
class="field"
bind:value={edit.target}
inputmode="decimal"
placeholder="target price (blank = notify on any drop)"
/>
<input
class="field"
bind:value={edit.currency}
maxlength="3"
placeholder="currency"
title="3-letter code, e.g. EUR — overrides the detected currency"
/>
</div>
<input class="field" bind:value={edit.note} maxlength="1000" placeholder="note" />
{#if editError}<p class="text-xs text-rose">{editError}</p>{/if}
<div class="flex justify-end gap-2 text-xs">
<button class="rounded border border-smoke px-3 py-1 text-mute hover:text-ink" onclick={cancelEdit}>cancel</button>
<button class="btn btn-acid px-3 py-1" disabled={editBusy} onclick={() => saveEdit(item)}>
{editBusy ? 'saving…' : 'save'}
</button>
</div>
</div>
{/if}
{#if onSale(item)}
<p class="gospel text-sm text-mint">✦ price dropped — grab it now</p>
{/if}
{#if item.last_error}
<p class="text-xs text-rose">price check failed: {item.last_error}</p>
{/if}
{#if historyFor === item.id}
<div class="border-t border-smoke pt-3">
{#if historyLoading}
<p class="text-xs text-mute flicker">loading history…</p>
{:else}
<PriceChart {history} target={item.target_price} currency={item.currency} />
{/if}
</div>
{/if}
</li>
{/each}
</ul>
{/if}
</section>
{:else}
<p class="text-center text-mute flicker">loading…</p>
{/if}