745 lines
28 KiB
Svelte
745 lines
28 KiB
Svelte
<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}
|