Unifisert ContextHeader: innstillinger, fargevelger, slett, tema-modul
- ContextHeader brukes nå på både personlig flate og samlinger - Ny theme.ts: ThemeConfig med hue+saturation per farge, presets (Standard, Hav, Skog, Solnedgang, Lavendel, Monokrom) - Fargevelger med hue-stripe (regnbue) + saturation-slider + swatch per farge (bakgrunn, overflate, aksent) - Slett arbeidsflate med bekreftelsesdialog og innholdstelling - Kontekst-velger: grupper, rename, ny, slett — fungerer overalt - +page.svelte forenklet: ~500 linjer CSS fjernet, bruker ContextHeader Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
fa85d29c35
commit
f565cfc670
3 changed files with 746 additions and 1140 deletions
File diff suppressed because it is too large
Load diff
171
frontend/src/lib/workspace/theme.ts
Normal file
171
frontend/src/lib/workspace/theme.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
/**
|
||||
* Shared theme logic for Synops workspace.
|
||||
*
|
||||
* Each workspace/collection can have its own theme stored in
|
||||
* metadata.preferences.theme. Themes use HSL color model for
|
||||
* background, surface, and accent colors.
|
||||
*
|
||||
* Three-layer inheritance:
|
||||
* 1. Flate-spesifikt (lagret i nodens metadata)
|
||||
* 2. Personlig default (fra "Min arbeidsflate")
|
||||
* 3. Plattform-default (DEFAULT_THEME)
|
||||
*/
|
||||
|
||||
/** Color definition: hue + saturation. Lightness is derived per use. */
|
||||
export interface ThemeColor {
|
||||
hue: number; // 0-360
|
||||
saturation: number; // 0-100
|
||||
}
|
||||
|
||||
/** Full theme configuration */
|
||||
export interface ThemeConfig {
|
||||
bg: ThemeColor;
|
||||
surface: ThemeColor;
|
||||
accent: ThemeColor;
|
||||
}
|
||||
|
||||
/** Platform default — neutral dark with indigo accent */
|
||||
export const DEFAULT_THEME: ThemeConfig = {
|
||||
bg: { hue: 0, saturation: 0 },
|
||||
surface: { hue: 0, saturation: 0 },
|
||||
accent: { hue: 239, saturation: 70 },
|
||||
};
|
||||
|
||||
/** Named theme presets */
|
||||
export interface ThemePreset {
|
||||
name: string;
|
||||
theme: ThemeConfig;
|
||||
}
|
||||
|
||||
export const THEME_PRESETS: ThemePreset[] = [
|
||||
{
|
||||
name: 'Standard',
|
||||
theme: DEFAULT_THEME,
|
||||
},
|
||||
{
|
||||
name: 'Hav',
|
||||
theme: {
|
||||
bg: { hue: 210, saturation: 15 },
|
||||
surface: { hue: 210, saturation: 12 },
|
||||
accent: { hue: 200, saturation: 75 },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Skog',
|
||||
theme: {
|
||||
bg: { hue: 150, saturation: 12 },
|
||||
surface: { hue: 150, saturation: 10 },
|
||||
accent: { hue: 142, saturation: 65 },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Solnedgang',
|
||||
theme: {
|
||||
bg: { hue: 15, saturation: 12 },
|
||||
surface: { hue: 15, saturation: 10 },
|
||||
accent: { hue: 25, saturation: 80 },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Lavendel',
|
||||
theme: {
|
||||
bg: { hue: 270, saturation: 10 },
|
||||
surface: { hue: 270, saturation: 8 },
|
||||
accent: { hue: 280, saturation: 65 },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Monokrom',
|
||||
theme: {
|
||||
bg: { hue: 0, saturation: 0 },
|
||||
surface: { hue: 0, saturation: 0 },
|
||||
accent: { hue: 0, saturation: 0 },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function hsl(hue: number, sat: number, light: number): string {
|
||||
return `hsl(${hue}, ${sat}%, ${light}%)`;
|
||||
}
|
||||
|
||||
function hsla(hue: number, sat: number, light: number, alpha: number): string {
|
||||
return `hsla(${hue}, ${sat}%, ${light}%, ${alpha})`;
|
||||
}
|
||||
|
||||
/** Apply theme to :root CSS custom properties */
|
||||
export function applyTheme(config: ThemeConfig): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
const root = document.documentElement;
|
||||
|
||||
const { bg, surface, accent } = config;
|
||||
|
||||
// Background: very dark, slight tint
|
||||
root.style.setProperty('--color-bg', hsl(bg.hue, bg.saturation, 4));
|
||||
|
||||
// Surface: slightly lighter
|
||||
root.style.setProperty('--color-surface', hsl(surface.hue, surface.saturation, 12));
|
||||
root.style.setProperty('--color-surface-hover', hsl(surface.hue, Math.max(0, surface.saturation - 2), 15));
|
||||
|
||||
// Border: derived from surface
|
||||
root.style.setProperty('--color-border', hsl(surface.hue, Math.max(0, surface.saturation - 3), 18));
|
||||
root.style.setProperty('--color-border-hover', hsl(surface.hue, Math.max(0, surface.saturation - 3), 24));
|
||||
|
||||
// Accent
|
||||
root.style.setProperty('--color-accent', hsl(accent.hue, accent.saturation, 60));
|
||||
root.style.setProperty('--color-accent-hover', hsl(accent.hue, accent.saturation, 65));
|
||||
root.style.setProperty('--color-accent-glow', hsla(accent.hue, accent.saturation, 60, 0.15));
|
||||
}
|
||||
|
||||
/** Reset theme to platform defaults (remove inline styles) */
|
||||
export function resetTheme(): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
const root = document.documentElement;
|
||||
const props = [
|
||||
'--color-bg', '--color-surface', '--color-surface-hover',
|
||||
'--color-border', '--color-border-hover',
|
||||
'--color-accent', '--color-accent-hover', '--color-accent-glow',
|
||||
];
|
||||
for (const prop of props) {
|
||||
root.style.removeProperty(prop);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load theme from node metadata. Backward compatible with old format
|
||||
* ({ hueBg, hueSurface, hueAccent }) and new format ({ bg, surface, accent }).
|
||||
*/
|
||||
export function loadThemeFromMetadata(meta: Record<string, unknown>): ThemeConfig | null {
|
||||
const prefs = meta.preferences as Record<string, unknown> | undefined;
|
||||
const theme = prefs?.theme as Record<string, unknown> | undefined;
|
||||
if (!theme) return null;
|
||||
|
||||
// New format
|
||||
if (theme.bg && typeof theme.bg === 'object') {
|
||||
return theme as unknown as ThemeConfig;
|
||||
}
|
||||
|
||||
// Old format: { hueBg, hueSurface, hueAccent }
|
||||
if (typeof theme.hueBg === 'number' || typeof theme.hueAccent === 'number') {
|
||||
return {
|
||||
bg: { hue: (theme.hueBg as number) ?? 0, saturation: (theme.hueBg as number) ? 10 : 0 },
|
||||
surface: { hue: (theme.hueSurface as number) ?? 0, saturation: (theme.hueSurface as number) ? 8 : 0 },
|
||||
accent: { hue: (theme.hueAccent as number) ?? 239, saturation: 70 },
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Serialize theme for storage in node metadata */
|
||||
export function themeToMetadata(config: ThemeConfig): Record<string, unknown> {
|
||||
return {
|
||||
bg: config.bg,
|
||||
surface: config.surface,
|
||||
accent: config.accent,
|
||||
};
|
||||
}
|
||||
|
||||
/** Get a CSS color string for a theme color at a given lightness */
|
||||
export function themeColorCSS(color: ThemeColor, lightness: number): string {
|
||||
return hsl(color.hue, color.saturation, lightness);
|
||||
}
|
||||
|
|
@ -1,10 +1,16 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { connectionState, nodeStore, edgeStore, nodeAccessStore, nodeVisibility } from '$lib/realtime';
|
||||
import type { Node } from '$lib/realtime';
|
||||
import { fetchMyWorkspace, updateNode, createNode, createEdge } from '$lib/api';
|
||||
import { signOut } from '@auth/sveltekit/client';
|
||||
import { fetchMyWorkspace, updateNode } from '$lib/api';
|
||||
import ContextHeader from '$lib/components/ContextHeader.svelte';
|
||||
import {
|
||||
type ThemeConfig,
|
||||
DEFAULT_THEME,
|
||||
applyTheme,
|
||||
loadThemeFromMetadata,
|
||||
themeToMetadata,
|
||||
} from '$lib/workspace/theme.js';
|
||||
|
||||
// Canvas + BlockShell
|
||||
import Canvas from '$lib/components/canvas/Canvas.svelte';
|
||||
|
|
@ -20,9 +26,6 @@
|
|||
PANEL_HEADER_HEIGHT,
|
||||
} from '$lib/workspace/types.js';
|
||||
|
||||
// Recency tracking for context selector
|
||||
import { getRankedNodeIds } from '$lib/workspace/recency.js';
|
||||
|
||||
// Trait components (reuse from collection page)
|
||||
import EditorTrait from '$lib/components/traits/EditorTrait.svelte';
|
||||
import ChatTrait from '$lib/components/traits/ChatTrait.svelte';
|
||||
|
|
@ -73,10 +76,9 @@
|
|||
} else if (!layoutInitialized) {
|
||||
layoutInitialized = true;
|
||||
}
|
||||
// Load theme preferences
|
||||
// Load preferences
|
||||
if (res.metadata) {
|
||||
loadThemeFromMetadata(res.metadata as Record<string, unknown>);
|
||||
// Load saved camera position
|
||||
loadThemeFromMeta(res.metadata as Record<string, unknown>);
|
||||
const meta = res.metadata as Record<string, unknown>;
|
||||
const cam = meta.camera as Camera | undefined;
|
||||
if (cam && typeof cam.x === 'number') {
|
||||
|
|
@ -156,7 +158,7 @@
|
|||
gridEnabled,
|
||||
preferences: {
|
||||
...(currentMeta.preferences ?? {}),
|
||||
theme: { hueBg: themeHueBg, hueSurface: themeHueSurface, hueAccent: themeHueAccent },
|
||||
theme: themeToMetadata(currentTheme),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -234,188 +236,21 @@
|
|||
const activeLayoutTraits = $derived(layout.panels.map(p => p.trait));
|
||||
|
||||
// =========================================================================
|
||||
// Context selector (navigate to collections)
|
||||
// Theme
|
||||
// =========================================================================
|
||||
|
||||
let selectorOpen = $state(false);
|
||||
let searchQuery = $state('');
|
||||
let searchInput = $state<HTMLInputElement | undefined>(undefined);
|
||||
let currentTheme = $state<ThemeConfig>(DEFAULT_THEME);
|
||||
|
||||
const collectionNodes = $derived.by((): Node[] => {
|
||||
if (!connected || !nodeId) return [];
|
||||
const accessibleIds = new Set<string>();
|
||||
for (const edge of edgeStore.bySource(nodeId)) {
|
||||
if (!edge.system) accessibleIds.add(edge.targetId);
|
||||
}
|
||||
for (const id of nodeAccessStore.objectsForSubject(nodeId)) {
|
||||
accessibleIds.add(id);
|
||||
}
|
||||
const collections: Node[] = [];
|
||||
for (const id of accessibleIds) {
|
||||
const node = nodeStore.get(id);
|
||||
if (node && node.nodeKind === 'collection' && nodeVisibility(node, nodeId) !== 'hidden') {
|
||||
collections.push(node);
|
||||
}
|
||||
}
|
||||
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;
|
||||
return (a.title ?? '').localeCompare(b.title ?? '');
|
||||
});
|
||||
return collections;
|
||||
});
|
||||
|
||||
const filteredCollections = $derived.by(() => {
|
||||
if (!searchQuery.trim()) return collectionNodes;
|
||||
const q = searchQuery.toLowerCase().trim();
|
||||
return collectionNodes.filter(n => (n.title ?? '').toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
const myCollections = $derived(filteredCollections.filter(n => n.createdBy === nodeId));
|
||||
const sharedCollections = $derived(filteredCollections.filter(n => n.createdBy !== nodeId));
|
||||
|
||||
let renamingId = $state<string | undefined>(undefined);
|
||||
let renameValue = $state('');
|
||||
|
||||
function startRename(node: Node) {
|
||||
renamingId = node.id;
|
||||
renameValue = node.title || '';
|
||||
}
|
||||
|
||||
async function commitRename() {
|
||||
if (!renamingId || !accessToken) return;
|
||||
const id = renamingId;
|
||||
renamingId = undefined;
|
||||
if (renameValue.trim()) {
|
||||
try {
|
||||
await updateNode(accessToken, { node_id: id, title: renameValue.trim() });
|
||||
} catch (e) {
|
||||
console.error('Feil ved omdøping:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleRenameKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') { e.preventDefault(); commitRename(); }
|
||||
if (e.key === 'Escape') { renamingId = undefined; }
|
||||
}
|
||||
|
||||
let isCreatingWorkspace = $state(false);
|
||||
|
||||
async function createNewWorkspace() {
|
||||
if (!accessToken || !nodeId || isCreatingWorkspace) return;
|
||||
isCreatingWorkspace = true;
|
||||
selectorOpen = false;
|
||||
try {
|
||||
const { node_id } = await createNode(accessToken, {
|
||||
node_kind: 'collection',
|
||||
title: 'Ny arbeidsflate',
|
||||
visibility: 'hidden',
|
||||
metadata: {
|
||||
traits: {
|
||||
chat: {},
|
||||
editor: {},
|
||||
kanban: {},
|
||||
calendar: {},
|
||||
}
|
||||
},
|
||||
});
|
||||
await createEdge(accessToken, {
|
||||
source_id: nodeId,
|
||||
target_id: node_id,
|
||||
edge_type: 'owner',
|
||||
});
|
||||
goto(`/collection/${node_id}`);
|
||||
} catch (e) {
|
||||
console.error('Feil ved oppretting av arbeidsflate:', e);
|
||||
} finally {
|
||||
isCreatingWorkspace = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelector() {
|
||||
selectorOpen = !selectorOpen;
|
||||
toolMenuOpen = false;
|
||||
settingsOpen = false;
|
||||
if (selectorOpen) {
|
||||
searchQuery = '';
|
||||
renamingId = undefined;
|
||||
requestAnimationFrame(() => searchInput?.focus());
|
||||
}
|
||||
}
|
||||
|
||||
function selectCollection(id: string) {
|
||||
selectorOpen = false;
|
||||
goto(`/collection/${id}`);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Tool menu
|
||||
// =========================================================================
|
||||
|
||||
let toolMenuOpen = $state(false);
|
||||
|
||||
const availableTools = $derived.by(() => {
|
||||
return Object.entries(TRAIT_PANEL_INFO).map(([key, info]) => ({
|
||||
key,
|
||||
...info,
|
||||
active: activeLayoutTraits.includes(key),
|
||||
}));
|
||||
});
|
||||
|
||||
function toggleToolMenu() {
|
||||
toolMenuOpen = !toolMenuOpen;
|
||||
selectorOpen = false;
|
||||
settingsOpen = false;
|
||||
}
|
||||
|
||||
function addTool(trait: string) {
|
||||
handleAddPanel(trait);
|
||||
toolMenuOpen = false;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Settings menu (theme + sign out)
|
||||
// =========================================================================
|
||||
|
||||
let settingsOpen = $state(false);
|
||||
let themeHueBg = $state(0);
|
||||
let themeHueSurface = $state(0);
|
||||
let themeHueAccent = $state(240); // default indigo ≈ 240
|
||||
|
||||
function toggleSettings() {
|
||||
settingsOpen = !settingsOpen;
|
||||
selectorOpen = false;
|
||||
toolMenuOpen = false;
|
||||
}
|
||||
|
||||
function hslColor(hue: number, sat: number, light: number): string {
|
||||
return `hsl(${hue}, ${sat}%, ${light}%)`;
|
||||
}
|
||||
|
||||
function applyTheme() {
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--color-bg', hslColor(themeHueBg, themeHueBg ? 10 : 0, 4));
|
||||
root.style.setProperty('--color-surface', hslColor(themeHueSurface, themeHueSurface ? 8 : 0, 12));
|
||||
root.style.setProperty('--color-surface-hover', hslColor(themeHueSurface, themeHueSurface ? 6 : 0, 15));
|
||||
root.style.setProperty('--color-border', hslColor(themeHueSurface, themeHueSurface ? 5 : 0, 18));
|
||||
root.style.setProperty('--color-accent', hslColor(themeHueAccent, 70, 60));
|
||||
root.style.setProperty('--color-accent-hover', hslColor(themeHueAccent, 70, 65));
|
||||
root.style.setProperty('--color-accent-glow', `hsla(${themeHueAccent}, 70%, 60%, 0.15)`);
|
||||
function handleThemeChange(newTheme: ThemeConfig) {
|
||||
currentTheme = newTheme;
|
||||
persistMetadata();
|
||||
}
|
||||
|
||||
function loadThemeFromMetadata(meta: Record<string, unknown>) {
|
||||
const prefs = meta.preferences as Record<string, unknown> | undefined;
|
||||
const theme = prefs?.theme as Record<string, number> | undefined;
|
||||
if (theme) {
|
||||
themeHueBg = theme.hueBg ?? 0;
|
||||
themeHueSurface = theme.hueSurface ?? 0;
|
||||
themeHueAccent = theme.hueAccent ?? 240;
|
||||
applyTheme();
|
||||
function loadThemeFromMeta(meta: Record<string, unknown>) {
|
||||
const loaded = loadThemeFromMetadata(meta);
|
||||
if (loaded) {
|
||||
currentTheme = loaded;
|
||||
applyTheme(loaded);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -443,27 +278,6 @@
|
|||
// Click outside
|
||||
// =========================================================================
|
||||
|
||||
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;
|
||||
}
|
||||
if (settingsOpen && !target.closest('.settings-menu')) {
|
||||
settingsOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
selectorOpen = false;
|
||||
toolMenuOpen = false;
|
||||
settingsOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Trait components that have dedicated implementations */
|
||||
const knownTraits = new Set([
|
||||
'editor', 'chat', 'kanban', 'podcast', 'publishing',
|
||||
|
|
@ -524,173 +338,16 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={handleClickOutside} onkeydown={handleKeydown} />
|
||||
|
||||
<div class="workspace-page">
|
||||
<!-- Header -->
|
||||
<header class="context-header">
|
||||
<div class="context-header-inner">
|
||||
<div class="context-header-left">
|
||||
<div class="context-selector">
|
||||
<button
|
||||
class="context-selector-trigger"
|
||||
onclick={toggleSelector}
|
||||
title="Bytt kontekst"
|
||||
>
|
||||
<span class="context-selector-title">Min arbeidsflate</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 arbeidsflater..."
|
||||
class="context-selector-search-input"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
<div class="context-selector-list">
|
||||
{#if myCollections.length > 0}
|
||||
<div class="context-selector-group-label">Mine flater</div>
|
||||
{#each myCollections as node (node.id)}
|
||||
<div class="context-selector-item-row">
|
||||
{#if renamingId === node.id}
|
||||
<input
|
||||
class="context-selector-rename-input"
|
||||
bind:value={renameValue}
|
||||
onblur={commitRename}
|
||||
onkeydown={handleRenameKeydown}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{:else}
|
||||
<button
|
||||
class="context-selector-item"
|
||||
onclick={() => selectCollection(node.id)}
|
||||
>
|
||||
<span class="context-selector-item-title">{node.title || 'Uten tittel'}</span>
|
||||
</button>
|
||||
<button
|
||||
class="context-selector-rename-btn"
|
||||
onclick={(e) => { e.stopPropagation(); startRename(node); }}
|
||||
title="Gi nytt navn"
|
||||
>✏️</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
{#if sharedCollections.length > 0}
|
||||
<div class="context-selector-group-label">Delte flater</div>
|
||||
{#each sharedCollections as node (node.id)}
|
||||
<button
|
||||
class="context-selector-item"
|
||||
onclick={() => selectCollection(node.id)}
|
||||
>
|
||||
<span class="context-selector-item-title">{node.title || 'Uten tittel'}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
{#if myCollections.length === 0 && sharedCollections.length === 0}
|
||||
<div class="context-selector-empty">
|
||||
{searchQuery ? 'Ingen treff' : 'Ingen arbeidsflater'}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="context-selector-footer">
|
||||
<button
|
||||
class="context-selector-new-btn"
|
||||
onclick={createNewWorkspace}
|
||||
disabled={isCreatingWorkspace}
|
||||
>
|
||||
+ Ny arbeidsflate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">●</span>
|
||||
{:else}
|
||||
<span class="context-status" title="{connectionState.current}">●</span>
|
||||
{/if}
|
||||
|
||||
<div class="settings-menu">
|
||||
<button
|
||||
class="context-btn settings-trigger"
|
||||
onclick={toggleSettings}
|
||||
title="Innstillinger"
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
|
||||
{#if settingsOpen}
|
||||
<div class="settings-dropdown">
|
||||
<div class="settings-title">Tema</div>
|
||||
<label class="settings-slider">
|
||||
<span class="settings-slider-label">Bakgrunn</span>
|
||||
<input type="range" min="0" max="360" bind:value={themeHueBg} oninput={applyTheme} />
|
||||
</label>
|
||||
<label class="settings-slider">
|
||||
<span class="settings-slider-label">Overflate</span>
|
||||
<input type="range" min="0" max="360" bind:value={themeHueSurface} oninput={applyTheme} />
|
||||
</label>
|
||||
<label class="settings-slider">
|
||||
<span class="settings-slider-label">Aksent</span>
|
||||
<input type="range" min="0" max="360" bind:value={themeHueAccent} oninput={applyTheme} />
|
||||
</label>
|
||||
<div class="settings-divider"></div>
|
||||
{#if $page.data.session?.user}
|
||||
<div class="settings-user">{$page.data.session.user.name}</div>
|
||||
{/if}
|
||||
<button
|
||||
class="settings-signout"
|
||||
onclick={() => signOut()}
|
||||
>
|
||||
Logg ut
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<ContextHeader
|
||||
userId={nodeId}
|
||||
accessToken={accessToken}
|
||||
{connected}
|
||||
onAddPanel={handleAddPanel}
|
||||
activeTraits={activeLayoutTraits}
|
||||
theme={currentTheme}
|
||||
onThemeChange={handleThemeChange}
|
||||
/>
|
||||
|
||||
<!-- Main content -->
|
||||
{#if workspaceLoading}
|
||||
|
|
@ -862,281 +519,6 @@
|
|||
background: var(--color-bg, #0a0a0b);
|
||||
}
|
||||
|
||||
/* ================================================================= */
|
||||
/* Context header (inline — personal workspace variant) */
|
||||
/* ================================================================= */
|
||||
.context-header {
|
||||
border-bottom: 1px solid var(--color-border, #2a2a2e);
|
||||
background: var(--color-surface, #1c1c20);
|
||||
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;
|
||||
}
|
||||
|
||||
.context-back {
|
||||
font-size: 16px;
|
||||
color: #5a5a66;
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
padding: 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.context-back:hover {
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
/* ================================================================= */
|
||||
/* Context Selector */
|
||||
/* ================================================================= */
|
||||
.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: #e8e8ec;
|
||||
max-width: 300px;
|
||||
transition: background 0.12s, border-color 0.12s;
|
||||
}
|
||||
|
||||
.context-selector-trigger:hover {
|
||||
background: var(--color-surface-hover, #242428);
|
||||
border-color: #e5e7eb;
|
||||
}
|
||||
|
||||
.context-selector-title {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.context-selector-chevron {
|
||||
font-size: 10px;
|
||||
color: #5a5a66;
|
||||
transition: transform 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.context-selector-chevron.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.context-selector-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
min-width: 260px;
|
||||
max-width: 360px;
|
||||
background: var(--color-surface, #1c1c20);
|
||||
border: 1px solid var(--color-border, #2a2a2e);
|
||||
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 var(--color-border, #2a2a2e);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
background: var(--color-bg, #141416);
|
||||
}
|
||||
|
||||
.context-selector-search-input:focus {
|
||||
border-color: var(--color-accent, #4f46e5);
|
||||
background: var(--color-surface, #1c1c20);
|
||||
}
|
||||
|
||||
.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: #8a8a96;
|
||||
text-align: left;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.context-selector-item:hover {
|
||||
background: var(--color-surface-hover, #242428);
|
||||
}
|
||||
|
||||
.context-selector-item-title {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.context-selector-empty {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #5a5a66;
|
||||
}
|
||||
|
||||
/* ================================================================= */
|
||||
/* 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: var(--color-surface, #1c1c20);
|
||||
border: 1px solid var(--color-border, #2a2a2e);
|
||||
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: #5a5a66;
|
||||
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: #8a8a96;
|
||||
text-align: left;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.tool-menu-item:hover:not(:disabled) {
|
||||
background: var(--color-surface-hover, #242428);
|
||||
}
|
||||
|
||||
.tool-menu-item:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.tool-menu-item-active {
|
||||
color: #5a5a66;
|
||||
}
|
||||
|
||||
.tool-menu-item-icon {
|
||||
font-size: 15px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tool-menu-item-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tool-menu-item-badge {
|
||||
font-size: 10px;
|
||||
color: #5a5a66;
|
||||
background: var(--color-surface-hover, #242428);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ================================================================= */
|
||||
/* Status + buttons */
|
||||
/* ================================================================= */
|
||||
.context-status {
|
||||
font-size: 8px;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.context-status-ok {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.context-btn {
|
||||
padding: 4px 10px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
background: var(--color-surface-hover, #242428);
|
||||
color: #4b5563;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.context-btn:hover {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
/* ================================================================= */
|
||||
/* Messages */
|
||||
/* ================================================================= */
|
||||
|
|
@ -1285,216 +667,10 @@
|
|||
min-height: 100%;
|
||||
}
|
||||
|
||||
/* ================================================================= */
|
||||
/* Context selector — groups, rename, new */
|
||||
/* ================================================================= */
|
||||
.context-selector-group-label {
|
||||
padding: 8px 10px 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #5a5a66;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.context-selector-item-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.context-selector-item-row .context-selector-item {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.context-selector-rename-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 4px 6px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.1s;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.context-selector-item-row:hover .context-selector-rename-btn {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.context-selector-rename-btn:hover {
|
||||
opacity: 1 !important;
|
||||
background: var(--color-surface-hover, #242428);
|
||||
}
|
||||
|
||||
.context-selector-rename-input {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #6366f1 !important;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
background: #141416 !important;
|
||||
color: #e8e8ec !important;
|
||||
margin: 2px 4px;
|
||||
}
|
||||
|
||||
.context-selector-footer {
|
||||
border-top: 1px solid var(--color-border, #2a2a2e);
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.context-selector-new-btn {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px dashed var(--color-border, #2a2a2e);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: #5a5a66;
|
||||
transition: border-color 0.1s, color 0.1s;
|
||||
}
|
||||
|
||||
.context-selector-new-btn:hover:not(:disabled) {
|
||||
border-color: var(--color-accent, #6366f1);
|
||||
color: #8a8a96;
|
||||
}
|
||||
|
||||
.context-selector-new-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ================================================================= */
|
||||
/* Settings menu */
|
||||
/* ================================================================= */
|
||||
.settings-menu {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.settings-trigger {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.settings-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
right: 0;
|
||||
min-width: 220px;
|
||||
background: var(--color-surface, #1c1c20);
|
||||
background: var(--color-surface, #1c1c20);
|
||||
border: 1px solid var(--color-border, #2a2a2e);
|
||||
border: 1px solid var(--color-border, #2a2a2e);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
|
||||
z-index: 50;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.settings-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #5a5a66;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.settings-slider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.settings-slider-label {
|
||||
font-size: 12px;
|
||||
color: #8a8a96;
|
||||
min-width: 65px;
|
||||
}
|
||||
|
||||
.settings-slider input[type="range"] {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: #2a2a2e !important;
|
||||
border: none !important;
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.settings-slider input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent, #6366f1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.settings-divider {
|
||||
height: 1px;
|
||||
background: #2a2a2e;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.settings-user {
|
||||
font-size: 13px;
|
||||
color: #8a8a96;
|
||||
padding: 4px 0 8px;
|
||||
}
|
||||
|
||||
.settings-signout {
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: #8a8a96;
|
||||
text-align: left;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.settings-signout:hover {
|
||||
background: var(--color-surface-hover, #242428);
|
||||
color: #e8e8ec;
|
||||
}
|
||||
|
||||
/* ================================================================= */
|
||||
/* 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,
|
||||
.settings-dropdown {
|
||||
max-width: calc(100vw - 24px);
|
||||
}
|
||||
|
||||
.context-header-right {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.workspace-empty-tools {
|
||||
gap: 6px;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue