From 0caa8eb126e159b3462bee15d7ddc42bbd4642dd Mon Sep 17 00:00:00 2001 From: vegard Date: Wed, 18 Mar 2026 07:31:40 +0000 Subject: [PATCH] =?UTF-8?q?Fullf=C3=B8rer=20oppgave=2019.3:=20Arbeidsflate?= =?UTF-8?q?n=20layout=20med=20Canvas=20+=20BlockShell?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skriver om /collection/[id] fra vertikal stack til spatial workspace: - Desktop: trait-paneler vises som BlockShell-wrappers på Canvas med fri pan/zoom, drag-repositionering og resize - Mobil (<768px): tab-navigasjon med ett synlig panel om gangen - Tre-lags layout-modell: personlig (edge metadata) > node-default (generert fra traits) > plattform-default (grid fallback) - Layout persisteres debounced (1s) til brukerens owner/member_of edge metadata via updateEdge API - Nye traits legges automatisk til eksisterende layout - Fjernede traits filtreres ut ved resolving Ny fil: frontend/src/lib/workspace/types.ts - PanelLayout/WorkspaceLayout typer - TRAIT_PANEL_INFO med default størrelse/ikon per trait - generateDefaultLayout(): grid-arrangement fra trait-liste - resolveLayout(): tre-lags merging med saved layout Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/lib/workspace/types.ts | 148 ++++ .../src/routes/collection/[id]/+page.svelte | 679 +++++++++++++++--- tasks.md | 3 +- 3 files changed, 721 insertions(+), 109 deletions(-) create mode 100644 frontend/src/lib/workspace/types.ts diff --git a/frontend/src/lib/workspace/types.ts b/frontend/src/lib/workspace/types.ts new file mode 100644 index 0000000..efef069 --- /dev/null +++ b/frontend/src/lib/workspace/types.ts @@ -0,0 +1,148 @@ +/** + * Workspace Layout Types — defines panel arrangement for the spatial canvas. + * + * Tre lag: + * 1. Personlig — brukerens eget arrangement (lagret i edge metadata) + * 2. Node-default — samlingens traits foreslår layout + * 3. Plattform-default — Synops sine standarder (fallback) + * + * Ref: docs/retninger/arbeidsflaten.md § "Tre lag" + */ + +/** Position and size of a single panel on the canvas */ +export interface PanelLayout { + /** Trait name this panel represents */ + trait: string; + /** X position in world coordinates */ + x: number; + /** Y position in world coordinates */ + y: number; + /** Panel width */ + width: number; + /** Panel height */ + height: number; +} + +/** Full workspace layout state */ +export interface WorkspaceLayout { + panels: PanelLayout[]; +} + +/** Panel metadata: icon and display name per trait */ +export interface TraitPanelInfo { + title: string; + icon: string; + defaultWidth: number; + defaultHeight: number; +} + +/** Known trait panel configurations */ +export const TRAIT_PANEL_INFO: Record = { + chat: { title: 'Chat', icon: '💬', defaultWidth: 400, defaultHeight: 500 }, + editor: { title: 'Artikkelverktøy', icon: '📝', defaultWidth: 600, defaultHeight: 500 }, + kanban: { title: 'Kanban', icon: '📋', defaultWidth: 500, defaultHeight: 450 }, + calendar: { title: 'Kalender', icon: '📅', defaultWidth: 500, defaultHeight: 400 }, + podcast: { title: 'Podcast', icon: '🎙️', defaultWidth: 450, defaultHeight: 400 }, + publishing: { title: 'Publisering', icon: '📤', defaultWidth: 400, defaultHeight: 350 }, + rss: { title: 'RSS', icon: '📡', defaultWidth: 400, defaultHeight: 350 }, + recording: { title: 'Opptak', icon: '🔴', defaultWidth: 450, defaultHeight: 400 }, + transcription: { title: 'Transkripsjon', icon: '📄', defaultWidth: 500, defaultHeight: 450 }, + studio: { title: 'Studio', icon: '🎛️', defaultWidth: 550, defaultHeight: 450 }, + mixer: { title: 'Mikser', icon: '🎚️', defaultWidth: 450, defaultHeight: 400 }, +}; + +/** Default info for unknown traits */ +const DEFAULT_PANEL_INFO: TraitPanelInfo = { + title: 'Verktøy', + icon: '🔧', + defaultWidth: 400, + defaultHeight: 350, +}; + +/** Get panel info for a trait, falling back to defaults */ +export function getPanelInfo(trait: string): TraitPanelInfo { + return TRAIT_PANEL_INFO[trait] ?? { ...DEFAULT_PANEL_INFO, title: trait }; +} + +/** + * Generate a default layout from trait names. + * Arranges panels in a grid, spaced evenly. + * This is the "plattform-default" layer. + */ +export function generateDefaultLayout(traitNames: string[]): WorkspaceLayout { + if (traitNames.length === 0) return { panels: [] }; + + const GAP = 30; + const panels: PanelLayout[] = []; + + // Calculate grid: aim for roughly 2-3 columns + const cols = traitNames.length <= 2 ? traitNames.length : Math.min(3, Math.ceil(Math.sqrt(traitNames.length))); + + let currentX = GAP; + let currentY = GAP; + let rowMaxHeight = 0; + + for (let i = 0; i < traitNames.length; i++) { + const col = i % cols; + if (col === 0 && i > 0) { + // New row + currentX = GAP; + currentY += rowMaxHeight + GAP; + rowMaxHeight = 0; + } + + const info = getPanelInfo(traitNames[i]); + panels.push({ + trait: traitNames[i], + x: currentX, + y: currentY, + width: info.defaultWidth, + height: info.defaultHeight, + }); + + currentX += info.defaultWidth + GAP; + rowMaxHeight = Math.max(rowMaxHeight, info.defaultHeight); + } + + return { panels }; +} + +/** + * Resolve layout using the three-layer model: + * 1. Personal (from edge metadata) — highest priority + * 2. Node-default (from traits) — if no personal layout + * 3. Platform-default (generated) — fallback + */ +export function resolveLayout( + traitNames: string[], + savedLayout: WorkspaceLayout | null, +): WorkspaceLayout { + if (savedLayout && savedLayout.panels.length > 0) { + // Personal layout exists — use it, but add any new traits not in saved layout + const savedTraits = new Set(savedLayout.panels.map(p => p.trait)); + const newTraits = traitNames.filter(t => !savedTraits.has(t)); + + if (newTraits.length === 0) { + // Filter out panels for traits that no longer exist + const activeTraits = new Set(traitNames); + return { + panels: savedLayout.panels.filter(p => activeTraits.has(p.trait)), + }; + } + + // Append new traits at the end + const existingPanels = savedLayout.panels.filter(p => new Set(traitNames).has(p.trait)); + const maxY = Math.max(0, ...existingPanels.map(p => p.y + p.height)); + const newLayout = generateDefaultLayout(newTraits); + // Offset new panels below existing ones + const offsetPanels = newLayout.panels.map(p => ({ + ...p, + y: p.y + maxY + 30, + })); + + return { panels: [...existingPanels, ...offsetPanels] }; + } + + // No saved layout — generate from traits (platform default) + return generateDefaultLayout(traitNames); +} diff --git a/frontend/src/routes/collection/[id]/+page.svelte b/frontend/src/routes/collection/[id]/+page.svelte index f7bf23d..ce03068 100644 --- a/frontend/src/routes/collection/[id]/+page.svelte +++ b/frontend/src/routes/collection/[id]/+page.svelte @@ -2,6 +2,20 @@ import { page } from '$app/stores'; import { connectionState, nodeStore, edgeStore, nodeVisibility } from '$lib/spacetime'; import type { Node } from '$lib/spacetime'; + import { updateEdge } from '$lib/api'; + + // Canvas + BlockShell + import Canvas from '$lib/components/canvas/Canvas.svelte'; + import type { CanvasObject } from '$lib/components/canvas/types.js'; + import BlockShell from '$lib/components/blockshell/BlockShell.svelte'; + + // Workspace layout + import { + type PanelLayout, + type WorkspaceLayout, + getPanelInfo, + resolveLayout, + } from '$lib/workspace/types.js'; // Trait components import EditorTrait from '$lib/components/traits/EditorTrait.svelte'; @@ -54,12 +68,6 @@ 'rss', 'calendar', 'recording', 'transcription', 'studio', 'mixer' ]); - /** Traits that have a dedicated component */ - const renderedTraits = $derived(traitNames.filter(t => knownTraits.has(t))); - - /** Traits without a dedicated component — shown with generic panel */ - const genericTraits = $derived(traitNames.filter(t => !knownTraits.has(t))); - /** Count of child nodes */ const childCount = $derived.by(() => { if (!connected || !collectionId) return 0; @@ -69,32 +77,168 @@ } return count; }); + + // ========================================================================= + // Workspace layout state + // ========================================================================= + + /** Find the user's edge to this collection (owner or member_of) */ + const userEdge = $derived.by(() => { + if (!connected || !nodeId || !collectionId) return undefined; + for (const edge of edgeStore.bySource(nodeId)) { + if ( + (edge.edgeType === 'owner' || edge.edgeType === 'member_of') && + edge.targetId === collectionId + ) { + return edge; + } + } + return undefined; + }); + + /** Parse saved layout from user's edge metadata */ + const savedLayout = $derived.by((): WorkspaceLayout | null => { + if (!userEdge) return null; + try { + const meta = JSON.parse(userEdge.metadata ?? '{}'); + if (meta.workspace_layout && Array.isArray(meta.workspace_layout.panels)) { + return meta.workspace_layout as WorkspaceLayout; + } + } catch { /* ignore */ } + return null; + }); + + /** Resolved layout using three-layer model */ + let layout = $state({ panels: [] }); + let layoutInitialized = $state(false); + + // Initialize layout when traits and saved layout become available + $effect(() => { + if (traitNames.length === 0) return; + // Only initialize once per collection — user moves update layout directly + if (!layoutInitialized) { + layout = resolveLayout(traitNames, savedLayout); + layoutInitialized = true; + } + }); + + // Reset when collection changes + $effect(() => { + const _id = collectionId; + layoutInitialized = false; + }); + + /** Convert layout panels to CanvasObjects for the Canvas component */ + const canvasObjects = $derived( + layout.panels.map(p => ({ + id: p.trait, + x: p.x, + y: p.y, + width: p.width, + height: p.height, + })) + ); + + // ========================================================================= + // Layout persistence + // ========================================================================= + + let saveTimeout: ReturnType | undefined; + + /** Persist layout to user's edge metadata (debounced) */ + function persistLayout() { + if (!accessToken || !userEdge) return; + clearTimeout(saveTimeout); + saveTimeout = setTimeout(async () => { + try { + const existingMeta = JSON.parse(userEdge!.metadata ?? '{}'); + await updateEdge(accessToken!, { + edge_id: userEdge!.id, + metadata: { + ...existingMeta, + workspace_layout: layout, + }, + }); + } catch (err) { + console.warn('Failed to persist workspace layout:', err); + } + }, 1000); + } + + /** Handle panel move on canvas */ + function handleObjectMove(id: string, x: number, y: number) { + const idx = layout.panels.findIndex(p => p.trait === id); + if (idx >= 0) { + layout.panels[idx] = { ...layout.panels[idx], x, y }; + layout = { ...layout }; + persistLayout(); + } + } + + /** Handle panel resize via BlockShell */ + function handlePanelResize(trait: string, width: number, height: number) { + const idx = layout.panels.findIndex(p => p.trait === trait); + if (idx >= 0) { + layout.panels[idx] = { ...layout.panels[idx], width, height }; + layout = { ...layout }; + persistLayout(); + } + } + + /** Handle panel close */ + function handlePanelClose(trait: string) { + layout = { + panels: layout.panels.filter(p => p.trait !== trait), + }; + persistLayout(); + } + + // ========================================================================= + // Mobile detection + tab state + // ========================================================================= + + let windowWidth = $state(typeof window !== 'undefined' ? window.innerWidth : 1024); + const isMobile = $derived(windowWidth < 768); + let activeTab = $state(0); + + $effect(() => { + function onResize() { windowWidth = window.innerWidth; } + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); + }); + + // Clamp active tab when panels change + $effect(() => { + if (activeTab >= traitNames.length && traitNames.length > 0) { + activeTab = 0; + } + }); -
+
-
-
-
- ← Mottak -

+
+
+
+ ← Mottak +

{collectionNode?.title || 'Samling'}

-
+
{#if connected} - Tilkoblet + Tilkoblet {:else} - {connectionState.current} + {connectionState.current} {/if} {#if traitNames.length > 0} - {traitNames.length} traits + {traitNames.length} traits {/if} - {childCount} noder + {childCount} noder {#if connected && collectionNode && accessToken} @@ -103,97 +247,418 @@
-
- - {#if showTraitAdmin && accessToken} -
- { showTraitAdmin = false; }} - /> -
- {/if} + + {#if showTraitAdmin && accessToken} +
+ { showTraitAdmin = false; }} + /> +
+ {/if} - {#if !connected} -

Venter på tilkobling…

- {:else if !collectionNode} -
-

Samling ikke funnet

-

Samlingsnoden med ID {collectionId} finnes ikke eller er ikke tilgjengelig.

- Tilbake til mottak -
- {:else if traitNames.length === 0} -
-

Denne samlingen har ingen aktive traits.

-

Traits bestemmer hvilke verktøy og visninger som er tilgjengelige.

- {#if accessToken && !showTraitAdmin} - + + {#if !connected} +
+

Venter på tilkobling…

+
+ {:else if !collectionNode} +
+

Samling ikke funnet

+

Samlingsnoden med ID {collectionId} finnes ikke eller er ikke tilgjengelig.

+ Tilbake til mottak +
+ {:else if traitNames.length === 0} +
+

Denne samlingen har ingen aktive traits.

+

Traits bestemmer hvilke verktøy og visninger som er tilgjengelige.

+ {#if accessToken && !showTraitAdmin} + + {/if} +
+ {:else if isMobile} + + + +
+ {#each traitNames as trait, i (trait)} + + {/each} +
+ +
+ {#each traitNames as trait, i (trait)} + {#if activeTab === i} +
+ {#if knownTraits.has(trait)} + {#if trait === 'editor'} + + {:else if trait === 'chat'} + + {:else if trait === 'kanban'} + + {:else if trait === 'podcast'} + + {:else if trait === 'publishing'} + + {:else if trait === 'rss'} + + {:else if trait === 'calendar'} + + {:else if trait === 'recording'} + + {:else if trait === 'transcription'} + + {:else if trait === 'studio'} + + {:else if trait === 'mixer'} + + {/if} + {:else} + + {/if} +
{/if} -
- {:else} - -
- {#each traitNames as trait (trait)} - - {trait} - - {/each} -
+ {/each} +
+ {:else} + + + +
+ + {#snippet renderObject(obj)} + {@const trait = obj.id} + {@const info = getPanelInfo(trait)} + {@const panel = layout.panels.find(p => p.trait === trait)} + handlePanelResize(trait, w, h)} + onClose={() => handlePanelClose(trait)} + > + {#if knownTraits.has(trait)} + {#if trait === 'editor'} + + {:else if trait === 'chat'} + + {:else if trait === 'kanban'} + + {:else if trait === 'podcast'} + + {:else if trait === 'publishing'} + + {:else if trait === 'rss'} + + {:else if trait === 'calendar'} + + {:else if trait === 'recording'} + + {:else if trait === 'transcription'} + + {:else if trait === 'studio'} + + {:else if trait === 'mixer'} + + {/if} + {:else} + + {/if} + + {/snippet} + +
+ {/if} - -
- {#each renderedTraits as trait (trait)} - {#if trait === 'editor'} - - {:else if trait === 'chat'} - - {:else if trait === 'kanban'} - - {:else if trait === 'podcast'} - - {:else if trait === 'publishing'} - - {:else if trait === 'rss'} - - {:else if trait === 'calendar'} - - {:else if trait === 'recording'} - - {:else if trait === 'transcription'} - - {:else if trait === 'studio'} - - {:else if trait === 'mixer'} - - {/if} - {/each} - - {#each genericTraits as trait (trait)} - - {/each} -
- {/if} - - - {#if connected && accessToken} -
- -
- {/if} - - - {#if accessToken && collectionId} -
+ + {#if connected && accessToken} + - {/if} -
+ {/if} +

+ {/if}
+ + diff --git a/tasks.md b/tasks.md index 2aa4a9b..061691c 100644 --- a/tasks.md +++ b/tasks.md @@ -214,8 +214,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). -- [~] 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". - > Påbegynt: 2026-03-18T07:26 +- [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". - [ ] 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.