feat(fase-18): /collective/manage — suggested titles subsection (default + catalog)

Adds the admin-side surface for the Fase 18 list-title flow:

  • `default_list_title` input — single line, save-on-blur (matches the
    collective-name input shape from Fase 10.2). Optimistically patches
    `currentCollective` so the next /lists modal prefill sees the value
    without waiting for the realtime UPDATE to round-trip.
  • Curated catalog list — one row per entry with an inline "Remove"
    icon button. An "Add title" button opens a small modal (name input
    + Save) that calls `add_list_title` via the listTitles store.

Members see the whole section read-only — the input is `disabled`, the
"Add title" button is hidden, and a banner explains "Only admins can
curate these titles." Guests don't see the section at all (mirrors the
`canSeeCommonItems` gate from Fase 15).

The default-title `$effect` that mirrors `$currentCollective.default_list_title`
into the input draft is guarded against re-firing while the admin is
mid-edit — only sync when the input isn't focused — so realtime UPDATEs
from another tab don't clobber the unsaved edit.

E2E `tests/e2e/list-title-flow.test.ts` gains:
  • LTF-02 — Ana adds the catalog prefix via the manage UI, sets the
    default via the same UI, then drives the create-modal three times
    and asserts the third list lands as `${prefix} #3`. Exercises the
    full path: manage RPC → realtime/store update → modal auto-suffix
    on submit → numbered list created.
  • LTF-04 — Borja (member) opens manage, sees the section, no add
    button, read-only banner visible, default-title input disabled.

Tests: 4 LTF e2e + 4 existing manage-collective e2e all green (8/8).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 02:33:36 +02:00
parent 4ce24a4145
commit c0db382ae2
2 changed files with 356 additions and 0 deletions

View File

