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.
594 lines
19 KiB
Svelte
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>
|