This commit is contained in:
2026-06-17 10:59:45 +02:00
parent 408e48c568
commit a2ccec4bb1
35 changed files with 2514 additions and 257 deletions
+336
View File
@@ -0,0 +1,336 @@
<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 Item,
type ItemStatus,
type List,
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('');
// form
let title = $state('');
let url = $state('');
let note = $state('');
let targetPrice = $state('');
let busy = $state(false);
let formError = $state('');
// 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';
}
}
async function removeItem(item: Item) {
if (!confirm(`cast out “${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 keep vigil';
} 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 read the chronicle';
} 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: 'coveted',
acquired: 'acquired',
renounced: 'renounced'
};
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>
<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>
</div>
</div>
<!-- Add temptation -->
<form class="panel panel-acid space-y-4 p-6" onsubmit={addItem}>
<p class="label">add a temptation</p>
<input class="field" bind:value={title} maxlength="200" placeholder="what you covet" />
<div class="grid gap-4 sm:grid-cols-[1fr_8rem]">
<input class="field" bind:value={url} placeholder="product URL (we'll keep vigil on 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)" />
{#if formError}<p class="border-2 border-rose bg-rose/10 px-3 py-2 text-sm text-rose">{formError}</p>{/if}
<button class="btn btn-acid" disabled={busy}>{busy ? 'coveting…' : 'covet it +'}</button>
</form>
{#if !loaded}
<p class="text-center text-mute flicker">unveiling temptations…</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 bare</p>
<p class="mt-2 text-mute">paste a craving 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.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-mint={onSale(item)} class:text-ink={!onSale(item)}>
now {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>
</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: coveted → acquired → renounced"
onclick={() => cycleStatus(item)}
>
{STATUS_LABEL[item.status]}
</button>
</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="refetch price now (keep vigil)"
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 (the chronicle)"
onclick={() => toggleHistory(item)}
>
{historyFor === item.id ? 'hide history' : 'history'}
</button>
{/if}
<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>
</div>
{#if onSale(item)}
<p class="gospel text-sm text-mint">✦ the price has fallen — your moment is upon you</p>
{/if}
{#if item.last_error}
<p class="text-xs text-rose">vigil faltered: {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">unrolling the chronicle…</p>
{:else if history.length === 0}
<p class="text-xs text-mute">no observations yet — keep vigil to begin the record.</p>
{:else}
<ul class="space-y-1 text-xs">
{#each history as h}
<li class="flex items-center justify-between gap-3">
<span class="text-ink">{money(h.price, h.currency)}</span>
{#if h.in_stock === false}<span class="text-rose">sold out</span>{/if}
<span class="text-mute">{fmtDate(h.fetched_at)}</span>
</li>
{/each}
</ul>
{/if}
</div>
{/if}
</li>
{/each}
</ul>
{/if}
</section>
{:else}
<p class="text-center text-mute flicker">loading…</p>
{/if}