@@ -15,6 +15,13 @@
setWeight as setCommonItemWeight,
purge as purgeCommonItem
} from '$lib/stores/commonItems';
import {
listTitleCatalog,
loadTitleCatalog,
addTitle as addCatalogTitle,
removeTitle as removeCatalogTitle,
setDefaultListTitle
} from '$lib/stores/listTitles';
import * as m from '$lib/paraglide/messages';
type Member = {
@@ -51,6 +58,7 @@
if (c) {
void loadMembers();
void loadCommonItems(c.id);
void loadTitleCatalog(c.id);
}
});
return unsub;
@@ -432,6 +440,103 @@
return d.toLocaleDateString();
}
// ── Fase 18: list-title catalog (default + curated suggestions) ─────────
// Admin writes via the RPCs in `$lib/stores/listTitles`. Members + guests
// see the section read-only (the surface mirrors the common-items pattern
// from Fase 15 so the page reads as one consistent admin-tooling area).
const canSeeListTitles = $derived(myRole === 'admin' || myRole === 'member');
let defaultTitleDraft = $state('');
let defaultTitleSaving = $state(false);
let defaultTitleSaved = $state(false);
let listTitlesError = $state<string | null>(null);
// Keep the draft input in sync with the active collective. Guarded against
// the realtime UPDATE re-firing while the admin is mid-edit (only sync
// when the input isn't focused).
$effect(() => {
if ($currentCollective && document.activeElement?.id !== 'default-list-title-input') {
defaultTitleDraft = $currentCollective.default_list_title ?? '';
}
});
async function saveDefaultTitle() {
if (!$currentCollective || !isAdmin) return;
const trimmed = defaultTitleDraft.trim();
const current = $currentCollective.default_list_title ?? '';
if (trimmed === current) return;
defaultTitleSaving = true;
listTitlesError = null;
const err = await setDefaultListTitle($currentCollective.id, trimmed.length > 0 ? trimmed : null);
defaultTitleSaving = false;
if (err) {
listTitlesError = err.message;
return;
}
// Optimistic patch so the next /lists modal prefill sees the value
// even before the realtime UPDATE round-trips.
currentCollective.update((c) =>
c ? { ...c, default_list_title: trimmed.length > 0 ? trimmed : null } : c
);
userCollectives.update((list) =>
list.map((c) =>
c.id === $currentCollective!.id
? { ...c, default_list_title: trimmed.length > 0 ? trimmed : null }
: c
)
);
defaultTitleSaved = true;
setTimeout(() => (defaultTitleSaved = false), 1500);
}
function onDefaultTitleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
void saveDefaultTitle();
} else if (e.key === 'Escape') {
e.preventDefault();
if ($currentCollective) defaultTitleDraft = $currentCollective.default_list_title ?? '';
}
}
// Curated-titles CRUD (modal-based add, inline remove).
let titleAddOpen = $state(false);
let titleAddName = $state('');
let titleAddSaving = $state(false);
function openTitleAdd() {
if (!isAdmin) return;
titleAddName = '';
titleAddOpen = true;
}
function closeTitleAdd() {
titleAddOpen = false;
}
async function confirmTitleAdd() {
if (!$currentCollective || !isAdmin) return;
const trimmed = titleAddName.trim();
if (!trimmed) return;
titleAddSaving = true;
listTitlesError = null;
const err = await addCatalogTitle($currentCollective.id, trimmed);
titleAddSaving = false;
if (err) {
listTitlesError = err.message;
return;
}
titleAddOpen = false;
}
async function handleRemoveTitle(title: string) {
if (!$currentCollective || !isAdmin) return;
listTitlesError = null;
const err = await removeCatalogTitle($currentCollective.id, title);
if (err) listTitlesError = err.message;
}
async function confirmDissolve() {
if (!$currentCollective || !dissolveConfirmReady || dissolveInFlight) return;
const target = $currentCollective;
@@ -749,6 +854,104 @@
</section>
{/if}
<!-- Suggested list titles (Fase 18) — admin sets default + curates catalog;
members read-only; guests hidden. Mirrors the common-items shape. -->
{#if canSeeListTitles}
<section data-testid="list-titles-section" class="mb-8">
<div class="mb-2 flex items-center justify-between gap-3">
<h2 class="text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">
{m.manage_list_titles_section_title()}
</h2>
{#if isAdmin}
<button
type="button"
data-testid="list-titles-add-button"
onclick={openTitleAdd}
class="rounded-md bg-slate-900 px-3 py-1.5 text-xs font-semibold text-white
hover:opacity-90 dark:bg-slate-50 dark:text-slate-900"
>
{m.manage_list_titles_add_button()}
</button>
{/if}
</div>
<p class="mb-3 text-xs text-text-secondary">{m.manage_list_titles_blurb()}</p>
{#if !isAdmin}
<p class="mb-3 text-xs text-text-secondary" data-testid="list-titles-readonly-banner">
{m.manage_list_titles_readonly()}
</p>
{/if}
{#if listTitlesError}
<p class="mb-2 text-sm text-destructive" data-testid="list-titles-error">{listTitlesError}</p>
{/if}
<!-- Default-title input -->
<div class="mb-4">
<label
for="default-list-title-input"
class="mb-1 block text-xs font-medium text-text-secondary"
>
{m.manage_default_list_title_label()}
</label>
<div class="relative">
<input
id="default-list-title-input"
data-testid="manage-default-list-title-input"
type="text"
bind:value={defaultTitleDraft}
onkeydown={onDefaultTitleKeydown}
onblur={() => void saveDefaultTitle()}
disabled={!isAdmin}
maxlength="120"
placeholder={m.manage_default_list_title_placeholder()}
class="w-full rounded-lg border border-slate-300 bg-surface px-3 py-2 text-sm
focus:border-slate-500 focus:outline-none disabled:opacity-60
dark:border-slate-600 dark:bg-slate-800"
/>
{#if defaultTitleSaving}
<span class="absolute right-3 top-2 text-xs text-text-secondary">{m.settings_saving()}</span>
{:else if defaultTitleSaved}
<span class="absolute right-3 top-2 text-xs text-emerald-600"></span>
{/if}
</div>
</div>
<!-- Curated catalog -->
{#if $listTitleCatalog.length === 0}
<p class="text-sm text-text-secondary" data-testid="list-titles-empty">
{m.manage_list_titles_empty()}
</p>
{:else}
<ul class="space-y-1" data-testid="list-titles-list">
{#each $listTitleCatalog as row (row.title)}
<li
data-testid={`list-title-row-${row.title}`}
class="flex items-center gap-3 rounded-md bg-surface-raised px-3 py-2"
>
<p class="min-w-0 flex-1 truncate text-sm font-medium text-slate-900 dark:text-slate-50">
{row.title}
</p>
<button
type="button"
data-testid={`list-title-remove-${row.title}`}
title={!isAdmin ? m.manage_list_titles_readonly() : m.manage_list_titles_remove()}
aria-label={m.manage_list_titles_remove()}
disabled={!isAdmin}
onclick={() => void handleRemoveTitle(row.title)}
class="shrink-0 rounded-md p-1.5 text-slate-400 hover:bg-red-50 hover:text-red-600
disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent disabled:hover:text-slate-400
dark:hover:bg-red-900/20"
>
</button>
</li>
{/each}
</ul>
{/if}
</section>
{/if}
<!-- Invite link generator — background shift instead of border -->
{#if isAdmin}
<section class="rounded-lg bg-surface-container-low p-4 dark:bg-surface-raised">
@@ -1044,3 +1247,69 @@
</div>
</div>
{/if}
<!-- Add curated list-title modal (Fase 18) -->
{#if titleAddOpen}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
data-testid="list-titles-add-modal"
role="dialog"
aria-modal="true"
onclick={(e) => {
if (e.target === e.currentTarget) closeTitleAdd();
}}
onkeydown={(e) => e.key === 'Escape' && closeTitleAdd()}
tabindex="-1"
>
<div class="w-full max-w-md rounded-lg bg-surface p-5 shadow-[0px_20px_40px_rgba(15,23,42,0.2)] dark:bg-surface-raised">
<h2 class="mb-3 text-base font-semibold text-slate-900 dark:text-slate-50">
{m.manage_list_titles_add_modal_title()}
</h2>
<label for="list-titles-add-name" class="mb-1 block text-xs font-medium text-text-secondary">
{m.manage_list_titles_add_name_label()}
</label>
<!-- svelte-ignore a11y_autofocus -->
<input
id="list-titles-add-name"
data-testid="list-titles-add-name"
type="text"
bind:value={titleAddName}
autofocus
placeholder={m.manage_list_titles_add_name_placeholder()}
maxlength="120"
class="mb-3 w-full rounded-lg border border-slate-300 bg-surface px-3 py-2 text-sm
focus:border-slate-500 focus:outline-none dark:border-slate-600 dark:bg-slate-800"
onkeydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
void confirmTitleAdd();
}
}}
/>
<div class="flex justify-end gap-2">
<button
type="button"
onclick={closeTitleAdd}
class="rounded-md px-3 py-1.5 text-sm font-medium text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
>
{m.common_cancel()}
</button>
<button
type="button"
data-testid="list-titles-add-save"
disabled={!titleAddName.trim() || titleAddSaving}
onclick={() => void confirmTitleAdd()}
class="inline-flex items-center gap-2 rounded-md bg-slate-900 px-4 py-1.5 text-sm font-semibold text-white hover:opacity-90 disabled:opacity-50
dark:bg-slate-50 dark:text-slate-900"
>
{#if titleAddSaving}
<Spinner size="sm" />
<span>{m.loading()}</span>
{:else}
{m.manage_list_titles_add_save()}
{/if}
</button>
</div>
</div>
</div>
{/if}