diff --git a/apps/mana/apps/web/src/lib/modules/dreams/ListView.svelte b/apps/mana/apps/web/src/lib/modules/dreams/ListView.svelte index 4058b15b5..bb6ec0371 100644 --- a/apps/mana/apps/web/src/lib/modules/dreams/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/dreams/ListView.svelte @@ -184,7 +184,12 @@ {#if viewMode === 'symbols'} - + { + viewMode = 'list'; + startEdit(d); + }} + /> {:else}
e.preventDefault()} class="quick-add"> diff --git a/apps/mana/apps/web/src/lib/modules/dreams/queries.ts b/apps/mana/apps/web/src/lib/modules/dreams/queries.ts index 21e583aa9..437a0ef5d 100644 --- a/apps/mana/apps/web/src/lib/modules/dreams/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/dreams/queries.ts @@ -120,6 +120,18 @@ export function formatDreamDate(iso: string): string { return date.toLocaleDateString('de-DE', { day: 'numeric', month: 'short', year: 'numeric' }); } +/** Map of symbol name → most recent dreamDate that references it. */ +export function getLastUsedBySymbol(dreams: Dream[]): Map { + const map = new Map(); + for (const d of dreams) { + for (const sym of d.symbols ?? []) { + const prev = map.get(sym); + if (!prev || d.dreamDate > prev) map.set(sym, d.dreamDate); + } + } + return map; +} + /** All dreams that contain the given symbol, newest first. */ export function getDreamsWithSymbol(dreams: Dream[], symbolName: string): Dream[] { return dreams diff --git a/apps/mana/apps/web/src/lib/modules/dreams/views/SymbolDetailView.svelte b/apps/mana/apps/web/src/lib/modules/dreams/views/SymbolDetailView.svelte index 5be321c4f..db9095439 100644 --- a/apps/mana/apps/web/src/lib/modules/dreams/views/SymbolDetailView.svelte +++ b/apps/mana/apps/web/src/lib/modules/dreams/views/SymbolDetailView.svelte @@ -12,9 +12,19 @@ useAllDreamSymbols, } from '../queries'; import { dreamsStore } from '../stores/dreams.svelte'; - import { MOOD_COLORS, MOOD_LABELS, type DreamMood } from '../types'; + import { MOOD_COLORS, MOOD_LABELS, type Dream, type DreamMood } from '../types'; - let { symbolId, onBack }: { symbolId: string; onBack: () => void } = $props(); + let { + symbolId, + onBack, + onSelectSymbol, + onOpenDream, + }: { + symbolId: string; + onBack: () => void; + onSelectSymbol?: (id: string) => void; + onOpenDream?: (dream: Dream) => void; + } = $props(); let symbols$ = useAllDreamSymbols(); let dreams$ = useAllDreams(); @@ -27,14 +37,14 @@ let editName = $state(''); let editMeaning = $state(''); let editColor = $state('#6366f1'); - let initialized = $state(false); + let lastInitId = $state(null); $effect(() => { - if (symbol && !initialized) { + if (symbol && lastInitId !== symbol.id) { editName = symbol.name; editMeaning = symbol.meaning ?? ''; editColor = symbol.color ?? '#6366f1'; - initialized = true; + lastInitId = symbol.id; } }); @@ -45,11 +55,42 @@ editColor !== (symbol.color ?? '#6366f1')) ); + let savedHint = $state(false); + + // Debounced auto-save + let saveTimer: ReturnType | null = null; + $effect(() => { + // react to edit fields + void editName; + void editMeaning; + void editColor; + + if (!dirty || !symbol) return; + if (saveTimer) clearTimeout(saveTimer); + saveTimer = setTimeout(() => { + void save(); + }, 500); + return () => { + if (saveTimer) clearTimeout(saveTimer); + }; + }); + let dreamsWithSymbol = $derived(symbol ? getDreamsWithSymbol(dreams, symbol.name) : []); let moodDist = $derived(symbol ? getMoodDistribution(dreams, symbol.name) : []); let cooccurring = $derived(symbol ? getCooccurringSymbols(dreams, symbol.name) : []); let totalForBars = $derived(moodDist.reduce((sum, m) => sum + m.count, 0) || 1); + // Merge target candidates + let mergeOpen = $state(false); + let mergeTargetId = $state(''); + let mergeCandidates = $derived( + symbol + ? symbols + .filter((s) => s.id !== symbol.id && s.count > 0) + .sort((a, b) => a.name.localeCompare(b.name, 'de')) + : [] + ); + const PALETTE = ['#6366f1', '#22c55e', '#f59e0b', '#ef4444', '#06b6d4', '#a855f7', '#ec4899']; async function save() { @@ -59,6 +100,8 @@ meaning: editMeaning.trim() || null, color: editColor, }); + savedHint = true; + setTimeout(() => (savedHint = false), 1500); } async function handleDelete() { @@ -71,6 +114,25 @@ onBack(); } + async function handleMerge() { + if (!symbol || !mergeTargetId) return; + const target = symbols.find((s) => s.id === mergeTargetId); + if (!target) return; + const ok = confirm( + `"${symbol.name}" in "${target.name}" zusammenführen? Alle Träume werden umgeschrieben.` + ); + if (!ok) return; + await dreamsStore.mergeSymbols(symbol.id, mergeTargetId); + mergeOpen = false; + mergeTargetId = ''; + onBack(); + } + + function navigateToCooccurring(name: string) { + const target = symbols.find((s) => s.name === name); + if (target && onSelectSymbol) onSelectSymbol(target.id); + } + function moodColor(mood: string): string { if (mood in MOOD_COLORS) return MOOD_COLORS[mood as DreamMood]; return '#9ca3af'; @@ -85,11 +147,35 @@
- {#if symbol} - - {/if} +
+ {#if savedHint} + Gespeichert + {/if} + {#if symbol && mergeCandidates.length > 0} + + {/if} + {#if symbol} + + {/if} +
+ {#if mergeOpen && symbol} +
+ "{symbol.name}" zusammenführen mit: + + + +
+ {/if} + {#if !symbol}

Symbol nicht gefunden.

{:else} @@ -129,12 +215,6 @@ >
- {#if dirty} -
- -
- {/if} - {#if moodDist.length > 0}
@@ -162,7 +242,13 @@
{#each cooccurring as c} - {c.name} {c.count} + {/each}
@@ -174,7 +260,7 @@
{#each dreamsWithSymbol as d (d.id)} -
+
-
+ {/each} @@ -206,6 +292,13 @@ display: flex; justify-content: space-between; align-items: center; + gap: 0.5rem; + } + + .header-actions { + display: flex; + align-items: center; + gap: 0.375rem; } .back-btn { @@ -220,6 +313,41 @@ text-decoration: underline; } + .saved-hint { + font-size: 0.625rem; + color: #22c55e; + font-weight: 500; + animation: fade-in 0.2s ease-out; + } + @keyframes fade-in { + from { + opacity: 0; + transform: translateY(-2px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + .meta-btn { + background: transparent; + border: 1px solid rgba(0, 0, 0, 0.08); + color: #6b7280; + font-size: 0.6875rem; + padding: 0.25rem 0.625rem; + border-radius: 0.25rem; + cursor: pointer; + } + .meta-btn:hover { + border-color: #6366f1; + color: #6366f1; + } + :global(.dark) .meta-btn { + border-color: rgba(255, 255, 255, 0.08); + color: #9ca3af; + } + .del-btn { background: transparent; border: 1px solid rgba(239, 68, 68, 0.3); @@ -233,6 +361,62 @@ background: rgba(239, 68, 68, 0.08); } + /* Merge panel */ + .merge-panel { + display: flex; + align-items: center; + gap: 0.375rem; + flex-wrap: wrap; + padding: 0.5rem 0.625rem; + border-radius: 0.375rem; + background: rgba(99, 102, 241, 0.05); + border: 1px solid rgba(99, 102, 241, 0.2); + } + .merge-label { + font-size: 0.6875rem; + color: #6b7280; + } + .merge-select { + background: transparent; + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 0.25rem; + padding: 0.1875rem 0.375rem; + font-size: 0.6875rem; + color: #374151; + outline: none; + font-family: inherit; + } + :global(.dark) .merge-select { + border-color: rgba(255, 255, 255, 0.12); + color: #f3f4f6; + color-scheme: dark; + } + .merge-confirm { + padding: 0.1875rem 0.625rem; + border-radius: 0.25rem; + border: none; + background: #6366f1; + color: white; + font-size: 0.6875rem; + cursor: pointer; + } + .merge-confirm:disabled { + background: rgba(99, 102, 241, 0.3); + cursor: not-allowed; + } + .merge-cancel { + padding: 0.1875rem 0.5rem; + border-radius: 0.25rem; + border: none; + background: transparent; + color: #9ca3af; + font-size: 0.6875rem; + cursor: pointer; + } + .merge-cancel:hover { + color: #374151; + } + .sym-header { display: flex; align-items: center; @@ -318,24 +502,6 @@ color: #f3f4f6; } - .save-row { - display: flex; - justify-content: flex-end; - } - .save-btn { - padding: 0.25rem 0.75rem; - border-radius: 0.25rem; - font-size: 0.6875rem; - font-weight: 500; - background: #6366f1; - color: white; - border: none; - cursor: pointer; - } - .save-btn:hover { - background: #5558e6; - } - /* Bars */ .bars { display: flex; @@ -384,6 +550,14 @@ background: rgba(99, 102, 241, 0.08); color: #6366f1; font-size: 0.6875rem; + border: 1px solid transparent; + cursor: pointer; + font-family: inherit; + transition: all 0.15s; + } + .cooc-chip:hover { + background: rgba(99, 102, 241, 0.18); + border-color: #6366f1; } .cooc-count { font-size: 0.5625rem; @@ -401,13 +575,22 @@ display: flex; align-items: center; gap: 0.5rem; - padding: 0.375rem 0.25rem; + padding: 0.375rem 0.375rem; border-radius: 0.25rem; + background: transparent; + border: none; + width: 100%; + text-align: left; + cursor: pointer; + font-family: inherit; } - .dream-ref:hover { + .dream-ref:disabled { + cursor: default; + } + .dream-ref:not(:disabled):hover { background: rgba(0, 0, 0, 0.03); } - :global(.dark) .dream-ref:hover { + :global(.dark) .dream-ref:not(:disabled):hover { background: rgba(255, 255, 255, 0.04); } diff --git a/apps/mana/apps/web/src/lib/modules/dreams/views/SymbolsView.svelte b/apps/mana/apps/web/src/lib/modules/dreams/views/SymbolsView.svelte index acb8dff1b..717f1dafa 100644 --- a/apps/mana/apps/web/src/lib/modules/dreams/views/SymbolsView.svelte +++ b/apps/mana/apps/web/src/lib/modules/dreams/views/SymbolsView.svelte @@ -4,61 +4,141 @@ Click → opens detail panel. --> {#if selectedSymbolId} - (selectedSymbolId = null)} /> + (selectedSymbolId = null)} + onSelectSymbol={selectSymbol} + {onOpenDream} + /> {:else}
- {#if symbols.length > 5} - - {/if} + +
+ {#if active.length > 5} + + {/if} +
+ + + +
+
- {#if filtered.length === 0} + {#if sorted.length === 0}

- {symbols.length === 0 + {active.length === 0 ? 'Noch keine Symbole. Füge Symbole zu deinen Träumen hinzu, um sie hier zu sehen.' : 'Keine Treffer'}

{:else} -
- {#each filtered as sym (sym.id)} +
+ {#each sorted as sym (sym.id)} + {@const lastDate = lastUsedMap.get(sym.name)} + {@const noMeaning = !sym.meaning?.trim()} {/each}
@@ -70,11 +150,17 @@ .symbols-view { display: flex; flex-direction: column; - gap: 0.75rem; + gap: 0.625rem; flex: 1; min-height: 0; } + .toolbar { + display: flex; + flex-direction: column; + gap: 0.5rem; + } + .search-input { padding: 0.3rem 0.5rem; border-radius: 0.375rem; @@ -92,14 +178,46 @@ color: #f3f4f6; } + .sort-tabs { + display: flex; + gap: 0.25rem; + flex-wrap: wrap; + } + .sort-tab { + padding: 0.1875rem 0.5rem; + border-radius: 9999px; + border: 1px solid rgba(0, 0, 0, 0.08); + background: transparent; + font-size: 0.625rem; + color: #9ca3af; + cursor: pointer; + transition: all 0.15s; + } + .sort-tab:hover { + color: #6366f1; + } + .sort-tab.active { + background: #6366f1; + color: white; + border-color: #6366f1; + } + :global(.dark) .sort-tab { + border-color: rgba(255, 255, 255, 0.08); + } + .cloud { display: flex; flex-wrap: wrap; align-items: baseline; - gap: 0.5rem 0.75rem; + gap: 0.5rem 0.625rem; padding: 0.5rem 0.25rem; overflow-y: auto; } + .cloud.list-mode { + flex-direction: column; + align-items: stretch; + gap: 0.1875rem; + } .sym-chip { display: inline-flex; @@ -114,11 +232,22 @@ transition: all 0.15s; font-weight: 500; line-height: 1.4; + text-align: left; + } + .cloud.list-mode .sym-chip { + border-radius: 0.375rem; + justify-content: flex-start; } .sym-chip:hover { - background: color-mix(in srgb, var(--sym-color) 10%, transparent); + background: color-mix(in srgb, var(--sym-color) 12%, transparent); border-color: var(--sym-color); } + .sym-chip.no-meaning { + opacity: 0.7; + } + .sym-chip.no-meaning:hover { + opacity: 1; + } :global(.dark) .sym-chip { border-color: rgba(255, 255, 255, 0.08); } @@ -141,6 +270,32 @@ font-weight: 400; } + .sym-meta { + font-size: 0.625rem; + color: #c0bfba; + font-weight: 400; + } + :global(.dark) .sym-meta { + color: #4b5563; + } + + .sym-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 13px; + height: 13px; + border-radius: 9999px; + background: rgba(0, 0, 0, 0.06); + color: #9ca3af; + font-size: 0.5625rem; + font-weight: 700; + margin-left: 0.125rem; + } + :global(.dark) .sym-badge { + background: rgba(255, 255, 255, 0.08); + } + .empty { padding: 2rem 0; text-align: center;