feat(settings): Phase 2d.6 — Tag-Presets management UI

Closes the userTagPresets loop: users can now create, set-default,
and delete presets from Settings → Tag-Presets, making the dropdown
in SpaceCreateDialog actually useful (before this, it only showed
"empty" / "copy-current" because no presets existed).

New settings category "Tag-Presets":
- searchIndex.ts: adds the category entry + anchor; sidebar picks it
  up automatically since it iterates `categories`.
- TagPresetsSection.svelte: list + create + delete + set-default.
- settings/ListView.svelte: conditional render wiring.

The create flow is deliberately one-click: name the preset, hit
"Aus <activeSpace.name> erstellen", and we snapshot every non-deleted
tag + tagGroup in the active Space into the new preset (with
groupName denormalized so the preset is space-independent). The first
preset automatically becomes the user's default — subsequent ones can
be promoted via the star button.

No full per-entry editor in this commit. If the user wants to tweak a
preset's contents, they create a sibling Space with the preset,
modify tags there, and promote THAT Space's tags to a new preset.
Scope-creep avoidance for a feature whose main value is snapshotting,
not authoring.

Type-check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-22 18:10:28 +02:00
parent ef76815eb2
commit 0f8fbb381b
3 changed files with 381 additions and 2 deletions

View file

@ -4,9 +4,9 @@
* updates both the navigation and the search results.
*/
import type { Component } from 'svelte';
import { Gear, Robot, ShieldCheck, Cloud } from '@mana/shared-icons';
import { Gear, Robot, ShieldCheck, Cloud, Tag } from '@mana/shared-icons';
export type CategoryId = 'general' | 'ai' | 'security' | 'data';
export type CategoryId = 'general' | 'ai' | 'security' | 'data' | 'tag-presets';
export interface Category {
id: CategoryId;
@ -55,6 +55,13 @@ export const categories: Category[] = [
'danger-zone',
],
},
{
id: 'tag-presets',
label: 'Tag-Presets',
description: 'Tag-Sets für neue Spaces',
icon: Tag,
anchors: ['tag-presets'],
},
];
export interface SearchEntry {

View file

@ -0,0 +1,369 @@
<!--
Settings → Tag-Presets
User-level templates for seeding tags into newly-created Spaces.
See docs/plans/space-scoped-data-model.md §5.
-->
<script lang="ts">
import { Plus, Trash, Star, CheckCircle } from '@mana/shared-icons';
import { useUserTagPresets } from '$lib/data/tag-presets/queries';
import { tagPresetsStore } from '$lib/data/tag-presets/store.svelte';
import { getActiveSpace } from '$lib/data/scope/active-space.svelte';
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import type { TagPresetEntry } from '$lib/data/tag-presets/types';
const presets = $derived(useUserTagPresets());
const activeSpace = $derived(getActiveSpace());
let creating = $state(false);
let newName = $state('');
let error = $state<string | null>(null);
interface LocalTagRow {
id: string;
spaceId?: string;
name?: string;
color?: string;
icon?: string | null;
groupId?: string | null;
deletedAt?: string;
}
interface LocalTagGroupRow {
id: string;
spaceId?: string;
name?: string;
deletedAt?: string;
}
/**
* Snapshot the current Space's tags + tagGroups into a preset with
* the user-provided name. One-click shortcut — no per-tag editor.
*/
async function createFromActiveSpace() {
error = null;
if (!newName.trim()) {
error = 'Bitte einen Namen eingeben';
return;
}
if (!activeSpace) {
error = 'Kein aktiver Space';
return;
}
creating = true;
try {
const rawTags = await db.table<LocalTagRow>('globalTags').toArray();
const rawGroups = await db.table<LocalTagGroupRow>('tagGroups').toArray();
const inSpaceTags = rawTags.filter((t) => t.spaceId === activeSpace.id && !t.deletedAt);
const inSpaceGroups = rawGroups.filter((g) => g.spaceId === activeSpace.id && !g.deletedAt);
const decryptedTags = await decryptRecords<LocalTagRow>('globalTags', inSpaceTags);
const decryptedGroups = await decryptRecords<LocalTagGroupRow>('tagGroups', inSpaceGroups);
const groupNameById = new Map<string, string>();
for (const g of decryptedGroups) {
if (g.name) groupNameById.set(g.id, g.name);
}
const entries: TagPresetEntry[] = decryptedTags.map((t) => ({
name: t.name ?? '',
color: t.color ?? '#6b7280',
icon: t.icon ?? undefined,
groupName: t.groupId ? groupNameById.get(t.groupId) : undefined,
}));
await tagPresetsStore.createPreset({
name: newName.trim(),
tags: entries,
isDefault: presets.value.length === 0, // first preset becomes default
});
newName = '';
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
creating = false;
}
}
async function handleDelete(id: string, name: string) {
if (!confirm(`Preset „${name}" löschen?`)) return;
await tagPresetsStore.deletePreset(id);
}
async function handleSetDefault(id: string) {
await tagPresetsStore.setDefault(id);
}
</script>
<section id="tag-presets">
<header>
<h2>Tag-Presets</h2>
<p class="hint">
Gespeicherte Tag-Sets, die du beim Anlegen eines neuen Space als Start-Vorlage wählen kannst.
Änderungen am Preset berühren bereits erstellte Spaces nicht — es ist eine Einweg-Kopie.
</p>
</header>
<div class="create-row">
<input
type="text"
placeholder={'Preset-Name (z.B. „Mein Standard-Set")'}
bind:value={newName}
disabled={creating || !activeSpace}
/>
<button
type="button"
class="primary"
onclick={createFromActiveSpace}
disabled={creating || !newName.trim() || !activeSpace}
>
<Plus size={14} />
<span>
{creating
? 'Erstelle …'
: activeSpace
? `Aus „${activeSpace.name}" erstellen`
: 'Lade Space …'}
</span>
</button>
</div>
{#if error}<p class="error">{error}</p>{/if}
{#if presets.value.length === 0}
<p class="empty">
Noch keine Presets. Lege das erste an, indem du dem aktuellen Tag-Set einen Namen gibst — es
wird dann automatisch als Default für neue Spaces verwendet.
</p>
{:else}
<ul class="preset-list">
{#each presets.value as preset (preset.id)}
<li class="preset-row" class:default={preset.isDefault}>
<div class="preset-info">
<div class="preset-name">
{preset.name}
{#if preset.isDefault}
<span class="default-badge">
<CheckCircle size={12} weight="fill" />
Default
</span>
{/if}
</div>
<div class="preset-meta">
{preset.tags.length} Tag{preset.tags.length === 1 ? '' : 's'}
{#if preset.tags.some((t) => t.groupName)}
· mit Gruppen
{/if}
</div>
</div>
<div class="preset-actions">
{#if !preset.isDefault}
<button
type="button"
class="icon-btn"
onclick={() => handleSetDefault(preset.id)}
title="Als Default setzen"
aria-label="Als Default setzen"
>
<Star size={16} />
</button>
{/if}
<button
type="button"
class="icon-btn danger"
onclick={() => handleDelete(preset.id, preset.name)}
title="Löschen"
aria-label="Löschen"
>
<Trash size={16} />
</button>
</div>
</li>
{/each}
</ul>
{/if}
</section>
<style>
section {
display: flex;
flex-direction: column;
gap: 1rem;
color: hsl(var(--color-foreground));
}
header {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
h2 {
font-size: 1rem;
font-weight: 600;
margin: 0;
}
.hint {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
margin: 0;
line-height: 1.5;
}
.create-row {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.create-row input {
flex: 1 1 220px;
padding: 0.5rem 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 8px;
background: hsl(var(--color-input, var(--color-background, var(--color-card))));
color: hsl(var(--color-foreground));
font: inherit;
font-size: 0.875rem;
}
.create-row input:focus {
outline: none;
border-color: var(--pill-primary-color, hsl(var(--color-primary, 230 80% 55%)));
}
.primary {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
background: var(--pill-primary-color, hsl(var(--color-primary, 230 80% 55%)));
color: hsl(var(--color-primary-foreground, 0 0% 100%));
border: 0;
border-radius: 8px;
font: inherit;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
}
.primary:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.error {
color: hsl(0 70% 55%);
font-size: 0.8125rem;
margin: 0;
}
.empty {
padding: 1rem 1.125rem;
border: 1px dashed hsl(var(--color-border));
border-radius: 10px;
background: hsl(var(--color-muted) / 0.3);
color: hsl(var(--color-muted-foreground));
font-size: 0.875rem;
margin: 0;
}
.preset-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.preset-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 0.875rem;
border: 1px solid hsl(var(--color-border));
border-radius: 10px;
background: hsl(var(--color-card));
}
.preset-row.default {
border-color: color-mix(
in srgb,
var(--pill-primary-color, hsl(var(--color-primary, 230 80% 55%))) 50%,
transparent
);
background: color-mix(
in srgb,
var(--pill-primary-color, hsl(var(--color-primary, 230 80% 55%))) 6%,
hsl(var(--color-card))
);
}
.preset-info {
min-width: 0;
flex: 1;
}
.preset-name {
font-size: 0.875rem;
font-weight: 500;
display: flex;
align-items: center;
gap: 0.5rem;
}
.default-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.6875rem;
padding: 0.125rem 0.4rem;
background: var(--pill-primary-color, hsl(var(--color-primary, 230 80% 55%)));
color: hsl(var(--color-primary-foreground, 0 0% 100%));
border-radius: 9999px;
font-weight: 500;
}
.preset-meta {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
margin-top: 0.125rem;
}
.preset-actions {
display: flex;
gap: 0.25rem;
}
.icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
padding: 0;
background: transparent;
border: 1px solid hsl(var(--color-border));
border-radius: 6px;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
transition:
color 120ms ease,
border-color 120ms ease,
background 120ms ease;
}
.icon-btn:hover {
color: hsl(var(--color-foreground));
background: hsl(var(--color-muted) / 0.5);
}
.icon-btn.danger:hover {
color: hsl(0 70% 55%);
border-color: color-mix(in srgb, hsl(0 70% 55%) 40%, transparent);
background: color-mix(in srgb, hsl(0 70% 55%) 8%, transparent);
}
</style>

View file

@ -16,6 +16,7 @@
import AiSection from '$lib/components/settings/sections/AiSection.svelte';
import SecuritySection from '$lib/components/settings/sections/SecuritySection.svelte';
import DataSection from '$lib/components/settings/sections/DataSection.svelte';
import TagPresetsSection from '$lib/components/settings/sections/TagPresetsSection.svelte';
let activeCategory = $state<CategoryId>('general');
@ -77,6 +78,8 @@
<SecuritySection />
{:else if activeCategory === 'data'}
<DataSection />
{:else if activeCategory === 'tag-presets'}
<TagPresetsSection />
{/if}
</div>