diff --git a/frontend/src/lib/components/traits/CalendarTrait.svelte b/frontend/src/lib/components/traits/CalendarTrait.svelte index 04d6053..82842fd 100644 --- a/frontend/src/lib/components/traits/CalendarTrait.svelte +++ b/frontend/src/lib/components/traits/CalendarTrait.svelte @@ -6,7 +6,7 @@ import type { BlockReceiver, PlacementIntent } from '$lib/components/blockshell/types'; interface Props { - collection: Node; + collection?: Node; config: Record; userId?: string; accessToken?: string; diff --git a/frontend/src/lib/components/traits/ChatTrait.svelte b/frontend/src/lib/components/traits/ChatTrait.svelte index 931ef6a..4b53a29 100644 --- a/frontend/src/lib/components/traits/ChatTrait.svelte +++ b/frontend/src/lib/components/traits/ChatTrait.svelte @@ -9,7 +9,7 @@ import { tick } from 'svelte'; interface Props { - collection: Node; + collection?: Node; config: Record; userId?: string; accessToken?: string; @@ -45,6 +45,7 @@ const chatNodes = $derived.by(() => { const nodes: Node[] = []; + if (!collection) return nodes; const seen = new Set(); for (const edge of edgeStore.byTarget(collection.id)) { if (edge.edgeType !== 'belongs_to') continue; @@ -121,8 +122,8 @@ } nodes.sort((a, b) => { - const ta = a.createdAt?.microsSinceUnixEpoch ?? 0n; - const tb = b.createdAt?.microsSinceUnixEpoch ?? 0n; + const ta = a.createdAt ?? 0; + const tb = b.createdAt ?? 0; return ta > tb ? 1 : ta < tb ? -1 : 0; }); @@ -189,8 +190,8 @@ // ========================================================================= function formatTime(node: Node): string { - if (!node.createdAt?.microsSinceUnixEpoch) return ''; - const ms = Number(node.createdAt.microsSinceUnixEpoch / 1000n); + if (!node.createdAt) return ''; + const ms = Math.floor(node.createdAt / 1000); const date = new Date(ms); const now = new Date(); const isToday = date.toDateString() === now.toDateString(); diff --git a/frontend/src/lib/components/traits/EditorTrait.svelte b/frontend/src/lib/components/traits/EditorTrait.svelte index b07e6f8..0a2b60c 100644 --- a/frontend/src/lib/components/traits/EditorTrait.svelte +++ b/frontend/src/lib/components/traits/EditorTrait.svelte @@ -7,7 +7,7 @@ import type { BlockReceiver, PlacementIntent } from '$lib/components/blockshell/types'; interface Props { - collection: Node; + collection?: Node; config: Record; userId?: string; accessToken?: string; @@ -69,6 +69,7 @@ } const contentItems = $derived.by((): ContentItem[] => { + if (!collection) return []; const items: ContentItem[] = []; for (const edge of edgeStore.byTarget(collection.id)) { if (edge.edgeType !== 'belongs_to') continue; @@ -78,8 +79,8 @@ } } items.sort((a, b) => { - const ta = a.node.createdAt?.microsSinceUnixEpoch ?? 0n; - const tb = b.node.createdAt?.microsSinceUnixEpoch ?? 0n; + const ta = a.node.createdAt ?? 0; + const tb = b.node.createdAt ?? 0; return tb > ta ? 1 : tb < ta ? -1 : 0; }); return items; @@ -131,8 +132,8 @@ } } nodes.sort((a, b) => { - const ta = a.createdAt?.microsSinceUnixEpoch ?? 0n; - const tb = b.createdAt?.microsSinceUnixEpoch ?? 0n; + const ta = a.createdAt ?? 0; + const tb = b.createdAt ?? 0; return tb > ta ? 1 : tb < ta ? -1 : 0; }); return nodes; @@ -217,7 +218,7 @@ let showCreateForm = $state(false); async function handleCreateArticle() { - if (!accessToken || !newTitle.trim() || isCreating) return; + if (!accessToken || !collection || !newTitle.trim() || isCreating) return; isCreating = true; try { const { node_id } = await createNode(accessToken, { @@ -360,8 +361,8 @@ // ========================================================================= function formatTime(node: Node): string { - if (!node.createdAt?.microsSinceUnixEpoch) return ''; - const ms = Number(node.createdAt.microsSinceUnixEpoch / 1000n); + if (!node.createdAt) return ''; + const ms = Math.floor(node.createdAt / 1000); const date = new Date(ms); return date.toLocaleDateString('nb-NO', { day: 'numeric', month: 'short' }); } @@ -664,7 +665,7 @@ {#if publishTarget && accessToken && pubConfig} { publishTarget = null; }} diff --git a/frontend/src/lib/components/traits/KanbanTrait.svelte b/frontend/src/lib/components/traits/KanbanTrait.svelte index 8b03fa8..0393461 100644 --- a/frontend/src/lib/components/traits/KanbanTrait.svelte +++ b/frontend/src/lib/components/traits/KanbanTrait.svelte @@ -6,7 +6,7 @@ import type { BlockReceiver, PlacementIntent } from '$lib/components/blockshell/types'; interface Props { - collection: Node; + collection?: Node; config: Record; userId?: string; accessToken?: string; @@ -43,6 +43,7 @@ // ========================================================================= const columns = $derived.by(() => { + if (!collection) return ['todo', 'in_progress', 'done']; try { const meta = JSON.parse(collection.metadata ?? '{}'); const traitConf = meta.traits?.kanban; @@ -163,7 +164,7 @@ e.preventDefault(); dragOverColumn = null; - if (!draggedCard || !accessToken) return; + if (!draggedCard || !accessToken || !collection) return; if (draggedCard.status === targetColumn) { draggedCard = null; return; @@ -205,7 +206,7 @@ let isCreating = $state(false); async function handleCreateCard(column: string) { - if (!accessToken || !newCardTitle.trim() || isCreating) return; + if (!accessToken || !collection || !newCardTitle.trim() || isCreating) return; isCreating = true; try { @@ -259,8 +260,8 @@ // ========================================================================= function formatTime(node: Node): string { - if (!node.createdAt?.microsSinceUnixEpoch) return ''; - const ms = Number(node.createdAt.microsSinceUnixEpoch / 1000n); + if (!node.createdAt) return ''; + const ms = Math.floor(node.createdAt / 1000); const date = new Date(ms); return date.toLocaleDateString('nb-NO', { day: 'numeric', month: 'short' }); } diff --git a/frontend/src/lib/components/traits/MixerTrait.svelte b/frontend/src/lib/components/traits/MixerTrait.svelte index c1fdf1c..6996469 100644 --- a/frontend/src/lib/components/traits/MixerTrait.svelte +++ b/frontend/src/lib/components/traits/MixerTrait.svelte @@ -45,7 +45,7 @@ } from '$lib/mixer'; interface Props { - collection: Node; + collection?: Node; config: Record; accessToken?: string; } @@ -90,6 +90,7 @@ // Derive room_id from the collection's communication node // Pattern: "communication_{communication_node_id}" const roomId = $derived.by(() => { + if (!collection) return null; for (const edge of edgeStore.byTarget(collection.id)) { if (edge.edgeType !== 'belongs_to') continue; const node = nodeStore.get(edge.sourceId); diff --git a/frontend/src/lib/components/traits/PodcastTrait.svelte b/frontend/src/lib/components/traits/PodcastTrait.svelte index 35d58eb..363c93b 100644 --- a/frontend/src/lib/components/traits/PodcastTrait.svelte +++ b/frontend/src/lib/components/traits/PodcastTrait.svelte @@ -6,7 +6,7 @@ import TraitPanel from './TraitPanel.svelte'; interface Props { - collection: Node; + collection?: Node; config: Record; userId?: string; accessToken?: string; @@ -17,6 +17,7 @@ /** Media nodes (episodes) belonging to this collection */ const episodes = $derived.by(() => { const nodes: Node[] = []; + if (!collection) return nodes; for (const edge of edgeStore.byTarget(collection.id)) { if (edge.edgeType !== 'belongs_to') continue; const node = nodeStore.get(edge.sourceId); @@ -31,8 +32,8 @@ } } nodes.sort((a, b) => { - const ta = a.createdAt?.microsSinceUnixEpoch ?? 0n; - const tb = b.createdAt?.microsSinceUnixEpoch ?? 0n; + const ta = a.createdAt ?? 0; + const tb = b.createdAt ?? 0; return tb > ta ? 1 : tb < ta ? -1 : 0; }); return nodes; diff --git a/frontend/src/lib/components/traits/PublishingTrait.svelte b/frontend/src/lib/components/traits/PublishingTrait.svelte index 545a441..b853d20 100644 --- a/frontend/src/lib/components/traits/PublishingTrait.svelte +++ b/frontend/src/lib/components/traits/PublishingTrait.svelte @@ -3,7 +3,7 @@ import TraitPanel from './TraitPanel.svelte'; interface Props { - collection: Node; + collection?: Node; config: Record; } @@ -45,14 +45,14 @@
{#if requireApproval} Redaksjonell arbeidsflate {/if} Rediger forside diff --git a/frontend/src/lib/components/traits/RecordingTrait.svelte b/frontend/src/lib/components/traits/RecordingTrait.svelte index aedc163..77eebea 100644 --- a/frontend/src/lib/components/traits/RecordingTrait.svelte +++ b/frontend/src/lib/components/traits/RecordingTrait.svelte @@ -20,14 +20,14 @@ } from '$lib/livekit'; interface Props { - collection: Node; + collection?: Node; config: Record; accessToken?: string; } let { collection, config, accessToken }: Props = $props(); - let status: RoomStatus = $state('disconnected'); + let status = $state('disconnected'); let participants: LiveKitParticipant[] = $state([]); let localIdentity: string = $state(''); let error: string | null = $state(null); @@ -55,6 +55,7 @@ /** Find communication nodes linked to this collection */ const communicationNodes = $derived.by(() => { const nodes: Node[] = []; + if (!collection) return nodes; for (const edge of edgeStore.byTarget(collection.id)) { if (edge.edgeType !== 'belongs_to') continue; const node = nodeStore.get(edge.sourceId); diff --git a/frontend/src/lib/components/traits/RssTrait.svelte b/frontend/src/lib/components/traits/RssTrait.svelte index 6db46c3..b5d2563 100644 --- a/frontend/src/lib/components/traits/RssTrait.svelte +++ b/frontend/src/lib/components/traits/RssTrait.svelte @@ -3,7 +3,7 @@ import TraitPanel from './TraitPanel.svelte'; interface Props { - collection: Node; + collection?: Node; config: Record; } @@ -14,6 +14,7 @@ /** Build the feed URL from publishing slug if available */ const feedUrl = $derived.by(() => { + if (!collection) return ''; try { const meta = JSON.parse(collection.metadata ?? '{}'); const slug = meta.traits?.publishing?.slug; diff --git a/frontend/src/lib/components/traits/SoundPadGrid.svelte b/frontend/src/lib/components/traits/SoundPadGrid.svelte index 1d56c7b..e0f0757 100644 --- a/frontend/src/lib/components/traits/SoundPadGrid.svelte +++ b/frontend/src/lib/components/traits/SoundPadGrid.svelte @@ -28,7 +28,7 @@ } interface Props { - collection: Node; + collection?: Node; accessToken?: string; isViewer?: boolean; } @@ -56,7 +56,7 @@ // Parse pad config from collection metadata $effect(() => { - const meta = collection.metadata ? JSON.parse(collection.metadata) : {}; + const meta = collection?.metadata ? JSON.parse(collection.metadata) : {}; const mixerMeta = meta?.mixer ?? {}; const rawPads: PadConfig[] = mixerMeta?.pads ?? []; @@ -212,10 +212,10 @@ } async function savePadConfigs(configs: PadConfig[]) { - if (!accessToken) return; + if (!accessToken || !collection) return; // Filter out empty pads for clean storage const padsToSave = configs.filter(p => p.cas_hash); - const meta = collection.metadata ? JSON.parse(collection.metadata) : {}; + const meta = collection?.metadata ? JSON.parse(collection.metadata) : {}; const mixer = meta.mixer ?? {}; mixer.pads = padsToSave.length > 0 ? configs : undefined; meta.mixer = mixer; diff --git a/frontend/src/lib/components/traits/StudioTrait.svelte b/frontend/src/lib/components/traits/StudioTrait.svelte index 5eebf73..c38feb5 100644 --- a/frontend/src/lib/components/traits/StudioTrait.svelte +++ b/frontend/src/lib/components/traits/StudioTrait.svelte @@ -5,7 +5,7 @@ import type { BlockReceiver, PlacementIntent } from '$lib/components/blockshell/types'; interface Props { - collection: Node; + collection?: Node; config: Record; userId?: string; accessToken?: string; @@ -41,6 +41,7 @@ const audioNodes = $derived.by(() => { const nodes: Node[] = []; + if (!collection) return nodes; for (const edge of edgeStore.byTarget(collection.id)) { if (edge.edgeType !== 'belongs_to') continue; const node = nodeStore.get(edge.sourceId); @@ -55,8 +56,8 @@ } } nodes.sort((a, b) => { - const ta = a.createdAt?.microsSinceUnixEpoch ?? 0n; - const tb = b.createdAt?.microsSinceUnixEpoch ?? 0n; + const ta = a.createdAt ?? 0; + const tb = b.createdAt ?? 0; return tb > ta ? 1 : tb < ta ? -1 : 0; }); return nodes; @@ -121,8 +122,8 @@ } function formatTime(node: Node): string { - if (!node.createdAt?.microsSinceUnixEpoch) return ''; - const ms = Number(node.createdAt.microsSinceUnixEpoch / 1000n); + if (!node.createdAt) return ''; + const ms = Math.floor(node.createdAt / 1000); const date = new Date(ms); return date.toLocaleDateString('nb-NO', { day: 'numeric', month: 'short' }); } diff --git a/frontend/src/lib/components/traits/TranscriptionTrait.svelte b/frontend/src/lib/components/traits/TranscriptionTrait.svelte index a67540f..d9fcd1c 100644 --- a/frontend/src/lib/components/traits/TranscriptionTrait.svelte +++ b/frontend/src/lib/components/traits/TranscriptionTrait.svelte @@ -3,7 +3,7 @@ import TraitPanel from './TraitPanel.svelte'; interface Props { - collection: Node; + collection?: Node; config: Record; } diff --git a/frontend/src/routes/chat/[id]/+page.svelte b/frontend/src/routes/chat/[id]/+page.svelte index e4deb27..16c2052 100644 --- a/frontend/src/routes/chat/[id]/+page.svelte +++ b/frontend/src/routes/chat/[id]/+page.svelte @@ -53,8 +53,8 @@ // Sort by created_at ascending (oldest first, like a chat) nodes.sort((a, b) => { - const ta = a.createdAt?.microsSinceUnixEpoch ?? 0n; - const tb = b.createdAt?.microsSinceUnixEpoch ?? 0n; + const ta = a.createdAt ?? 0; + const tb = b.createdAt ?? 0; return ta > tb ? 1 : ta < tb ? -1 : 0; }); @@ -113,8 +113,8 @@ /** Format timestamp for display */ function formatTime(node: Node): string { - if (!node.createdAt?.microsSinceUnixEpoch) return ''; - const ms = Number(node.createdAt.microsSinceUnixEpoch / 1000n); + if (!node.createdAt) return ''; + const ms = Math.floor(node.createdAt / 1000); const date = new Date(ms); const now = new Date(); const isToday = date.toDateString() === now.toDateString(); diff --git a/frontend/src/routes/collection/[id]/+page.svelte b/frontend/src/routes/collection/[id]/+page.svelte index 587b9e3..a70375a 100644 --- a/frontend/src/routes/collection/[id]/+page.svelte +++ b/frontend/src/routes/collection/[id]/+page.svelte @@ -127,10 +127,13 @@ } }); + let saveTimeout: ReturnType | undefined; + // Reset when collection changes $effect(() => { const _id = collectionId; layoutInitialized = false; + clearTimeout(saveTimeout); }); /** Convert layout panels to CanvasObjects for the Canvas component */ @@ -148,8 +151,6 @@ // Layout persistence // ========================================================================= - let saveTimeout: ReturnType | undefined; - /** Persist layout to user's edge metadata (debounced) */ function persistLayout() { if (!accessToken || !userEdge) return; diff --git a/frontend/src/routes/editorial/[id]/+page.svelte b/frontend/src/routes/editorial/[id]/+page.svelte index 9b053e1..4c8ff7d 100644 --- a/frontend/src/routes/editorial/[id]/+page.svelte +++ b/frontend/src/routes/editorial/[id]/+page.svelte @@ -56,7 +56,7 @@ feedback: string | null; edgeId: string; edgeMeta: Record; - createdAt: string; + createdAt: number; discussionIds: string[]; } @@ -122,7 +122,7 @@ feedback: (meta.feedback as string) ?? null, edgeId: edge.id, edgeMeta: meta, - createdAt: node.createdAt ?? '', + createdAt: node.createdAt ?? 0, discussionIds }); } @@ -143,9 +143,9 @@ } // Sort each column: newest first for pending, oldest first for others - grouped.pending.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + grouped.pending.sort((a, b) => b.createdAt - a.createdAt); for (const col of ['in_review', 'approved', 'scheduled'] as Column[]) { - grouped[col].sort((a, b) => a.createdAt.localeCompare(b.createdAt)); + grouped[col].sort((a, b) => a.createdAt - b.createdAt); } return grouped; @@ -202,12 +202,13 @@ try { // Map column back to actual status for the edge - const newStatus = targetColumn === 'scheduled' ? 'approved' : targetColumn; + // Note: 'scheduled' case handled above with early return + dialog + const newStatus = targetColumn; const newMeta: Record = { ...card.edgeMeta, status: newStatus }; // If moving away from scheduled, remove publish_at - if (card.status === 'scheduled' && targetColumn !== 'scheduled') { + if (card.status === 'scheduled') { delete newMeta.publish_at; } diff --git a/frontend/src/routes/studio/[id]/+page.svelte b/frontend/src/routes/studio/[id]/+page.svelte index d0d8485..098c58c 100644 --- a/frontend/src/routes/studio/[id]/+page.svelte +++ b/frontend/src/routes/studio/[id]/+page.svelte @@ -74,7 +74,7 @@ // Version history: processed nodes derived from this media node const versions = $derived.by(() => { if (!connected || !mediaNodeId) return []; - const nodes: { id: string; title: string; createdAt: bigint }[] = []; + const nodes: { id: string; title: string; createdAt: number }[] = []; for (const edge of edgeStore.byTarget(mediaNodeId)) { if (edge.edgeType !== 'derived_from') continue; const node = nodeStore.get(edge.sourceId); @@ -82,7 +82,7 @@ nodes.push({ id: node.id, title: node.title ?? 'Prosessert', - createdAt: node.createdAt?.microsSinceUnixEpoch ?? 0n, + createdAt: node.createdAt ?? 0, }); } } diff --git a/tasks.md b/tasks.md index bff2c56..f0c3d8f 100644 --- a/tasks.md +++ b/tasks.md @@ -303,8 +303,7 @@ med spesifikasjon for det som trenger en dedikert sesjon. - [x] 23.6 Valider fase 13–14 (traits + publisering): trait-validering, pakkevelger, Tera-templates, HTML-rendering, forside, slot-håndtering, redaksjonell flyt, planlagt publisering, A/B-testing. - [x] 23.7 Valider fase 15–16 (admin + lydmixer): systemvarsler, graceful shutdown, jobbkø-oversikt, ressursstyring, serverhelse, Web Audio mixer, delt kontroll, sound pads, EQ, stemmeeffekter. - [x] 23.8 Valider fase 17–18 (lydstudio-utbedring + AI-verktøy): responsivt layout, FFmpeg-validering, fade/silence, AI-presets, direction-logikk, drag-and-drop integrasjon. -- [~] 23.9 Valider fase 19–20 (arbeidsflaten + universell overføring): canvas pan/zoom, BlockShell, layout-persistering, snarveier, transfer service, alle panelreworks (chat, kanban, kalender, editor, studio). - > Påbegynt: 2026-03-18T15:50 +- [x] 23.9 Valider fase 19–20 (arbeidsflaten + universell overføring): canvas pan/zoom, BlockShell, layout-persistering, snarveier, transfer service, alle panelreworks (chat, kanban, kalender, editor, studio). - [ ] 23.10 Valider fase 21 (CLI-verktøy): kjør hvert synops-*-verktøy, verifiser --help, --payload-json, output-format, feilhåndtering, synops-common integrasjon. - [ ] 23.11 Valider fase 22 (STDB-migrering): WebSocket-sanntid fungerer, PG LISTEN/NOTIFY-triggere, ingen STDB-rester i aktiv kode/konfig.