feat(fase-11): settings tag management subsection (11.3.6)

Adds a "Tags" section to /settings between Collectives and Account. Lists
every tag in the active collective with:
  * inline rename — tap the chip, edit, Enter / blur to save
  * recolor swatch row (8 preset dots, current colour outlined)
  * delete button per tag (cascades item attachments via FK)

Uses the tagsStore which is already subscribed to item_tags realtime, so
edits from another tab / device update this view live without a reload.
No new screens; everything is inline in the existing settings layout.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 03:27:36 +02:00
parent 1a6aae2874
commit 2b04edc511

View File

@@ -9,6 +9,15 @@
import ImageCropper from '$lib/components/ImageCropper.svelte';
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
import SyncConflictsPanel from '$lib/components/SyncConflictsPanel.svelte';
import TagChip from '$lib/components/TagChip.svelte';
import {
tags as tagsStore,
loadTags,
renameTag,
recolorTag,
deleteTag
} from '$lib/stores/tags';
import type { ItemTag, ItemTagColor } from '@colectivo/types';
import { languageTag, setLanguageTag } from '$lib/paraglide/runtime';
import * as m from '$lib/paraglide/messages';
import type { ThemePreference } from '$lib/theme';
@@ -43,8 +52,54 @@
onMount(async () => {
await loadProfile();
if ($currentCollective) {
await loadTags($currentCollective.id);
}
});
// Inline edit state for the tag list (one row at a time).
let editingTagId = $state<string | null>(null);
let editingTagName = $state('');
const TAG_COLORS: ItemTagColor[] = [
'slate',
'red',
'amber',
'green',
'sky',
'indigo',
'pink',
'stone'
];
function startEditTag(tag: ItemTag) {
editingTagId = tag.id;
editingTagName = tag.name;
}
async function commitEditTag() {
const id = editingTagId;
const next = editingTagName.trim();
editingTagId = null;
editingTagName = '';
if (id && next) {
await renameTag(id, next);
}
}
function cancelEditTag() {
editingTagId = null;
editingTagName = '';
}
async function handleRecolor(tag: ItemTag, color: ItemTagColor) {
if (tag.color === color) return;
await recolorTag(tag.id, color);
}
async function handleDeleteTag(id: string) {
await deleteTag(id);
}
async function loadProfile() {
if (!$currentUser) return;
@@ -397,6 +452,68 @@
{/if}
</section>
<!-- Tags (Fase 11.3.6) — collective-scoped tag management. Lives in
settings because the tag flow on the list view is per-item; the
full list of tags + rename + recolor + delete is admin-style. -->
<section data-testid="settings-tags-section" class="pt-8">
<p class="mb-3 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">
{m.settings_tags_section()}
</p>
{#if $tagsStore.length === 0}
<p class="text-sm text-text-secondary">{m.settings_tags_empty()}</p>
{:else}
<ul class="space-y-2">
{#each $tagsStore as tag (tag.id)}
<li
data-testid="settings-tag-row"
data-tag={tag.name}
class="flex items-center gap-3 rounded-md bg-surface-raised px-3 py-2"
>
{#if editingTagId === tag.id}
<input
bind:value={editingTagName}
onblur={commitEditTag}
onkeydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
void commitEditTag();
} else if (e.key === 'Escape') cancelEditTag();
}}
class="min-w-0 flex-1 rounded bg-background px-2 py-1 text-sm text-text-primary outline-none ring-1 ring-black/10 focus:ring-slate-900 dark:ring-white/10 dark:focus:ring-slate-100"
/>
{:else}
<button
type="button"
onclick={() => startEditTag(tag)}
class="min-w-0 flex-1 text-left"
>
<TagChip name={tag.name} color={tag.color} />
</button>
{/if}
<div class="flex shrink-0 items-center gap-1" aria-label={m.settings_tags_color()}>
{#each TAG_COLORS as color}
<button
type="button"
onclick={() => handleRecolor(tag, color)}
aria-label={color}
class="h-4 w-4 rounded-full bg-{color}-500 {tag.color === color ? 'ring-2 ring-offset-1 ring-slate-700 dark:ring-slate-300' : 'opacity-70 hover:opacity-100'}"
></button>
{/each}
</div>
<button
type="button"
data-testid="settings-tag-delete"
onclick={() => handleDeleteTag(tag.id)}
class="rounded-md border border-slate-300 px-2 py-1 text-xs font-medium text-slate-700 hover:bg-slate-50 dark:border-slate-600 dark:text-slate-300 dark:hover:bg-slate-800"
>
{m.settings_tags_delete()}
</button>
</li>
{/each}
</ul>
{/if}
</section>
<!-- Account — spacing creates the separation, not a border line -->
<section class="pt-8">
<p class="mb-3 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">