diff --git a/frontend/src/lib/components/ContextHeader.svelte b/frontend/src/lib/components/ContextHeader.svelte new file mode 100644 index 0000000..e457e41 --- /dev/null +++ b/frontend/src/lib/components/ContextHeader.svelte @@ -0,0 +1,601 @@ + + + + +
+
+ +
+ + +
+ + + {#if selectorOpen} +
+ +
+ {#each filteredCollections as node (node.id)} + + {:else} +
+ {searchQuery ? 'Ingen treff' : 'Ingen samlinger'} +
+ {/each} +
+
+ {/if} +
+
+ + +
+
+ + + {#if toolMenuOpen} +
+
Legg til panel
+ {#each availableTools as tool (tool.key)} + + {/each} +
+ {/if} +
+ + {#if connected} + + {:else} + + {/if} + + {#if connected && collectionNode} + + {/if} +
+
+
+ + diff --git a/frontend/src/lib/workspace/recency.ts b/frontend/src/lib/workspace/recency.ts new file mode 100644 index 0000000..5559685 --- /dev/null +++ b/frontend/src/lib/workspace/recency.ts @@ -0,0 +1,71 @@ +/** + * Tracks node visit frequency and recency for the context selector. + * Uses localStorage to persist across sessions. + * + * Scoring: combines frequency (visit count) with recency (time decay) + * to rank nodes in the context switcher dropdown. + */ + +const STORAGE_KEY = 'synops_node_recency'; +const MAX_ENTRIES = 50; + +interface NodeVisit { + /** Number of times visited */ + count: number; + /** Last visit timestamp (ms since epoch) */ + lastVisit: number; +} + +type RecencyMap = Record; + +function load(): RecencyMap { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) return JSON.parse(raw) as RecencyMap; + } catch { /* ignore corrupt data */ } + return {}; +} + +function save(map: RecencyMap) { + // Prune to MAX_ENTRIES, keeping highest-scored + const entries = Object.entries(map); + if (entries.length > MAX_ENTRIES) { + const now = Date.now(); + entries.sort((a, b) => score(b[1], now) - score(a[1], now)); + const pruned: RecencyMap = {}; + for (let i = 0; i < MAX_ENTRIES; i++) { + pruned[entries[i][0]] = entries[i][1]; + } + localStorage.setItem(STORAGE_KEY, JSON.stringify(pruned)); + } else { + localStorage.setItem(STORAGE_KEY, JSON.stringify(map)); + } +} + +/** Score combines frequency with time decay (halves every 7 days). */ +function score(visit: NodeVisit, now: number): number { + const daysSince = (now - visit.lastVisit) / (1000 * 60 * 60 * 24); + const decay = Math.pow(0.5, daysSince / 7); + return visit.count * decay; +} + +/** Record a visit to a node. Call when navigating to a collection. */ +export function recordVisit(nodeId: string): void { + const map = load(); + const existing = map[nodeId]; + map[nodeId] = { + count: (existing?.count ?? 0) + 1, + lastVisit: Date.now(), + }; + save(map); +} + +/** Get node IDs sorted by score (most relevant first). */ +export function getRankedNodeIds(): string[] { + const map = load(); + const now = Date.now(); + return Object.entries(map) + .map(([id, visit]) => ({ id, score: score(visit, now) })) + .sort((a, b) => b.score - a.score) + .map(e => e.id); +} diff --git a/frontend/src/routes/collection/[id]/+page.svelte b/frontend/src/routes/collection/[id]/+page.svelte index ce03068..6537db3 100644 --- a/frontend/src/routes/collection/[id]/+page.svelte +++ b/frontend/src/routes/collection/[id]/+page.svelte @@ -14,9 +14,13 @@ type PanelLayout, type WorkspaceLayout, getPanelInfo, + generateDefaultLayout, resolveLayout, } from '$lib/workspace/types.js'; + // Context header + import ContextHeader from '$lib/components/ContextHeader.svelte'; + // Trait components import EditorTrait from '$lib/components/traits/EditorTrait.svelte'; import ChatTrait from '$lib/components/traits/ChatTrait.svelte'; @@ -193,6 +197,35 @@ persistLayout(); } + /** Handle adding a new panel from the tool menu */ + function handleAddPanel(trait: string) { + // Don't add duplicate panels + if (layout.panels.some(p => p.trait === trait)) return; + + const info = getPanelInfo(trait); + // Place below existing panels + const maxY = layout.panels.length > 0 + ? Math.max(...layout.panels.map(p => p.y + p.height)) + : 0; + + layout = { + panels: [ + ...layout.panels, + { + trait, + x: 30, + y: maxY + 30, + width: info.defaultWidth, + height: info.defaultHeight, + }, + ], + }; + persistLayout(); + } + + /** Active trait keys in the current layout (for tool menu) */ + const activeLayoutTraits = $derived(layout.panels.map(p => p.trait)); + // ========================================================================= // Mobile detection + tab state // ========================================================================= @@ -216,36 +249,18 @@
- -
-
-
- ← Mottak -

- {collectionNode?.title || 'Samling'} -

-
-
- {#if connected} - Tilkoblet - {:else} - {connectionState.current} - {/if} - {#if traitNames.length > 0} - {traitNames.length} traits - {/if} - {childCount} noder - {#if connected && collectionNode && accessToken} - - {/if} -
-
-
+ + { showTraitAdmin = !showTraitAdmin; }} + {showTraitAdmin} + onAddPanel={handleAddPanel} + activeTraits={activeLayoutTraits} + /> {#if showTraitAdmin && accessToken} @@ -412,93 +427,6 @@ background: #f0f2f5; } - /* ================================================================= */ - /* Header */ - /* ================================================================= */ - .workspace-header { - border-bottom: 1px solid #e5e7eb; - background: white; - flex-shrink: 0; - z-index: 30; - } - - .workspace-header-inner { - display: flex; - align-items: center; - justify-content: space-between; - padding: 8px 16px; - max-width: 100%; - } - - .workspace-header-left { - display: flex; - align-items: center; - gap: 12px; - min-width: 0; - } - - .workspace-header-right { - display: flex; - align-items: center; - gap: 12px; - flex-shrink: 0; - } - - .workspace-back { - font-size: 13px; - color: #9ca3af; - text-decoration: none; - flex-shrink: 0; - } - - .workspace-back:hover { - color: #6b7280; - } - - .workspace-title { - font-size: 16px; - font-weight: 600; - color: #111827; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .workspace-status { - font-size: 11px; - color: #9ca3af; - } - - .workspace-status-ok { - color: #16a34a; - } - - .workspace-meta { - font-size: 11px; - color: #9ca3af; - } - - .workspace-btn { - padding: 4px 10px; - border: none; - border-radius: 6px; - font-size: 12px; - font-weight: 500; - cursor: pointer; - background: #f3f4f6; - color: #4b5563; - transition: background 0.12s; - } - - .workspace-btn:hover { - background: #e5e7eb; - } - - .workspace-btn-active { - background: #4f46e5; - color: white; - } - .workspace-btn-primary { margin-top: 12px; padding: 8px 16px; @@ -643,18 +571,6 @@ /* Responsive */ /* ================================================================= */ @media (max-width: 768px) { - .workspace-header-inner { - padding: 8px 12px; - } - - .workspace-header-right { - gap: 8px; - } - - .workspace-title { - font-size: 15px; - } - .workspace-footer-tools { flex-direction: column; gap: 8px; diff --git a/tasks.md b/tasks.md index 4e891c8..1590127 100644 --- a/tasks.md +++ b/tasks.md @@ -215,8 +215,7 @@ Ref: `docs/retninger/arbeidsflaten.md`, `docs/features/canvas_primitiv.md` - [x] 19.1 Canvas-primitiv Svelte-komponent: pan/zoom kamera med CSS transforms, viewport culling, pointer events (mus + touch), snap-to-grid (valgfritt), fullskjermsmodus. Ref: `docs/features/canvas_primitiv.md`. - [x] 19.2 BlockShell wrapper-komponent: header med tittel + fullskjerm/resize/lukk-knapper, drag-handles for repositionering, resize-handles, drop-sone rendering (highlight ved drag-over). Responsivt (min-size, max-size). - [x] 19.3 Arbeidsflaten layout: skriv om `/collection/[id]` fra vertikal stack til Canvas + BlockShell. Last brukerens lagrede arrangement eller bruk defaults fra samlingens traits. Persist arrangement i bruker-edge metadata. Desktop: spatial canvas, mobil: stacked/tabs. Ref: `docs/retninger/arbeidsflaten.md` § "Tre lag". -- [~] 19.4 Kontekst-header: header tilhører flaten, viser gjeldende node som nedtrekksmeny/kontekst-velger. Mest brukte noder øverst (frekvens/recency), søkbart. Verktøymeny for å instansiere nye paneler. Ref: `docs/retninger/arbeidsflaten.md` § "Kontekst-header". - > Påbegynt: 2026-03-18T07:33 +- [x] 19.4 Kontekst-header: header tilhører flaten, viser gjeldende node som nedtrekksmeny/kontekst-velger. Mest brukte noder øverst (frekvens/recency), søkbart. Verktøymeny for å instansiere nye paneler. Ref: `docs/retninger/arbeidsflaten.md` § "Kontekst-header". - [ ] 19.5 Snarveier: paneler kan minimeres til kompakt ikon/fane. Dobbeltklikk → minimer/gjenopprett. Bevarer posisjon og størrelse. Ref: `docs/retninger/arbeidsflaten.md` § "Snarveier". - [ ] 19.6 Personlig flate: brukerens standard arbeidsflate (node_kind: 'workspace'). Vises når ikke koblet til en annen node. Persistent layout.