diff --git a/frontend/src/lib/components/ContextHeader.svelte b/frontend/src/lib/components/ContextHeader.svelte index 4746402..dfaf69f 100644 --- a/frontend/src/lib/components/ContextHeader.svelte +++ b/frontend/src/lib/components/ContextHeader.svelte @@ -1,63 +1,78 @@ - + - ← + {#if !isPersonalWorkspace} + ← + {/if} - {collectionNode?.title || 'Samling'} + {isPersonalWorkspace ? 'Min arbeidsflate' : (collectionNode?.title || 'Arbeidsflate')} ▾ @@ -189,43 +312,117 @@ bind:this={searchInput} bind:value={searchQuery} type="text" - placeholder="Søk samlinger..." + placeholder="Søk arbeidsflater..." class="context-selector-search-input" onclick={(e) => e.stopPropagation()} /> + {#if !searchQuery.trim()} { selectorOpen = false; goto('/'); }} > Min arbeidsflate - - {/if} - {#each filteredCollections as node (node.id)} - selectCollection(node.id)} - > - {node.title || 'Uten tittel'} - {#if node.id === collectionId} + {#if isPersonalWorkspace} ✓ {/if} - {:else} - - {searchQuery ? 'Ingen treff' : 'Ingen samlinger'} - - {/each} + {/if} + + + {#if myCollections.length > 0} + Mine flater + {#each myCollections as node (node.id)} + {#if deletingId === node.id} + + + Slett «{node.title || 'Uten tittel'}»? + {#if deleteChildCount > 0} + {deleteChildCount} noder tilhører denne flaten. + {/if} + + + { deletingId = undefined; }}>Avbryt + Slett + + + {:else} + + {#if renamingId === node.id} + e.stopPropagation()} + /> + {:else} + selectCollection(node.id)} + > + {node.title || 'Uten tittel'} + {#if node.id === collectionId} + ✓ + {/if} + + { e.stopPropagation(); startRename(node); }} + title="Gi nytt navn" + >✏️ + { e.stopPropagation(); startDelete(node); }} + title="Slett" + >🗑️ + {/if} + + {/if} + {/each} + {/if} + + + {#if sharedCollections.length > 0} + Delte flater + {#each sharedCollections as node (node.id)} + selectCollection(node.id)} + > + {node.title || 'Uten tittel'} + {#if node.id === collectionId} + ✓ + {/if} + + {/each} + {/if} + + {#if myCollections.length === 0 && sharedCollections.length === 0 && searchQuery.trim()} + Ingen treff + {/if} + + + {/if} - + - {#if connected} - ● - {:else} - ● - {/if} - - {#if connected && collectionNode} + {#if traitNames.length > 0 && onToggleTraitAdmin} {/if} + + {#if connected} + ● + {:else} + ● + {/if} + + + ⚙ + + {#if settingsOpen} + + + Tema + + {#each THEME_PRESETS as preset (preset.name)} + applyPreset(preset.theme)} + > + + {preset.name} + + {/each} + + + + + Bakgrunn + + setThemeColor('bg', 'hue', +e.currentTarget.value)} /> + setThemeColor('bg', 'saturation', +e.currentTarget.value)} /> + + + + + Overflate + + setThemeColor('surface', 'hue', +e.currentTarget.value)} /> + setThemeColor('surface', 'saturation', +e.currentTarget.value)} /> + + + + + Aksent + + setThemeColor('accent', 'hue', +e.currentTarget.value)} /> + setThemeColor('accent', 'saturation', +e.currentTarget.value)} /> + + + + + + {#if $page.data.session?.user} + {$page.data.session.user.name} + {/if} + signOut()}>Logg ut + + {/if} + diff --git a/frontend/src/lib/workspace/theme.ts b/frontend/src/lib/workspace/theme.ts new file mode 100644 index 0000000..3689537 --- /dev/null +++ b/frontend/src/lib/workspace/theme.ts @@ -0,0 +1,171 @@ +/** + * Shared theme logic for Synops workspace. + * + * Each workspace/collection can have its own theme stored in + * metadata.preferences.theme. Themes use HSL color model for + * background, surface, and accent colors. + * + * Three-layer inheritance: + * 1. Flate-spesifikt (lagret i nodens metadata) + * 2. Personlig default (fra "Min arbeidsflate") + * 3. Plattform-default (DEFAULT_THEME) + */ + +/** Color definition: hue + saturation. Lightness is derived per use. */ +export interface ThemeColor { + hue: number; // 0-360 + saturation: number; // 0-100 +} + +/** Full theme configuration */ +export interface ThemeConfig { + bg: ThemeColor; + surface: ThemeColor; + accent: ThemeColor; +} + +/** Platform default — neutral dark with indigo accent */ +export const DEFAULT_THEME: ThemeConfig = { + bg: { hue: 0, saturation: 0 }, + surface: { hue: 0, saturation: 0 }, + accent: { hue: 239, saturation: 70 }, +}; + +/** Named theme presets */ +export interface ThemePreset { + name: string; + theme: ThemeConfig; +} + +export const THEME_PRESETS: ThemePreset[] = [ + { + name: 'Standard', + theme: DEFAULT_THEME, + }, + { + name: 'Hav', + theme: { + bg: { hue: 210, saturation: 15 }, + surface: { hue: 210, saturation: 12 }, + accent: { hue: 200, saturation: 75 }, + }, + }, + { + name: 'Skog', + theme: { + bg: { hue: 150, saturation: 12 }, + surface: { hue: 150, saturation: 10 }, + accent: { hue: 142, saturation: 65 }, + }, + }, + { + name: 'Solnedgang', + theme: { + bg: { hue: 15, saturation: 12 }, + surface: { hue: 15, saturation: 10 }, + accent: { hue: 25, saturation: 80 }, + }, + }, + { + name: 'Lavendel', + theme: { + bg: { hue: 270, saturation: 10 }, + surface: { hue: 270, saturation: 8 }, + accent: { hue: 280, saturation: 65 }, + }, + }, + { + name: 'Monokrom', + theme: { + bg: { hue: 0, saturation: 0 }, + surface: { hue: 0, saturation: 0 }, + accent: { hue: 0, saturation: 0 }, + }, + }, +]; + +function hsl(hue: number, sat: number, light: number): string { + return `hsl(${hue}, ${sat}%, ${light}%)`; +} + +function hsla(hue: number, sat: number, light: number, alpha: number): string { + return `hsla(${hue}, ${sat}%, ${light}%, ${alpha})`; +} + +/** Apply theme to :root CSS custom properties */ +export function applyTheme(config: ThemeConfig): void { + if (typeof document === 'undefined') return; + const root = document.documentElement; + + const { bg, surface, accent } = config; + + // Background: very dark, slight tint + root.style.setProperty('--color-bg', hsl(bg.hue, bg.saturation, 4)); + + // Surface: slightly lighter + root.style.setProperty('--color-surface', hsl(surface.hue, surface.saturation, 12)); + root.style.setProperty('--color-surface-hover', hsl(surface.hue, Math.max(0, surface.saturation - 2), 15)); + + // Border: derived from surface + root.style.setProperty('--color-border', hsl(surface.hue, Math.max(0, surface.saturation - 3), 18)); + root.style.setProperty('--color-border-hover', hsl(surface.hue, Math.max(0, surface.saturation - 3), 24)); + + // Accent + root.style.setProperty('--color-accent', hsl(accent.hue, accent.saturation, 60)); + root.style.setProperty('--color-accent-hover', hsl(accent.hue, accent.saturation, 65)); + root.style.setProperty('--color-accent-glow', hsla(accent.hue, accent.saturation, 60, 0.15)); +} + +/** Reset theme to platform defaults (remove inline styles) */ +export function resetTheme(): void { + if (typeof document === 'undefined') return; + const root = document.documentElement; + const props = [ + '--color-bg', '--color-surface', '--color-surface-hover', + '--color-border', '--color-border-hover', + '--color-accent', '--color-accent-hover', '--color-accent-glow', + ]; + for (const prop of props) { + root.style.removeProperty(prop); + } +} + +/** + * Load theme from node metadata. Backward compatible with old format + * ({ hueBg, hueSurface, hueAccent }) and new format ({ bg, surface, accent }). + */ +export function loadThemeFromMetadata(meta: Record): ThemeConfig | null { + const prefs = meta.preferences as Record | undefined; + const theme = prefs?.theme as Record | undefined; + if (!theme) return null; + + // New format + if (theme.bg && typeof theme.bg === 'object') { + return theme as unknown as ThemeConfig; + } + + // Old format: { hueBg, hueSurface, hueAccent } + if (typeof theme.hueBg === 'number' || typeof theme.hueAccent === 'number') { + return { + bg: { hue: (theme.hueBg as number) ?? 0, saturation: (theme.hueBg as number) ? 10 : 0 }, + surface: { hue: (theme.hueSurface as number) ?? 0, saturation: (theme.hueSurface as number) ? 8 : 0 }, + accent: { hue: (theme.hueAccent as number) ?? 239, saturation: 70 }, + }; + } + + return null; +} + +/** Serialize theme for storage in node metadata */ +export function themeToMetadata(config: ThemeConfig): Record { + return { + bg: config.bg, + surface: config.surface, + accent: config.accent, + }; +} + +/** Get a CSS color string for a theme color at a given lightness */ +export function themeColorCSS(color: ThemeColor, lightness: number): string { + return hsl(color.hue, color.saturation, lightness); +} diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 2f0b359..b243ced 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -1,10 +1,16 @@ - - - - - - - - - Min arbeidsflate - ▾ - - - {#if selectorOpen} - - - e.stopPropagation()} - /> - - - {#if myCollections.length > 0} - Mine flater - {#each myCollections as node (node.id)} - - {#if renamingId === node.id} - e.stopPropagation()} - /> - {:else} - selectCollection(node.id)} - > - {node.title || 'Uten tittel'} - - { e.stopPropagation(); startRename(node); }} - title="Gi nytt navn" - >✏️ - {/if} - - {/each} - {/if} - {#if sharedCollections.length > 0} - Delte flater - {#each sharedCollections as node (node.id)} - selectCollection(node.id)} - > - {node.title || 'Uten tittel'} - - {/each} - {/if} - {#if myCollections.length === 0 && sharedCollections.length === 0} - - {searchQuery ? 'Ingen treff' : 'Ingen arbeidsflater'} - - {/if} - - - - {/if} - - - - - - - + Verktøy - - - {#if toolMenuOpen} - - Legg til panel - {#each availableTools as tool (tool.key)} - addTool(tool.key)} - disabled={tool.active} - title={tool.active ? 'Allerede på flaten' : `Legg til ${tool.title}`} - > - {tool.icon} - {tool.title} - {#if tool.active} - aktiv - {/if} - - {/each} - - {/if} - - - {#if connected} - ● - {:else} - ● - {/if} - - - - ⚙ - - - {#if settingsOpen} - - Tema - - Bakgrunn - - - - Overflate - - - - Aksent - - - - {#if $page.data.session?.user} - {$page.data.session.user.name} - {/if} - signOut()} - > - Logg ut - - - {/if} - - - - + {#if workspaceLoading} @@ -862,281 +519,6 @@ background: var(--color-bg, #0a0a0b); } - /* ================================================================= */ - /* Context header (inline — personal workspace variant) */ - /* ================================================================= */ - .context-header { - border-bottom: 1px solid var(--color-border, #2a2a2e); - background: var(--color-surface, #1c1c20); - flex-shrink: 0; - z-index: 30; - position: relative; - } - - .context-header-inner { - display: flex; - align-items: center; - justify-content: space-between; - padding: 6px 16px; - max-width: 100%; - min-height: 44px; - } - - .context-header-left { - display: flex; - align-items: center; - gap: 8px; - min-width: 0; - flex: 1; - } - - .context-header-right { - display: flex; - align-items: center; - gap: 10px; - flex-shrink: 0; - } - - .context-back { - font-size: 16px; - color: #5a5a66; - text-decoration: none; - flex-shrink: 0; - padding: 4px; - line-height: 1; - } - - .context-back:hover { - color: #4b5563; - } - - /* ================================================================= */ - /* Context Selector */ - /* ================================================================= */ - .context-selector { - position: relative; - min-width: 0; - } - - .context-selector-trigger { - display: flex; - align-items: center; - gap: 6px; - padding: 4px 10px; - border: 1px solid transparent; - border-radius: 6px; - background: transparent; - cursor: pointer; - font-size: 15px; - font-weight: 600; - color: #e8e8ec; - max-width: 300px; - transition: background 0.12s, border-color 0.12s; - } - - .context-selector-trigger:hover { - background: var(--color-surface-hover, #242428); - border-color: #e5e7eb; - } - - .context-selector-title { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .context-selector-chevron { - font-size: 10px; - color: #5a5a66; - transition: transform 0.15s; - flex-shrink: 0; - } - - .context-selector-chevron.open { - transform: rotate(180deg); - } - - .context-selector-dropdown { - position: absolute; - top: calc(100% + 4px); - left: 0; - min-width: 260px; - max-width: 360px; - background: var(--color-surface, #1c1c20); - border: 1px solid var(--color-border, #2a2a2e); - border-radius: 8px; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); - z-index: 50; - overflow: hidden; - } - - .context-selector-search { - padding: 8px; - border-bottom: 1px solid #f3f4f6; - } - - .context-selector-search-input { - width: 100%; - padding: 6px 10px; - border: 1px solid var(--color-border, #2a2a2e); - border-radius: 6px; - font-size: 13px; - outline: none; - background: var(--color-bg, #141416); - } - - .context-selector-search-input:focus { - border-color: var(--color-accent, #4f46e5); - background: var(--color-surface, #1c1c20); - } - - .context-selector-list { - max-height: 280px; - overflow-y: auto; - padding: 4px; - } - - .context-selector-item { - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - padding: 8px 10px; - border: none; - border-radius: 6px; - background: transparent; - cursor: pointer; - font-size: 13px; - color: #8a8a96; - text-align: left; - transition: background 0.1s; - } - - .context-selector-item:hover { - background: var(--color-surface-hover, #242428); - } - - .context-selector-item-title { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .context-selector-empty { - padding: 16px; - text-align: center; - font-size: 12px; - color: #5a5a66; - } - - /* ================================================================= */ - /* Tool Menu */ - /* ================================================================= */ - .tool-menu { - position: relative; - } - - .tool-menu-trigger { - font-weight: 500; - } - - .tool-menu-dropdown { - position: absolute; - top: calc(100% + 4px); - right: 0; - min-width: 200px; - background: var(--color-surface, #1c1c20); - border: 1px solid var(--color-border, #2a2a2e); - border-radius: 8px; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); - z-index: 50; - overflow: hidden; - padding: 4px; - } - - .tool-menu-title { - padding: 6px 10px 4px; - font-size: 11px; - font-weight: 600; - color: #5a5a66; - text-transform: uppercase; - letter-spacing: 0.05em; - } - - .tool-menu-item { - display: flex; - align-items: center; - gap: 8px; - width: 100%; - padding: 7px 10px; - border: none; - border-radius: 6px; - background: transparent; - cursor: pointer; - font-size: 13px; - color: #8a8a96; - text-align: left; - transition: background 0.1s; - } - - .tool-menu-item:hover:not(:disabled) { - background: var(--color-surface-hover, #242428); - } - - .tool-menu-item:disabled { - cursor: default; - opacity: 0.5; - } - - .tool-menu-item-active { - color: #5a5a66; - } - - .tool-menu-item-icon { - font-size: 15px; - flex-shrink: 0; - } - - .tool-menu-item-label { - flex: 1; - } - - .tool-menu-item-badge { - font-size: 10px; - color: #5a5a66; - background: var(--color-surface-hover, #242428); - padding: 1px 6px; - border-radius: 4px; - } - - /* ================================================================= */ - /* Status + buttons */ - /* ================================================================= */ - .context-status { - font-size: 8px; - color: #d1d5db; - } - - .context-status-ok { - color: #16a34a; - } - - .context-btn { - padding: 4px 10px; - border: none; - border-radius: 6px; - font-size: 12px; - font-weight: 500; - cursor: pointer; - background: var(--color-surface-hover, #242428); - color: #4b5563; - transition: background 0.12s; - } - - .context-btn:hover { - background: #e5e7eb; - } - /* ================================================================= */ /* Messages */ /* ================================================================= */ @@ -1285,216 +667,10 @@ min-height: 100%; } - /* ================================================================= */ - /* Context selector — groups, rename, new */ - /* ================================================================= */ - .context-selector-group-label { - padding: 8px 10px 4px; - font-size: 10px; - font-weight: 600; - color: #5a5a66; - text-transform: uppercase; - letter-spacing: 0.05em; - } - - .context-selector-item-row { - display: flex; - align-items: center; - gap: 2px; - } - - .context-selector-item-row .context-selector-item { - flex: 1; - min-width: 0; - } - - .context-selector-rename-btn { - flex-shrink: 0; - padding: 4px 6px; - border: none; - background: transparent; - cursor: pointer; - font-size: 12px; - opacity: 0; - transition: opacity 0.1s; - border-radius: 4px; - } - - .context-selector-item-row:hover .context-selector-rename-btn { - opacity: 0.6; - } - - .context-selector-rename-btn:hover { - opacity: 1 !important; - background: var(--color-surface-hover, #242428); - } - - .context-selector-rename-input { - flex: 1; - padding: 6px 10px; - border: 1px solid #6366f1 !important; - border-radius: 6px; - font-size: 13px; - outline: none; - background: #141416 !important; - color: #e8e8ec !important; - margin: 2px 4px; - } - - .context-selector-footer { - border-top: 1px solid var(--color-border, #2a2a2e); - padding: 6px; - } - - .context-selector-new-btn { - width: 100%; - padding: 8px 10px; - border: 1px dashed var(--color-border, #2a2a2e); - border-radius: 6px; - background: transparent; - cursor: pointer; - font-size: 13px; - color: #5a5a66; - transition: border-color 0.1s, color 0.1s; - } - - .context-selector-new-btn:hover:not(:disabled) { - border-color: var(--color-accent, #6366f1); - color: #8a8a96; - } - - .context-selector-new-btn:disabled { - opacity: 0.5; - cursor: default; - } - - /* ================================================================= */ - /* Settings menu */ - /* ================================================================= */ - .settings-menu { - position: relative; - } - - .settings-trigger { - font-size: 14px; - } - - .settings-dropdown { - position: absolute; - top: calc(100% + 4px); - right: 0; - min-width: 220px; - background: var(--color-surface, #1c1c20); - background: var(--color-surface, #1c1c20); - border: 1px solid var(--color-border, #2a2a2e); - border: 1px solid var(--color-border, #2a2a2e); - border-radius: 8px; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25); - z-index: 50; - padding: 12px; - } - - .settings-title { - font-size: 11px; - font-weight: 600; - color: #5a5a66; - text-transform: uppercase; - letter-spacing: 0.05em; - margin-bottom: 8px; - } - - .settings-slider { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 6px; - cursor: pointer; - } - - .settings-slider-label { - font-size: 12px; - color: #8a8a96; - min-width: 65px; - } - - .settings-slider input[type="range"] { - flex: 1; - height: 4px; - -webkit-appearance: none; - appearance: none; - background: #2a2a2e !important; - border: none !important; - border-radius: 2px; - outline: none; - padding: 0 !important; - } - - .settings-slider input[type="range"]::-webkit-slider-thumb { - -webkit-appearance: none; - width: 14px; - height: 14px; - border-radius: 50%; - background: var(--color-accent, #6366f1); - cursor: pointer; - } - - .settings-divider { - height: 1px; - background: #2a2a2e; - margin: 10px 0; - } - - .settings-user { - font-size: 13px; - color: #8a8a96; - padding: 4px 0 8px; - } - - .settings-signout { - width: 100%; - padding: 6px 10px; - border: none; - border-radius: 6px; - background: transparent; - cursor: pointer; - font-size: 13px; - color: #8a8a96; - text-align: left; - transition: background 0.1s; - } - - .settings-signout:hover { - background: var(--color-surface-hover, #242428); - color: #e8e8ec; - } - /* ================================================================= */ /* Responsive */ /* ================================================================= */ @media (max-width: 768px) { - .context-header-inner { - padding: 6px 12px; - } - - .context-selector-trigger { - font-size: 14px; - max-width: 200px; - } - - .context-selector-dropdown { - min-width: 220px; - max-width: calc(100vw - 24px); - } - - .tool-menu-dropdown, - .settings-dropdown { - max-width: calc(100vw - 24px); - } - - .context-header-right { - gap: 6px; - } - .workspace-empty-tools { gap: 6px; }
+ Slett «{node.title || 'Uten tittel'}»? + {#if deleteChildCount > 0} + {deleteChildCount} noder tilhører denne flaten. + {/if} +