synops/frontend/src/routes/collection/[id]/+page.svelte
vegard 15dd23b873 Valider fase 19–20: arbeidsflaten + universell overføring bestått
Fikser funnet under validering:
- Gjør collection-prop valgfri i alle trait-komponenter slik at de
  fungerer i personlig arbeidsflate uten collection-kontekst
- Legger til null-guards for collection.id i alle derived-blokker
  og funksjoner som oppretter edges
- Fjerner microsSinceUnixEpoch-rester fra STDB-migrasjonen —
  createdAt er nå et tall (Unix µs), ikke et objekt
- Retter saveTimeout-lekkasje i collection-sida: timer ryddes nå
  ved navigasjon mellom samlinger
- Fikser TypeScript-feil i editorial (number vs string, uoppnåelig
  'scheduled'-sammenligning), studio (bigint vs number),
  RecordingTrait ($state-generics)
- Typefeil redusert fra 55 → 4 (gjenværende er pre-eksisterende
  i mixer.ts/livekit.ts, ikke fase 19-20)

Validert: Canvas pan/zoom, BlockShell, layout-persistering,
snarveier, transfer service, alle panelreworks. Frontend bygger OK.
2026-03-18 16:03:17 +00:00

594 lines
19 KiB
Svelte

<script lang="ts">
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,
generateDefaultLayout,
resolveLayout,
PANEL_HEADER_HEIGHT,
} 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';
import KanbanTrait from '$lib/components/traits/KanbanTrait.svelte';
import PodcastTrait from '$lib/components/traits/PodcastTrait.svelte';
import PublishingTrait from '$lib/components/traits/PublishingTrait.svelte';
import RssTrait from '$lib/components/traits/RssTrait.svelte';
import CalendarTrait from '$lib/components/traits/CalendarTrait.svelte';
import RecordingTrait from '$lib/components/traits/RecordingTrait.svelte';
import TranscriptionTrait from '$lib/components/traits/TranscriptionTrait.svelte';
import StudioTrait from '$lib/components/traits/StudioTrait.svelte';
import MixerTrait from '$lib/components/traits/MixerTrait.svelte';
import GenericTrait from '$lib/components/traits/GenericTrait.svelte';
import TraitAdmin from '$lib/components/traits/TraitAdmin.svelte';
import NodeUsage from '$lib/components/NodeUsage.svelte';
import AiToolPanel from '$lib/components/AiToolPanel.svelte';
const session = $derived($page.data.session as Record<string, unknown> | undefined);
const nodeId = $derived(session?.nodeId as string | undefined);
const accessToken = $derived(session?.accessToken as string | undefined);
let showTraitAdmin = $state(false);
const connected = $derived(connectionState.current === 'connected');
const collectionId = $derived($page.params.id ?? '');
const collectionNode = $derived(connected ? nodeStore.get(collectionId) : undefined);
/** Parse full metadata */
const parsedMetadata = $derived.by((): Record<string, unknown> => {
if (!collectionNode) return {};
try {
return JSON.parse(collectionNode.metadata ?? '{}') as Record<string, unknown>;
} catch { return {}; }
});
/** Parse traits from collection metadata */
const traits = $derived.by((): Record<string, Record<string, unknown>> => {
const meta = parsedMetadata;
if (meta.traits && typeof meta.traits === 'object' && !Array.isArray(meta.traits)) {
return meta.traits as Record<string, Record<string, unknown>>;
}
return {};
});
const traitNames = $derived(Object.keys(traits));
/** Traits with dedicated components */
const knownTraits = new Set([
'editor', 'chat', 'kanban', 'podcast', 'publishing',
'rss', 'calendar', 'recording', 'transcription', 'studio', 'mixer'
]);
/** Count of child nodes */
const childCount = $derived.by(() => {
if (!connected || !collectionId) return 0;
let count = 0;
for (const edge of edgeStore.byTarget(collectionId)) {
if (edge.edgeType === 'belongs_to') count++;
}
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;
}
});
let saveTimeout: ReturnType<typeof setTimeout> | undefined;
// Reset when collection changes
$effect(() => {
const _id = collectionId;
layoutInitialized = false;
clearTimeout(saveTimeout);
});
/** 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.minimized ? PANEL_HEADER_HEIGHT : p.height,
}))
);
// =========================================================================
// Layout persistence
// =========================================================================
/** 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();
}
/** Handle panel minimize/restore toggle */
function handlePanelMinimize(trait: string, isMinimized: boolean) {
const idx = layout.panels.findIndex(p => p.trait === trait);
if (idx >= 0) {
layout.panels[idx] = { ...layout.panels[idx], minimized: isMinimized };
layout = { ...layout };
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
// =========================================================================
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="workspace-page">
<!-- Context Header: context selector + tool menu -->
<ContextHeader
{collectionId}
{collectionNode}
userId={nodeId}
{connected}
{traitNames}
onToggleTraitAdmin={() => { showTraitAdmin = !showTraitAdmin; }}
{showTraitAdmin}
onAddPanel={handleAddPanel}
activeTraits={activeLayoutTraits}
/>
<!-- Trait admin panel (overlay) -->
{#if showTraitAdmin && accessToken}
<div class="workspace-admin-overlay">
<TraitAdmin
{accessToken}
collectionId={collectionId}
{traits}
metadata={parsedMetadata}
onclose={() => { showTraitAdmin = false; }}
/>
</div>
{/if}
<!-- 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} {accessToken} />
{:else if trait === 'kanban'}
<KanbanTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} />
{: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} {accessToken} />
{: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} {accessToken} />
{:else if trait === 'mixer'}
<MixerTrait collection={collectionNode} config={traits[trait]} {accessToken} />
{/if}
{:else}
<GenericTrait name={trait} config={traits[trait]} />
{/if}
</div>
{/if}
{/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}
minimized={panel?.minimized ?? false}
onResize={(w, h) => handlePanelResize(trait, w, h)}
onClose={() => handlePanelClose(trait)}
onMinimizeChange={(m) => handlePanelMinimize(trait, m)}
>
{#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} {accessToken} />
{:else if trait === 'kanban'}
<KanbanTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} />
{: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} {accessToken} />
{: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} {accessToken} />
{: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}
<!-- 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} />
{/if}
</div>
{/if}
</div>
<style>
/* ================================================================= */
/* Page layout */
/* ================================================================= */
.workspace-page {
display: flex;
flex-direction: column;
height: 100vh;
background: #f0f2f5;
}
.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-footer-tools {
flex-direction: column;
gap: 8px;
padding: 8px 12px;
}
}
</style>