added more share options and collabs

This commit is contained in:
2026-06-18 00:12:02 +02:00
parent ede3ad2cee
commit d6d61df86a
10 changed files with 1125 additions and 138 deletions
+271 -36
View File
@@ -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}