Fullfører oppgave 19.4: Kontekst-header med kontekst-velger og verktøymeny
Erstatter den statiske headeren på arbeidsflaten med en interaktiv kontekst-header som lar brukeren: - Bytte mellom samlinger via nedtrekksmeny (kontekst-velger) - Søke blant tilgjengelige samlinger - Se mest brukte samlinger øverst (frekvens + recency-scoring) - Legge til nye verktøy-paneler via verktøymenyen - Se hvilke paneler som allerede er aktive på flaten Nye filer: - ContextHeader.svelte: header-komponent med kontekst-velger og verktøymeny - workspace/recency.ts: localStorage-basert frekvens/recency-tracking Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4e01f9f7e5
commit
17bda8d20f
4 changed files with 718 additions and 131 deletions
601
frontend/src/lib/components/ContextHeader.svelte
Normal file
601
frontend/src/lib/components/ContextHeader.svelte
Normal file
|
|
@ -0,0 +1,601 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { connectionState, nodeStore, edgeStore, nodeAccessStore, nodeVisibility } from '$lib/spacetime';
|
||||||
|
import type { Node } from '$lib/spacetime';
|
||||||
|
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 STDB 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">←</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}>▾</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">
|
||||||
|
{#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">✓</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 SpacetimeDB">●</span>
|
||||||
|
{:else}
|
||||||
|
<span class="context-status" title="{connectionState.current}">●</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>
|
||||||
71
frontend/src/lib/workspace/recency.ts
Normal file
71
frontend/src/lib/workspace/recency.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
/**
|
||||||
|
* Tracks node visit frequency and recency for the context selector.
|
||||||
|
* Uses localStorage to persist across sessions.
|
||||||
|
*
|
||||||
|
* Scoring: combines frequency (visit count) with recency (time decay)
|
||||||
|
* to rank nodes in the context switcher dropdown.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'synops_node_recency';
|
||||||
|
const MAX_ENTRIES = 50;
|
||||||
|
|
||||||
|
interface NodeVisit {
|
||||||
|
/** Number of times visited */
|
||||||
|
count: number;
|
||||||
|
/** Last visit timestamp (ms since epoch) */
|
||||||
|
lastVisit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecencyMap = Record<string, NodeVisit>;
|
||||||
|
|
||||||
|
function load(): RecencyMap {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (raw) return JSON.parse(raw) as RecencyMap;
|
||||||
|
} catch { /* ignore corrupt data */ }
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function save(map: RecencyMap) {
|
||||||
|
// Prune to MAX_ENTRIES, keeping highest-scored
|
||||||
|
const entries = Object.entries(map);
|
||||||
|
if (entries.length > MAX_ENTRIES) {
|
||||||
|
const now = Date.now();
|
||||||
|
entries.sort((a, b) => score(b[1], now) - score(a[1], now));
|
||||||
|
const pruned: RecencyMap = {};
|
||||||
|
for (let i = 0; i < MAX_ENTRIES; i++) {
|
||||||
|
pruned[entries[i][0]] = entries[i][1];
|
||||||
|
}
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(pruned));
|
||||||
|
} else {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(map));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Score combines frequency with time decay (halves every 7 days). */
|
||||||
|
function score(visit: NodeVisit, now: number): number {
|
||||||
|
const daysSince = (now - visit.lastVisit) / (1000 * 60 * 60 * 24);
|
||||||
|
const decay = Math.pow(0.5, daysSince / 7);
|
||||||
|
return visit.count * decay;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Record a visit to a node. Call when navigating to a collection. */
|
||||||
|
export function recordVisit(nodeId: string): void {
|
||||||
|
const map = load();
|
||||||
|
const existing = map[nodeId];
|
||||||
|
map[nodeId] = {
|
||||||
|
count: (existing?.count ?? 0) + 1,
|
||||||
|
lastVisit: Date.now(),
|
||||||
|
};
|
||||||
|
save(map);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get node IDs sorted by score (most relevant first). */
|
||||||
|
export function getRankedNodeIds(): string[] {
|
||||||
|
const map = load();
|
||||||
|
const now = Date.now();
|
||||||
|
return Object.entries(map)
|
||||||
|
.map(([id, visit]) => ({ id, score: score(visit, now) }))
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.map(e => e.id);
|
||||||
|
}
|
||||||
|
|
@ -14,9 +14,13 @@
|
||||||
type PanelLayout,
|
type PanelLayout,
|
||||||
type WorkspaceLayout,
|
type WorkspaceLayout,
|
||||||
getPanelInfo,
|
getPanelInfo,
|
||||||
|
generateDefaultLayout,
|
||||||
resolveLayout,
|
resolveLayout,
|
||||||
} from '$lib/workspace/types.js';
|
} from '$lib/workspace/types.js';
|
||||||
|
|
||||||
|
// Context header
|
||||||
|
import ContextHeader from '$lib/components/ContextHeader.svelte';
|
||||||
|
|
||||||
// Trait components
|
// Trait components
|
||||||
import EditorTrait from '$lib/components/traits/EditorTrait.svelte';
|
import EditorTrait from '$lib/components/traits/EditorTrait.svelte';
|
||||||
import ChatTrait from '$lib/components/traits/ChatTrait.svelte';
|
import ChatTrait from '$lib/components/traits/ChatTrait.svelte';
|
||||||
|
|
@ -193,6 +197,35 @@
|
||||||
persistLayout();
|
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
|
// Mobile detection + tab state
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
@ -216,36 +249,18 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="workspace-page">
|
<div class="workspace-page">
|
||||||
<!-- Header -->
|
<!-- Context Header: context selector + tool menu -->
|
||||||
<header class="workspace-header">
|
<ContextHeader
|
||||||
<div class="workspace-header-inner">
|
{collectionId}
|
||||||
<div class="workspace-header-left">
|
{collectionNode}
|
||||||
<a href="/" class="workspace-back">← Mottak</a>
|
userId={nodeId}
|
||||||
<h1 class="workspace-title">
|
{connected}
|
||||||
{collectionNode?.title || 'Samling'}
|
{traitNames}
|
||||||
</h1>
|
onToggleTraitAdmin={() => { showTraitAdmin = !showTraitAdmin; }}
|
||||||
</div>
|
{showTraitAdmin}
|
||||||
<div class="workspace-header-right">
|
onAddPanel={handleAddPanel}
|
||||||
{#if connected}
|
activeTraits={activeLayoutTraits}
|
||||||
<span class="workspace-status workspace-status-ok">Tilkoblet</span>
|
/>
|
||||||
{:else}
|
|
||||||
<span class="workspace-status">{connectionState.current}</span>
|
|
||||||
{/if}
|
|
||||||
{#if traitNames.length > 0}
|
|
||||||
<span class="workspace-meta">{traitNames.length} traits</span>
|
|
||||||
{/if}
|
|
||||||
<span class="workspace-meta">{childCount} noder</span>
|
|
||||||
{#if connected && collectionNode && accessToken}
|
|
||||||
<button
|
|
||||||
onclick={() => { showTraitAdmin = !showTraitAdmin; }}
|
|
||||||
class="workspace-btn {showTraitAdmin ? 'workspace-btn-active' : ''}"
|
|
||||||
>
|
|
||||||
Traits
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Trait admin panel (overlay) -->
|
<!-- Trait admin panel (overlay) -->
|
||||||
{#if showTraitAdmin && accessToken}
|
{#if showTraitAdmin && accessToken}
|
||||||
|
|
@ -412,93 +427,6 @@
|
||||||
background: #f0f2f5;
|
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 {
|
.workspace-btn-primary {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
|
|
@ -643,18 +571,6 @@
|
||||||
/* Responsive */
|
/* Responsive */
|
||||||
/* ================================================================= */
|
/* ================================================================= */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.workspace-header-inner {
|
|
||||||
padding: 8px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace-header-right {
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace-title {
|
|
||||||
font-size: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace-footer-tools {
|
.workspace-footer-tools {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
|
||||||
3
tasks.md
3
tasks.md
|
|
@ -215,8 +215,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.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).
|
- [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).
|
||||||
- [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".
|
- [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".
|
- [x] 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".
|
||||||
> Påbegynt: 2026-03-18T07:33
|
|
||||||
- [ ] 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.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.
|
- [ ] 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