synops/frontend/src/lib/components/ContextHeader.svelte
vegard e8a1a80652 Valider fase 22: STDB-migrering fullført, ingen rester i aktiv kode
Validering av fase 22 (SpacetimeDB-migrering) bekrefter:

1. WebSocket-sanntid fungerer:
   - maskinrommet lytter på PG NOTIFY-kanaler (node_changed, edge_changed,
     access_changed, mixer_channel_changed)
   - Enrichment av events med fulle rader fra PG
   - Broadcast via tokio::broadcast til WebSocket-klienter
   - Tilgangskontroll filtrerer events per bruker
   - Frontend kobler til /ws med JWT, mottar initial_sync + inkrementelle events

2. PG LISTEN/NOTIFY-triggere verifisert i database:
   - 4 notify-funksjoner: notify_node_change, notify_edge_change,
     notify_access_change, notify_mixer_channel_change
   - 4 triggere: nodes_notify, edges_notify, node_access_notify,
     mixer_channels_notify

3. Ingen STDB-rester i aktiv kode/konfig:
   - maskinrommet/src/: rent
   - Cargo.toml: ingen spacetimedb-avhengigheter
   - docker-compose.yml: ingen spacetimedb-tjeneste
   - Caddyfile: ingen spacetimedb-proxy
   - Eneste funn: frontend/src/lib/spacetime/ katalognavn —
     omdøpt til frontend/src/lib/realtime/ (32 filer oppdatert)
   - Historiske referanser i docs/arkiv og scripts/synops.md er OK
2026-03-18 16:31:16 +00:00

609 lines
14 KiB
Svelte

<script lang="ts">
import { goto } from '$app/navigation';
import { connectionState, nodeStore, edgeStore, nodeAccessStore, nodeVisibility } from '$lib/realtime';
import type { Node } from '$lib/realtime';
import { getRankedNodeIds, recordVisit } from '$lib/workspace/recency.js';
import { TRAIT_PANEL_INFO, getPanelInfo } from '$lib/workspace/types.js';
interface Props {
/** Current collection node ID */
collectionId: string;
/** Current collection node (if loaded) */
collectionNode: Node | undefined;
/** Current user's node ID */
userId: string | undefined;
/** Whether WebSocket is connected */
connected: boolean;
/** Active trait names on this collection */
traitNames: string[];
/** Callback to toggle trait admin panel */
onToggleTraitAdmin: () => void;
/** Whether trait admin is currently shown */
showTraitAdmin: boolean;
/** Callback to add a panel/trait to the workspace */
onAddPanel: (trait: string) => void;
/** Traits already visible on the workspace (to grey out in tool menu) */
activeTraits: string[];
}
let {
collectionId,
collectionNode,
userId,
connected,
traitNames,
onToggleTraitAdmin,
showTraitAdmin,
onAddPanel,
activeTraits,
}: Props = $props();
// =========================================================================
// Context selector (dropdown)
// =========================================================================
let selectorOpen = $state(false);
let searchQuery = $state('');
let searchInput = $state<HTMLInputElement | undefined>(undefined);
// Record visit when collection changes
$effect(() => {
if (collectionId) {
recordVisit(collectionId);
}
});
/** All collection nodes the user can access, ranked by recency/frequency */
const collectionNodes = $derived.by((): Node[] => {
if (!connected || !userId) return [];
// Gather all accessible node IDs
const accessibleIds = new Set<string>();
for (const edge of edgeStore.bySource(userId)) {
if (!edge.system) accessibleIds.add(edge.targetId);
}
for (const id of nodeAccessStore.objectsForSubject(userId)) {
accessibleIds.add(id);
}
// Filter to collection nodes
const collections: Node[] = [];
for (const id of accessibleIds) {
const node = nodeStore.get(id);
if (node && node.nodeKind === 'collection' && nodeVisibility(node, userId) !== 'hidden') {
collections.push(node);
}
}
// Sort by recency ranking
const ranked = getRankedNodeIds();
const rankMap = new Map(ranked.map((id, i) => [id, i]));
collections.sort((a, b) => {
const ra = rankMap.get(a.id) ?? 999;
const rb = rankMap.get(b.id) ?? 999;
if (ra !== rb) return ra - rb;
// Fallback: alphabetical
return (a.title ?? '').localeCompare(b.title ?? '');
});
return collections;
});
/** Filtered by search query */
const filteredCollections = $derived.by(() => {
if (!searchQuery.trim()) return collectionNodes;
const q = searchQuery.toLowerCase().trim();
return collectionNodes.filter(n =>
(n.title ?? '').toLowerCase().includes(q)
);
});
function toggleSelector() {
selectorOpen = !selectorOpen;
toolMenuOpen = false;
if (selectorOpen) {
searchQuery = '';
// Focus search input after DOM update
requestAnimationFrame(() => searchInput?.focus());
}
}
function selectCollection(id: string) {
selectorOpen = false;
if (id !== collectionId) {
goto(`/collection/${id}`);
}
}
function handleSelectorKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
selectorOpen = false;
}
}
// =========================================================================
// Tool menu (add panels)
// =========================================================================
let toolMenuOpen = $state(false);
/** Available traits that can be added as panels */
const availableTools = $derived.by(() => {
return Object.entries(TRAIT_PANEL_INFO).map(([key, info]) => ({
key,
...info,
active: activeTraits.includes(key),
}));
});
function toggleToolMenu() {
toolMenuOpen = !toolMenuOpen;
selectorOpen = false;
}
function addTool(trait: string) {
onAddPanel(trait);
toolMenuOpen = false;
}
// =========================================================================
// Click outside to close
// =========================================================================
function handleClickOutside(e: MouseEvent) {
const target = e.target as HTMLElement;
if (selectorOpen && !target.closest('.context-selector')) {
selectorOpen = false;
}
if (toolMenuOpen && !target.closest('.tool-menu')) {
toolMenuOpen = false;
}
}
</script>
<svelte:window onclick={handleClickOutside} onkeydown={handleSelectorKeydown} />
<header class="context-header">
<div class="context-header-inner">
<!-- Left: Back + Context selector -->
<div class="context-header-left">
<a href="/" class="context-back" title="Tilbake til mottak">&larr;</a>
<div class="context-selector">
<button
class="context-selector-trigger"
onclick={toggleSelector}
title="Bytt kontekst"
>
<span class="context-selector-title">
{collectionNode?.title || 'Samling'}
</span>
<span class="context-selector-chevron" class:open={selectorOpen}>&#9662;</span>
</button>
{#if selectorOpen}
<div class="context-selector-dropdown">
<div class="context-selector-search">
<input
bind:this={searchInput}
bind:value={searchQuery}
type="text"
placeholder="Søk samlinger..."
class="context-selector-search-input"
onclick={(e) => e.stopPropagation()}
/>
</div>
<div class="context-selector-list">
{#if !searchQuery.trim()}
<button
class="context-selector-item"
onclick={() => { selectorOpen = false; goto('/workspace'); }}
>
<span class="context-selector-item-title">Min arbeidsflate</span>
</button>
{/if}
{#each filteredCollections as node (node.id)}
<button
class="context-selector-item"
class:current={node.id === collectionId}
onclick={() => selectCollection(node.id)}
>
<span class="context-selector-item-title">{node.title || 'Uten tittel'}</span>
{#if node.id === collectionId}
<span class="context-selector-item-check">&#10003;</span>
{/if}
</button>
{:else}
<div class="context-selector-empty">
{searchQuery ? 'Ingen treff' : 'Ingen samlinger'}
</div>
{/each}
</div>
</div>
{/if}
</div>
</div>
<!-- Right: Tool menu + status + traits -->
<div class="context-header-right">
<div class="tool-menu">
<button
class="context-btn tool-menu-trigger"
onclick={toggleToolMenu}
title="Legg til verktøy-panel"
>
+ Verktøy
</button>
{#if toolMenuOpen}
<div class="tool-menu-dropdown">
<div class="tool-menu-title">Legg til panel</div>
{#each availableTools as tool (tool.key)}
<button
class="tool-menu-item"
class:tool-menu-item-active={tool.active}
onclick={() => addTool(tool.key)}
disabled={tool.active}
title={tool.active ? 'Allerede på flaten' : `Legg til ${tool.title}`}
>
<span class="tool-menu-item-icon">{tool.icon}</span>
<span class="tool-menu-item-label">{tool.title}</span>
{#if tool.active}
<span class="tool-menu-item-badge">aktiv</span>
{/if}
</button>
{/each}
</div>
{/if}
</div>
{#if connected}
<span class="context-status context-status-ok" title="Tilkoblet sanntid">&#9679;</span>
{:else}
<span class="context-status" title="{connectionState.current}">&#9679;</span>
{/if}
{#if connected && collectionNode}
<button
onclick={onToggleTraitAdmin}
class="context-btn {showTraitAdmin ? 'context-btn-active' : ''}"
title="Administrer traits"
>
Traits
</button>
{/if}
</div>
</div>
</header>
<style>
/* ================================================================= */
/* Context Header */
/* ================================================================= */
.context-header {
border-bottom: 1px solid #e5e7eb;
background: white;
flex-shrink: 0;
z-index: 30;
position: relative;
}
.context-header-inner {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 16px;
max-width: 100%;
min-height: 44px;
}
.context-header-left {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1;
}
.context-header-right {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
/* ================================================================= */
/* Back button */
/* ================================================================= */
.context-back {
font-size: 16px;
color: #9ca3af;
text-decoration: none;
flex-shrink: 0;
padding: 4px;
line-height: 1;
}
.context-back:hover {
color: #4b5563;
}
/* ================================================================= */
/* Context Selector (dropdown) */
/* ================================================================= */
.context-selector {
position: relative;
min-width: 0;
}
.context-selector-trigger {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
cursor: pointer;
font-size: 15px;
font-weight: 600;
color: #111827;
max-width: 300px;
transition: background 0.12s, border-color 0.12s;
}
.context-selector-trigger:hover {
background: #f3f4f6;
border-color: #e5e7eb;
}
.context-selector-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.context-selector-chevron {
font-size: 10px;
color: #9ca3af;
transition: transform 0.15s;
flex-shrink: 0;
}
.context-selector-chevron.open {
transform: rotate(180deg);
}
/* ================================================================= */
/* Dropdown */
/* ================================================================= */
.context-selector-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
min-width: 260px;
max-width: 360px;
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
z-index: 50;
overflow: hidden;
}
.context-selector-search {
padding: 8px;
border-bottom: 1px solid #f3f4f6;
}
.context-selector-search-input {
width: 100%;
padding: 6px 10px;
border: 1px solid #e5e7eb;
border-radius: 6px;
font-size: 13px;
outline: none;
background: #fafbfc;
}
.context-selector-search-input:focus {
border-color: #4f46e5;
background: white;
}
.context-selector-list {
max-height: 280px;
overflow-y: auto;
padding: 4px;
}
.context-selector-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 8px 10px;
border: none;
border-radius: 6px;
background: transparent;
cursor: pointer;
font-size: 13px;
color: #374151;
text-align: left;
transition: background 0.1s;
}
.context-selector-item:hover {
background: #f3f4f6;
}
.context-selector-item.current {
background: #eef2ff;
color: #4f46e5;
font-weight: 500;
}
.context-selector-item-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.context-selector-item-check {
color: #4f46e5;
font-size: 12px;
flex-shrink: 0;
margin-left: 8px;
}
.context-selector-empty {
padding: 16px;
text-align: center;
font-size: 12px;
color: #9ca3af;
}
/* ================================================================= */
/* Tool Menu */
/* ================================================================= */
.tool-menu {
position: relative;
}
.tool-menu-trigger {
font-weight: 500;
}
.tool-menu-dropdown {
position: absolute;
top: calc(100% + 4px);
right: 0;
min-width: 200px;
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
z-index: 50;
overflow: hidden;
padding: 4px;
}
.tool-menu-title {
padding: 6px 10px 4px;
font-size: 11px;
font-weight: 600;
color: #9ca3af;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.tool-menu-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 7px 10px;
border: none;
border-radius: 6px;
background: transparent;
cursor: pointer;
font-size: 13px;
color: #374151;
text-align: left;
transition: background 0.1s;
}
.tool-menu-item:hover:not(:disabled) {
background: #f3f4f6;
}
.tool-menu-item:disabled {
cursor: default;
opacity: 0.5;
}
.tool-menu-item-active {
color: #9ca3af;
}
.tool-menu-item-icon {
font-size: 15px;
flex-shrink: 0;
}
.tool-menu-item-label {
flex: 1;
}
.tool-menu-item-badge {
font-size: 10px;
color: #9ca3af;
background: #f3f4f6;
padding: 1px 6px;
border-radius: 4px;
}
/* ================================================================= */
/* Status indicator */
/* ================================================================= */
.context-status {
font-size: 8px;
color: #d1d5db;
}
.context-status-ok {
color: #16a34a;
}
/* ================================================================= */
/* Shared button style */
/* ================================================================= */
.context-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;
}
.context-btn:hover {
background: #e5e7eb;
}
.context-btn-active {
background: #4f46e5;
color: white;
}
/* ================================================================= */
/* Responsive */
/* ================================================================= */
@media (max-width: 768px) {
.context-header-inner {
padding: 6px 12px;
}
.context-selector-trigger {
font-size: 14px;
max-width: 200px;
}
.context-selector-dropdown {
min-width: 220px;
max-width: calc(100vw - 24px);
}
.tool-menu-dropdown {
max-width: calc(100vw - 24px);
}
.context-header-right {
gap: 6px;
}
}
</style>