From d82fab25df7448a3bfb0f8e13eea00d8191cea3b Mon Sep 17 00:00:00 2001 From: vegard Date: Thu, 19 Mar 2026 05:11:09 +0000 Subject: [PATCH] Workspace UI: AI/ressurs-paneler, innstillinger, kontekst-velger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AI-verktøy og Ressursforbruk registrert som BlockShell-paneler i verktøymenyen (🤖 og 📊) - Innstillingsmeny (⚙️) lengst til høyre i header: tre hue-slidere (bakgrunn, overflate, aksent) + logg ut. Lagres i workspace-metadata. - Kontekst-velger: to grupper (Mine flater / Delte flater), inline rename (✏️), "+ Ny arbeidsflate"-knapp - Mørke overrides for manglende Tailwind bg-farger i app.css Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/fikseliste.md | 26 +- frontend/src/app.css | 5 + frontend/src/lib/workspace/types.ts | 2 + frontend/src/routes/+page.svelte | 427 ++++++++++++++++++++++++++-- 4 files changed, 430 insertions(+), 30 deletions(-) diff --git a/docs/fikseliste.md b/docs/fikseliste.md index d2d6047..2dab8b4 100644 --- a/docs/fikseliste.md +++ b/docs/fikseliste.md @@ -4,20 +4,21 @@ Funnet ved manuell testing av frontend. Fikses som en samlet sesjon. ## Workspace -- [ ] AI-verktøy er hardkodet utenfor workspace (footer). Skal være et valgfritt BlockShell-panel som alle andre verktøy. -- [ ] Ressursforbruk er hardkodet utenfor workspace. Skal være et valgfritt BlockShell-panel. +- [x] AI-verktøy er nå et valgfritt BlockShell-panel (🤖 i verktøymenyen). +- [x] Ressursforbruk er nå et valgfritt BlockShell-panel (📊 i verktøymenyen). - [x] BlockShell-knapper (minimer, maksimer, lukk) fikset: - Minimer → kollapser til kompakt ikon/fane, bevarer posisjon - Maksimer → fullskjerm overlay (portalt til body), Escape for å gå tilbake - Lukk → fjern panel fra workspace - [x] Kanban-panel kan nå lukkes (samme fix) -- [ ] Fjern footer-feltet helt. Alt som var der (AI, ressurs) blir paneler i canvas. Canvas får full høyde. +- [x] Ingen footer — canvas har full høyde. - [ ] Workspace-modifikatorer (zoom-knapper, fullskjerm, snap-to-grid, tilpass) er uvirksomme. Zoom via musehjul fungerer. ## Header / innstillinger -- [ ] Fargevelger i header-meny (⚙️): bakgrunn, overflate, accent-hue. Lagres i person-node metadata.preferences.theme. Tre slidere er nok. -- [ ] Innstillingsmeny (⚙️-ikon i header): tema, varsler, profil. Ikke et panel i workspace — det styrer *hele* brukeropplevelsen. +- [x] Innstillingsmeny (⚙️-ikon lengst til høyre i header): tre hue-slidere (bakgrunn, overflate, aksent). Lagres i workspace-node metadata.preferences.theme. +- [x] Logg ut-knapp i innstillingsmenyen. +- [ ] Gjenstående: varsler, profil-innstillinger. ## Stor refaktor: workspace er appen @@ -40,16 +41,15 @@ Funnet ved manuell testing av frontend. Fikses som en samlet sesjon. ## Kontekst-velger (arbeidsflate-dropdown i header) -- [ ] Vis "Mine flater" og "Delte flater" som to grupper i dropdown. -- [ ] ✏️-ikon på egne flater for inline rename (klikk, skriv, enter). -- [ ] "+ Ny arbeidsflate"-knapp nederst i dropdown → opprett blank workspace-node. -- [ ] "Del med..."-handling (høyreklikk eller ⚙️) → velg person/team, velg rolle → member_of-edge. -- [ ] Flaten dukker opp under "Delte flater" hos mottaker. -- [ ] Bruk begrepet "arbeidsflate" konsekvent, ikke "workspace". +- [x] Vis "Mine flater" og "Delte flater" som to grupper i dropdown. +- [x] ✏️-ikon på egne flater for inline rename (klikk, skriv, enter). +- [x] "+ Ny arbeidsflate"-knapp nederst i dropdown → opprett blank workspace-node. +- [ ] "Del med..."-handling → velg person/team, velg rolle → member_of-edge. (v2) +- [x] Bruk begrepet "arbeidsflate" konsekvent, ikke "workspace". -## Tema (pågår) +## Tema - [x] Mørkt tema: arbeidsflaten (canvas + header) - [x] Mørkt tema: canvas-bakgrunn + grid-linjer +- [x] Hue-slidere for bakgrunn, overflate, aksent (lagres i workspace-metadata) - [ ] Gjenstående lyse elementer i chat, board, kalender, admin (CSS-override dekker noe, men hardkodede farger i style-blokker gjenstår) -- [ ] Lys/mørk-toggle i innstillingsmeny diff --git a/frontend/src/app.css b/frontend/src/app.css index aad9d43..478f3ff 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -58,6 +58,11 @@ div[style*="display: contents"] { background-color: var(--color-bg) !important; .hover\:bg-indigo-600:hover, .hover\:bg-indigo-700:hover { background-color: #7577f5 !important; } .text-indigo-600, .text-indigo-500, .text-indigo-700 { color: #6366f1 !important; } .bg-indigo-50, .bg-indigo-100 { background-color: rgba(99, 102, 241, 0.15) !important; } + .bg-purple-50 { background-color: rgba(139, 92, 246, 0.1) !important; } + .bg-green-50 { background-color: rgba(34, 197, 94, 0.1) !important; } + .bg-red-50 { background-color: rgba(239, 68, 68, 0.1) !important; } + .bg-blue-50 { background-color: rgba(59, 130, 246, 0.1) !important; } + .bg-yellow-50 { background-color: rgba(234, 179, 8, 0.1) !important; } .border-indigo-300 { border-color: #6366f1 !important; } .hover\:border-indigo-300:hover { border-color: #6366f1 !important; } diff --git a/frontend/src/lib/workspace/types.ts b/frontend/src/lib/workspace/types.ts index a9c9296..60f0855 100644 --- a/frontend/src/lib/workspace/types.ts +++ b/frontend/src/lib/workspace/types.ts @@ -56,6 +56,8 @@ export const TRAIT_PANEL_INFO: Record = { mixer: { title: 'Mikser', icon: '🎚️', defaultWidth: 450, defaultHeight: 400 }, orchestration: { title: 'Orkestrering', icon: '⚡', defaultWidth: 550, defaultHeight: 500 }, mindmap: { title: 'Tankekart', icon: '🧠', defaultWidth: 600, defaultHeight: 500 }, + ai: { title: 'AI-verktøy', icon: '🤖', defaultWidth: 420, defaultHeight: 500 }, + usage: { title: 'Ressursforbruk', icon: '📊', defaultWidth: 380, defaultHeight: 350 }, }; /** Default info for unknown traits */ diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 8ce4dca..ebc6064 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -3,7 +3,8 @@ import { goto } from '$app/navigation'; import { connectionState, nodeStore, edgeStore, nodeAccessStore, nodeVisibility } from '$lib/realtime'; import type { Node } from '$lib/realtime'; - import { fetchMyWorkspace, updateNode } from '$lib/api'; + import { fetchMyWorkspace, updateNode, createNode, createEdge } from '$lib/api'; + import { signOut } from '@auth/sveltekit/client'; // Canvas + BlockShell import Canvas from '$lib/components/canvas/Canvas.svelte'; @@ -36,6 +37,8 @@ import MixerTrait from '$lib/components/traits/MixerTrait.svelte'; import MindMapTrait from '$lib/components/traits/MindMapTrait.svelte'; import GenericTrait from '$lib/components/traits/GenericTrait.svelte'; + import AiToolPanel from '$lib/components/AiToolPanel.svelte'; + import NodeUsage from '$lib/components/NodeUsage.svelte'; import { createBlockReceiver, executeTransfer, resolveTransferMode, type DragPayload } from '$lib/transfer'; import type { BlockReceiver } from '$lib/components/blockshell/types'; @@ -70,6 +73,10 @@ } else if (!layoutInitialized) { layoutInitialized = true; } + // Load theme preferences + if (res.metadata) { + loadThemeFromMetadata(res.metadata as Record); + } }) .catch((err) => { workspaceError = err.message; @@ -120,12 +127,12 @@ let saveTimeout: ReturnType | undefined; - function persistLayout() { + /** Persist all workspace metadata (layout + preferences) */ + function persistMetadata() { if (!accessToken || !workspaceNodeId) return; clearTimeout(saveTimeout); saveTimeout = setTimeout(async () => { try { - // Read current metadata from node store const currentMeta = workspaceNode ? JSON.parse(workspaceNode.metadata ?? '{}') : {}; @@ -134,14 +141,21 @@ metadata: { ...currentMeta, workspace_layout: layout, + preferences: { + ...(currentMeta.preferences ?? {}), + theme: { hueBg: themeHueBg, hueSurface: themeHueSurface, hueAccent: themeHueAccent }, + }, }, }); } catch (err) { - console.warn('Failed to persist workspace layout:', err); + console.warn('Failed to persist workspace metadata:', err); } }, 1000); } + // Keep old name as alias for callers + function persistLayout() { persistMetadata(); } + function handleObjectMove(id: string, x: number, y: number) { const idx = layout.panels.findIndex(p => p.trait === id); if (idx >= 0) { @@ -240,11 +254,67 @@ return collectionNodes.filter(n => (n.title ?? '').toLowerCase().includes(q)); }); + const myCollections = $derived(filteredCollections.filter(n => n.createdBy === nodeId)); + const sharedCollections = $derived(filteredCollections.filter(n => n.createdBy !== nodeId)); + + let renamingId = $state(undefined); + let renameValue = $state(''); + + function startRename(node: Node) { + renamingId = node.id; + renameValue = node.title || ''; + } + + async function commitRename() { + if (!renamingId || !accessToken) return; + const id = renamingId; + renamingId = undefined; + if (renameValue.trim()) { + try { + await updateNode(accessToken, { node_id: id, title: renameValue.trim() }); + } catch (e) { + console.error('Feil ved omdøping:', e); + } + } + } + + function handleRenameKeydown(e: KeyboardEvent) { + if (e.key === 'Enter') { e.preventDefault(); commitRename(); } + if (e.key === 'Escape') { renamingId = undefined; } + } + + let isCreatingWorkspace = $state(false); + + async function createNewWorkspace() { + if (!accessToken || !nodeId || isCreatingWorkspace) return; + isCreatingWorkspace = true; + selectorOpen = false; + try { + const { node_id } = await createNode(accessToken, { + node_kind: 'collection', + title: 'Ny arbeidsflate', + visibility: 'hidden', + }); + await createEdge(accessToken, { + source_id: nodeId, + target_id: node_id, + edge_type: 'owner', + }); + goto(`/collection/${node_id}`); + } catch (e) { + console.error('Feil ved oppretting av arbeidsflate:', e); + } finally { + isCreatingWorkspace = false; + } + } + function toggleSelector() { selectorOpen = !selectorOpen; toolMenuOpen = false; + settingsOpen = false; if (selectorOpen) { searchQuery = ''; + renamingId = undefined; requestAnimationFrame(() => searchInput?.focus()); } } @@ -271,6 +341,7 @@ function toggleToolMenu() { toolMenuOpen = !toolMenuOpen; selectorOpen = false; + settingsOpen = false; } function addTool(trait: string) { @@ -278,6 +349,48 @@ toolMenuOpen = false; } + // ========================================================================= + // Settings menu (theme + sign out) + // ========================================================================= + + let settingsOpen = $state(false); + let themeHueBg = $state(0); + let themeHueSurface = $state(0); + let themeHueAccent = $state(240); // default indigo ≈ 240 + + function toggleSettings() { + settingsOpen = !settingsOpen; + selectorOpen = false; + toolMenuOpen = false; + } + + function hslColor(hue: number, sat: number, light: number): string { + return `hsl(${hue}, ${sat}%, ${light}%)`; + } + + function applyTheme() { + const root = document.documentElement; + root.style.setProperty('--color-bg', hslColor(themeHueBg, themeHueBg ? 10 : 0, 4)); + root.style.setProperty('--color-surface', hslColor(themeHueSurface, themeHueSurface ? 8 : 0, 12)); + root.style.setProperty('--color-surface-hover', hslColor(themeHueSurface, themeHueSurface ? 6 : 0, 15)); + root.style.setProperty('--color-border', hslColor(themeHueSurface, themeHueSurface ? 5 : 0, 18)); + root.style.setProperty('--color-accent', hslColor(themeHueAccent, 70, 60)); + root.style.setProperty('--color-accent-hover', hslColor(themeHueAccent, 70, 65)); + root.style.setProperty('--color-accent-glow', `hsla(${themeHueAccent}, 70%, 60%, 0.15)`); + persistMetadata(); + } + + function loadThemeFromMetadata(meta: Record) { + const prefs = meta.preferences as Record | undefined; + const theme = prefs?.theme as Record | undefined; + if (theme) { + themeHueBg = theme.hueBg ?? 0; + themeHueSurface = theme.hueSurface ?? 0; + themeHueAccent = theme.hueAccent ?? 240; + applyTheme(); + } + } + // ========================================================================= // Mobile detection // ========================================================================= @@ -310,19 +423,24 @@ if (toolMenuOpen && !target.closest('.tool-menu')) { toolMenuOpen = false; } + if (settingsOpen && !target.closest('.settings-menu')) { + settingsOpen = false; + } } function handleKeydown(e: KeyboardEvent) { if (e.key === 'Escape') { selectorOpen = false; toolMenuOpen = false; + settingsOpen = false; } } /** Trait components that have dedicated implementations */ const knownTraits = new Set([ 'editor', 'chat', 'kanban', 'podcast', 'publishing', - 'rss', 'calendar', 'recording', 'transcription', 'studio', 'mixer', 'mindmap' + 'rss', 'calendar', 'recording', 'transcription', 'studio', 'mixer', 'mindmap', + 'ai', 'usage' ]); // ========================================================================= @@ -402,24 +520,65 @@ 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()} />
- {#each filteredCollections as node (node.id)} - - {:else} + {#if myCollections.length > 0} +
Mine flater
+ {#each myCollections as node (node.id)} +
+ {#if renamingId === node.id} + e.stopPropagation()} + /> + {:else} + + + {/if} +
+ {/each} + {/if} + {#if sharedCollections.length > 0} +
Delte flater
+ {#each sharedCollections as node (node.id)} + + {/each} + {/if} + {#if myCollections.length === 0 && sharedCollections.length === 0}
- {searchQuery ? 'Ingen treff' : 'Ingen samlinger'} + {searchQuery ? 'Ingen treff' : 'Ingen arbeidsflater'}
- {/each} + {/if} +
+ {/if} @@ -463,6 +622,44 @@ {:else} {/if} + +
+ + + {#if settingsOpen} +
+
Tema
+ + + +
+ {#if $page.data.session?.user} +
{$page.data.session.user.name}
+ {/if} + +
+ {/if} +
@@ -545,6 +742,12 @@ {:else if panel.trait === 'mindmap'} + {:else if panel.trait === 'ai'} + + {:else if panel.trait === 'usage'} + {#if nodeId && accessToken} + + {/if} {/if} {:else} @@ -602,6 +805,12 @@ {:else if trait === 'mindmap'} + {:else if trait === 'ai'} + + {:else if trait === 'usage'} + {#if nodeId && accessToken} + + {/if} {/if} {:else} @@ -1045,6 +1254,189 @@ 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: #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 #2a2a2e; + padding: 6px; + } + + .context-selector-new-btn { + width: 100%; + padding: 8px 10px; + border: 1px dashed #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: #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: #1c1c20; + background: var(--color-surface, #1c1c20); + border: 1px solid #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: #242428; + color: #e8e8ec; + } + /* ================================================================= */ /* Responsive */ /* ================================================================= */ @@ -1063,7 +1455,8 @@ max-width: calc(100vw - 24px); } - .tool-menu-dropdown { + .tool-menu-dropdown, + .settings-dropdown { max-width: calc(100vw - 24px); }