Fullfører oppgave 19.3: Arbeidsflaten layout med Canvas + BlockShell
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) <noreply@anthropic.com>
This commit is contained in:
parent
4b6310ce53
commit
0caa8eb126
3 changed files with 721 additions and 109 deletions
148
frontend/src/lib/workspace/types.ts
Normal file
148
frontend/src/lib/workspace/types.ts
Normal file
|
|
@ -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<string, TraitPanelInfo> = {
|
||||
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);
|
||||
}
|
||||
|
|
@ -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<WorkspaceLayout>({ 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<CanvasObject[]>(
|
||||
layout.panels.map(p => ({
|
||||
id: p.trait,
|
||||
x: p.x,
|
||||
y: p.y,
|
||||
width: p.width,
|
||||
height: p.height,
|
||||
}))
|
||||
);
|
||||
|
||||
// =========================================================================
|
||||
// Layout persistence
|
||||
// =========================================================================
|
||||
|
||||
let saveTimeout: ReturnType<typeof setTimeout> | 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;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<div class="workspace-page">
|
||||
<!-- Header -->
|
||||
<header class="border-b border-gray-200 bg-white">
|
||||
<div class="mx-auto flex max-w-4xl items-center justify-between px-4 py-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/" class="text-sm text-gray-400 hover:text-gray-600">← Mottak</a>
|
||||
<h1 class="text-lg font-semibold text-gray-900">
|
||||
<header class="workspace-header">
|
||||
<div class="workspace-header-inner">
|
||||
<div class="workspace-header-left">
|
||||
<a href="/" class="workspace-back">← Mottak</a>
|
||||
<h1 class="workspace-title">
|
||||
{collectionNode?.title || 'Samling'}
|
||||
</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="workspace-header-right">
|
||||
{#if connected}
|
||||
<span class="text-xs text-green-600">Tilkoblet</span>
|
||||
<span class="workspace-status workspace-status-ok">Tilkoblet</span>
|
||||
{:else}
|
||||
<span class="text-xs text-gray-400">{connectionState.current}</span>
|
||||
<span class="workspace-status">{connectionState.current}</span>
|
||||
{/if}
|
||||
{#if traitNames.length > 0}
|
||||
<span class="text-xs text-gray-400">{traitNames.length} traits</span>
|
||||
<span class="workspace-meta">{traitNames.length} traits</span>
|
||||
{/if}
|
||||
<span class="text-xs text-gray-400">{childCount} noder</span>
|
||||
<span class="workspace-meta">{childCount} noder</span>
|
||||
{#if connected && collectionNode && accessToken}
|
||||
<button
|
||||
onclick={() => { showTraitAdmin = !showTraitAdmin; }}
|
||||
class="rounded-lg px-2 py-1 text-xs font-medium transition-colors {showTraitAdmin ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}"
|
||||
class="workspace-btn {showTraitAdmin ? 'workspace-btn-active' : ''}"
|
||||
>
|
||||
Traits
|
||||
</button>
|
||||
|
|
@ -103,97 +247,418 @@
|
|||
</div>
|
||||
</header>
|
||||
|
||||
<main class="mx-auto max-w-4xl px-4 py-6">
|
||||
<!-- Trait admin panel -->
|
||||
{#if showTraitAdmin && accessToken}
|
||||
<div class="mb-6">
|
||||
<TraitAdmin
|
||||
{accessToken}
|
||||
collectionId={collectionId}
|
||||
{traits}
|
||||
metadata={parsedMetadata}
|
||||
onclose={() => { showTraitAdmin = false; }}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Trait admin panel (overlay) -->
|
||||
{#if showTraitAdmin && accessToken}
|
||||
<div class="workspace-admin-overlay">
|
||||
<TraitAdmin
|
||||
{accessToken}
|
||||
collectionId={collectionId}
|
||||
{traits}
|
||||
metadata={parsedMetadata}
|
||||
onclose={() => { showTraitAdmin = false; }}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !connected}
|
||||
<p class="text-sm text-gray-400">Venter på tilkobling…</p>
|
||||
{:else if !collectionNode}
|
||||
<div class="rounded-lg border border-yellow-200 bg-yellow-50 p-4 text-sm text-yellow-800">
|
||||
<p class="font-medium">Samling ikke funnet</p>
|
||||
<p class="mt-1">Samlingsnoden med ID {collectionId} finnes ikke eller er ikke tilgjengelig.</p>
|
||||
<a href="/" class="mt-2 inline-block text-blue-600 hover:underline">Tilbake til mottak</a>
|
||||
</div>
|
||||
{:else if traitNames.length === 0}
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-6 text-center">
|
||||
<p class="text-sm text-gray-500">Denne samlingen har ingen aktive traits.</p>
|
||||
<p class="mt-1 text-xs text-gray-400">Traits bestemmer hvilke verktøy og visninger som er tilgjengelige.</p>
|
||||
{#if accessToken && !showTraitAdmin}
|
||||
<button
|
||||
onclick={() => { showTraitAdmin = true; }}
|
||||
class="mt-3 rounded-lg bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-700"
|
||||
>
|
||||
Legg til traits
|
||||
</button>
|
||||
<!-- Main content area -->
|
||||
{#if !connected}
|
||||
<div class="workspace-message">
|
||||
<p>Venter på tilkobling…</p>
|
||||
</div>
|
||||
{:else if !collectionNode}
|
||||
<div class="workspace-message workspace-message-warn">
|
||||
<p class="workspace-message-title">Samling ikke funnet</p>
|
||||
<p>Samlingsnoden med ID {collectionId} finnes ikke eller er ikke tilgjengelig.</p>
|
||||
<a href="/" class="workspace-link">Tilbake til mottak</a>
|
||||
</div>
|
||||
{:else if traitNames.length === 0}
|
||||
<div class="workspace-message">
|
||||
<p>Denne samlingen har ingen aktive traits.</p>
|
||||
<p class="workspace-message-sub">Traits bestemmer hvilke verktøy og visninger som er tilgjengelige.</p>
|
||||
{#if accessToken && !showTraitAdmin}
|
||||
<button
|
||||
onclick={() => { showTraitAdmin = true; }}
|
||||
class="workspace-btn-primary"
|
||||
>
|
||||
Legg til traits
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if isMobile}
|
||||
<!-- ============================================================= -->
|
||||
<!-- MOBILE: Stacked with tab navigation -->
|
||||
<!-- ============================================================= -->
|
||||
<div class="mobile-tabs">
|
||||
{#each traitNames as trait, i (trait)}
|
||||
<button
|
||||
class="mobile-tab {activeTab === i ? 'mobile-tab-active' : ''}"
|
||||
onclick={() => { activeTab = i; }}
|
||||
>
|
||||
{getPanelInfo(trait).icon}
|
||||
<span class="mobile-tab-label">{getPanelInfo(trait).title}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="mobile-panel">
|
||||
{#each traitNames as trait, i (trait)}
|
||||
{#if activeTab === i}
|
||||
<div class="mobile-panel-content">
|
||||
{#if knownTraits.has(trait)}
|
||||
{#if trait === 'editor'}
|
||||
<EditorTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} collectionMetadata={parsedMetadata} />
|
||||
{:else if trait === 'chat'}
|
||||
<ChatTrait collection={collectionNode} config={traits[trait]} userId={nodeId} />
|
||||
{:else if trait === 'kanban'}
|
||||
<KanbanTrait collection={collectionNode} config={traits[trait]} userId={nodeId} />
|
||||
{:else if trait === 'podcast'}
|
||||
<PodcastTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} />
|
||||
{:else if trait === 'publishing'}
|
||||
<PublishingTrait collection={collectionNode} config={traits[trait]} />
|
||||
{:else if trait === 'rss'}
|
||||
<RssTrait collection={collectionNode} config={traits[trait]} />
|
||||
{:else if trait === 'calendar'}
|
||||
<CalendarTrait collection={collectionNode} config={traits[trait]} userId={nodeId} />
|
||||
{:else if trait === 'recording'}
|
||||
<RecordingTrait collection={collectionNode} config={traits[trait]} {accessToken} />
|
||||
{:else if trait === 'transcription'}
|
||||
<TranscriptionTrait collection={collectionNode} config={traits[trait]} />
|
||||
{:else if trait === 'studio'}
|
||||
<StudioTrait collection={collectionNode} config={traits[trait]} userId={nodeId} />
|
||||
{:else if trait === 'mixer'}
|
||||
<MixerTrait collection={collectionNode} config={traits[trait]} {accessToken} />
|
||||
{/if}
|
||||
{:else}
|
||||
<GenericTrait name={trait} config={traits[trait]} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Active traits as pills -->
|
||||
<div class="mb-6 flex flex-wrap gap-2">
|
||||
{#each traitNames as trait (trait)}
|
||||
<span class="rounded-full bg-indigo-50 px-3 py-1 text-xs font-medium text-indigo-700">
|
||||
{trait}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- ============================================================= -->
|
||||
<!-- DESKTOP: Spatial Canvas with BlockShell panels -->
|
||||
<!-- ============================================================= -->
|
||||
<div class="workspace-canvas">
|
||||
<Canvas
|
||||
objects={canvasObjects}
|
||||
onObjectMove={handleObjectMove}
|
||||
grid={{ enabled: false, size: 20 }}
|
||||
>
|
||||
{#snippet renderObject(obj)}
|
||||
{@const trait = obj.id}
|
||||
{@const info = getPanelInfo(trait)}
|
||||
{@const panel = layout.panels.find(p => p.trait === trait)}
|
||||
<BlockShell
|
||||
title={info.title}
|
||||
icon={info.icon}
|
||||
width={panel?.width ?? obj.width}
|
||||
height={panel?.height ?? obj.height}
|
||||
onResize={(w, h) => handlePanelResize(trait, w, h)}
|
||||
onClose={() => handlePanelClose(trait)}
|
||||
>
|
||||
{#if knownTraits.has(trait)}
|
||||
{#if trait === 'editor'}
|
||||
<EditorTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} collectionMetadata={parsedMetadata} />
|
||||
{:else if trait === 'chat'}
|
||||
<ChatTrait collection={collectionNode} config={traits[trait]} userId={nodeId} />
|
||||
{:else if trait === 'kanban'}
|
||||
<KanbanTrait collection={collectionNode} config={traits[trait]} userId={nodeId} />
|
||||
{:else if trait === 'podcast'}
|
||||
<PodcastTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} />
|
||||
{:else if trait === 'publishing'}
|
||||
<PublishingTrait collection={collectionNode} config={traits[trait]} />
|
||||
{:else if trait === 'rss'}
|
||||
<RssTrait collection={collectionNode} config={traits[trait]} />
|
||||
{:else if trait === 'calendar'}
|
||||
<CalendarTrait collection={collectionNode} config={traits[trait]} userId={nodeId} />
|
||||
{:else if trait === 'recording'}
|
||||
<RecordingTrait collection={collectionNode} config={traits[trait]} {accessToken} />
|
||||
{:else if trait === 'transcription'}
|
||||
<TranscriptionTrait collection={collectionNode} config={traits[trait]} />
|
||||
{:else if trait === 'studio'}
|
||||
<StudioTrait collection={collectionNode} config={traits[trait]} userId={nodeId} />
|
||||
{:else if trait === 'mixer'}
|
||||
<MixerTrait collection={collectionNode} config={traits[trait]} {accessToken} />
|
||||
{/if}
|
||||
{:else}
|
||||
<GenericTrait name={trait} config={traits[trait]} />
|
||||
{/if}
|
||||
</BlockShell>
|
||||
{/snippet}
|
||||
</Canvas>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Trait panels -->
|
||||
<div class="space-y-4">
|
||||
{#each renderedTraits as trait (trait)}
|
||||
{#if trait === 'editor'}
|
||||
<EditorTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} collectionMetadata={parsedMetadata} />
|
||||
{:else if trait === 'chat'}
|
||||
<ChatTrait collection={collectionNode} config={traits[trait]} userId={nodeId} />
|
||||
{:else if trait === 'kanban'}
|
||||
<KanbanTrait collection={collectionNode} config={traits[trait]} userId={nodeId} />
|
||||
{:else if trait === 'podcast'}
|
||||
<PodcastTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} />
|
||||
{:else if trait === 'publishing'}
|
||||
<PublishingTrait collection={collectionNode} config={traits[trait]} />
|
||||
{:else if trait === 'rss'}
|
||||
<RssTrait collection={collectionNode} config={traits[trait]} />
|
||||
{:else if trait === 'calendar'}
|
||||
<CalendarTrait collection={collectionNode} config={traits[trait]} userId={nodeId} />
|
||||
{:else if trait === 'recording'}
|
||||
<RecordingTrait collection={collectionNode} config={traits[trait]} {accessToken} />
|
||||
{:else if trait === 'transcription'}
|
||||
<TranscriptionTrait collection={collectionNode} config={traits[trait]} />
|
||||
{:else if trait === 'studio'}
|
||||
<StudioTrait collection={collectionNode} config={traits[trait]} userId={nodeId} />
|
||||
{:else if trait === 'mixer'}
|
||||
<MixerTrait collection={collectionNode} config={traits[trait]} {accessToken} />
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#each genericTraits as trait (trait)}
|
||||
<GenericTrait name={trait} config={traits[trait]} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- AI-verktøy panel -->
|
||||
{#if connected && accessToken}
|
||||
<div class="mt-6">
|
||||
<AiToolPanel {accessToken} userId={nodeId} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Ressursforbruk for denne noden (oppgave 15.9) -->
|
||||
{#if accessToken && collectionId}
|
||||
<div class="mt-6">
|
||||
<!-- AI-verktøy og ressursforbruk (vises under canvas/tabs) -->
|
||||
{#if connected && accessToken}
|
||||
<div class="workspace-footer-tools">
|
||||
<AiToolPanel {accessToken} userId={nodeId} />
|
||||
{#if collectionId}
|
||||
<NodeUsage nodeId={collectionId} {accessToken} />
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* ================================================================= */
|
||||
/* Page layout */
|
||||
/* ================================================================= */
|
||||
.workspace-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
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;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
background: #4f46e5;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.workspace-btn-primary:hover {
|
||||
background: #4338ca;
|
||||
}
|
||||
|
||||
/* ================================================================= */
|
||||
/* Trait admin overlay */
|
||||
/* ================================================================= */
|
||||
.workspace-admin-overlay {
|
||||
position: relative;
|
||||
z-index: 25;
|
||||
padding: 12px 16px;
|
||||
background: #fafbfc;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
/* ================================================================= */
|
||||
/* Messages (loading, not found, empty) */
|
||||
/* ================================================================= */
|
||||
.workspace-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.workspace-message-warn {
|
||||
color: #92400e;
|
||||
background: #fffbeb;
|
||||
}
|
||||
|
||||
.workspace-message-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.workspace-message-sub {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.workspace-link {
|
||||
margin-top: 8px;
|
||||
color: #2563eb;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.workspace-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ================================================================= */
|
||||
/* Desktop: Canvas fills remaining space */
|
||||
/* ================================================================= */
|
||||
.workspace-canvas {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ================================================================= */
|
||||
/* Mobile: Tab navigation */
|
||||
/* ================================================================= */
|
||||
.mobile-tabs {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
gap: 0;
|
||||
background: white;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
flex-shrink: 0;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.mobile-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 10px 14px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color 0.12s, border-color 0.12s;
|
||||
}
|
||||
|
||||
.mobile-tab:hover {
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.mobile-tab-active {
|
||||
color: #4f46e5;
|
||||
border-bottom-color: #4f46e5;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mobile-tab-label {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mobile-panel {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.mobile-panel-content {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
/* ================================================================= */
|
||||
/* Footer tools (AI, resource usage) */
|
||||
/* ================================================================= */
|
||||
.workspace-footer-tools {
|
||||
flex-shrink: 0;
|
||||
padding: 8px 16px;
|
||||
background: white;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* ================================================================= */
|
||||
/* 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;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
3
tasks.md
3
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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue