- Lasso-seleksjon → dra flytter alle valgte paneler sammen - Grid on/off lagres i workspace-metadata (huskes mellom besøk) - Zoom lagres allerede via kameraposisjon (x, y, zoom) - ZOOM_MIN senket til 5% for spredte layouts Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1507 lines
42 KiB
Svelte
1507 lines
42 KiB
Svelte
<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';
|
|
|
|
// Canvas + BlockShell
|
|
import Canvas from '$lib/components/canvas/Canvas.svelte';
|
|
import type { CanvasObject, Camera } from '$lib/components/canvas/types.js';
|
|
import BlockShell from '$lib/components/blockshell/BlockShell.svelte';
|
|
|
|
// Workspace layout
|
|
import {
|
|
type PanelLayout,
|
|
type WorkspaceLayout,
|
|
getPanelInfo,
|
|
TRAIT_PANEL_INFO,
|
|
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';
|
|
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 MindMapTrait from '$lib/components/traits/MindMapTrait.svelte';
|
|
import GenericTrait from '$lib/components/traits/GenericTrait.svelte';
|
|
import AiToolPanel from '$lib/components/AiToolPanel.svelte';
|
|
import NodeUsage from '$lib/components/NodeUsage.svelte';
|
|
|
|
import { createBlockReceiver, executeTransfer, resolveTransferMode, type DragPayload } from '$lib/transfer';
|
|
import type { BlockReceiver } from '$lib/components/blockshell/types';
|
|
|
|
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);
|
|
const connected = $derived(connectionState.current === 'connected');
|
|
|
|
// =========================================================================
|
|
// Workspace node (fetched from backend)
|
|
// =========================================================================
|
|
|
|
let workspaceNodeId = $state<string | undefined>(undefined);
|
|
let workspaceLoading = $state(true);
|
|
let workspaceError = $state<string | undefined>(undefined);
|
|
|
|
// Fetch or create workspace on load
|
|
$effect(() => {
|
|
if (!accessToken) return;
|
|
workspaceLoading = true;
|
|
fetchMyWorkspace(accessToken)
|
|
.then((res) => {
|
|
workspaceNodeId = res.node_id;
|
|
// Initialize layout from backend metadata if we don't have one yet
|
|
if (!layoutInitialized && res.metadata?.workspace_layout) {
|
|
const saved = res.metadata.workspace_layout as WorkspaceLayout;
|
|
if (saved.panels && Array.isArray(saved.panels) && saved.panels.length > 0) {
|
|
layout = saved;
|
|
}
|
|
layoutInitialized = true;
|
|
} else if (!layoutInitialized) {
|
|
layoutInitialized = true;
|
|
}
|
|
// Load theme preferences
|
|
if (res.metadata) {
|
|
loadThemeFromMetadata(res.metadata as Record<string, unknown>);
|
|
// Load saved camera position
|
|
const meta = res.metadata as Record<string, unknown>;
|
|
const cam = meta.camera as Camera | undefined;
|
|
if (cam && typeof cam.x === 'number') {
|
|
savedCamera = cam;
|
|
}
|
|
if (typeof meta.gridEnabled === 'boolean') {
|
|
gridEnabled = meta.gridEnabled;
|
|
}
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
workspaceError = err.message;
|
|
})
|
|
.finally(() => {
|
|
workspaceLoading = false;
|
|
});
|
|
});
|
|
|
|
// Also try to read layout from WS node store (for real-time sync)
|
|
const workspaceNode = $derived(
|
|
workspaceNodeId && connected ? nodeStore.get(workspaceNodeId) : undefined
|
|
);
|
|
|
|
// =========================================================================
|
|
// Layout state
|
|
// =========================================================================
|
|
|
|
let layout = $state<WorkspaceLayout>({ panels: [] });
|
|
let layoutInitialized = $state(false);
|
|
let savedCamera = $state<Camera>({ x: 0, y: 0, zoom: 1.0 });
|
|
let gridEnabled = $state(false);
|
|
|
|
// When workspace node appears in store (after creation), load its layout
|
|
$effect(() => {
|
|
if (!workspaceNode || layoutInitialized) return;
|
|
try {
|
|
const meta = JSON.parse(workspaceNode.metadata ?? '{}');
|
|
if (meta.workspace_layout?.panels?.length > 0) {
|
|
layout = meta.workspace_layout as WorkspaceLayout;
|
|
}
|
|
} catch { /* ignore */ }
|
|
layoutInitialized = true;
|
|
});
|
|
|
|
/** Convert layout panels to CanvasObjects */
|
|
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 (debounced save to workspace node metadata)
|
|
// =========================================================================
|
|
|
|
let saveTimeout: ReturnType<typeof setTimeout> | undefined;
|
|
|
|
/** Persist all workspace metadata (layout + preferences) */
|
|
function persistMetadata() {
|
|
if (!accessToken || !workspaceNodeId) return;
|
|
clearTimeout(saveTimeout);
|
|
saveTimeout = setTimeout(async () => {
|
|
try {
|
|
const currentMeta = workspaceNode
|
|
? JSON.parse(workspaceNode.metadata ?? '{}')
|
|
: {};
|
|
await updateNode(accessToken!, {
|
|
node_id: workspaceNodeId!,
|
|
metadata: {
|
|
...currentMeta,
|
|
workspace_layout: layout,
|
|
camera: savedCamera,
|
|
gridEnabled,
|
|
preferences: {
|
|
...(currentMeta.preferences ?? {}),
|
|
theme: { hueBg: themeHueBg, hueSurface: themeHueSurface, hueAccent: themeHueAccent },
|
|
},
|
|
},
|
|
});
|
|
} catch (err) {
|
|
console.warn('Failed to persist workspace metadata:', err);
|
|
}
|
|
}, 1000);
|
|
}
|
|
|
|
// Keep old name as alias for callers
|
|
function persistLayout() { persistMetadata(); }
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
function handlePanelResize(trait: string, width: number, height: number, dx?: number, dy?: number) {
|
|
const idx = layout.panels.findIndex(p => p.trait === trait);
|
|
if (idx >= 0) {
|
|
const panel = layout.panels[idx];
|
|
layout.panels[idx] = {
|
|
...panel,
|
|
width,
|
|
height,
|
|
x: panel.x + (dx ?? 0),
|
|
y: panel.y + (dy ?? 0),
|
|
};
|
|
layout = { ...layout };
|
|
persistLayout();
|
|
}
|
|
}
|
|
|
|
function handlePanelClose(trait: string) {
|
|
layout = {
|
|
panels: layout.panels.filter(p => p.trait !== trait),
|
|
};
|
|
persistLayout();
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
function handleAddPanel(trait: string) {
|
|
if (layout.panels.some(p => p.trait === trait)) return;
|
|
const info = getPanelInfo(trait);
|
|
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();
|
|
}
|
|
|
|
const activeLayoutTraits = $derived(layout.panels.map(p => p.trait));
|
|
|
|
// =========================================================================
|
|
// Context selector (navigate to collections)
|
|
// =========================================================================
|
|
|
|
let selectorOpen = $state(false);
|
|
let searchQuery = $state('');
|
|
let searchInput = $state<HTMLInputElement | undefined>(undefined);
|
|
|
|
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)`);
|
|
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();
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// Mobile detection
|
|
// =========================================================================
|
|
|
|
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);
|
|
});
|
|
|
|
$effect(() => {
|
|
if (activeTab >= layout.panels.length && layout.panels.length > 0) {
|
|
activeTab = 0;
|
|
}
|
|
});
|
|
|
|
// =========================================================================
|
|
// 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',
|
|
'rss', 'calendar', 'recording', 'transcription', 'studio', 'mixer', 'mindmap',
|
|
'ai', 'usage'
|
|
]);
|
|
|
|
// =========================================================================
|
|
// BlockReceiver per tool type — wired into BlockShell for drop compatibility
|
|
// Ref: docs/features/universell_overfoering.md § 4
|
|
// =========================================================================
|
|
|
|
/** Traits that support BlockReceiver drops */
|
|
const RECEIVER_TRAITS = new Set(['editor', 'chat', 'kanban', 'calendar', 'studio']);
|
|
|
|
/** Cache receivers by trait name — one per type */
|
|
const receiverCache = new Map<string, BlockReceiver>();
|
|
|
|
function getReceiverForTrait(trait: string): BlockReceiver | undefined {
|
|
if (!RECEIVER_TRAITS.has(trait)) return undefined;
|
|
if (!receiverCache.has(trait)) {
|
|
const toolType = trait as import('$lib/transfer').ToolType;
|
|
receiverCache.set(trait, createBlockReceiver(toolType));
|
|
}
|
|
return receiverCache.get(trait);
|
|
}
|
|
|
|
function handlePanelDrop(trait: string, payload: DragPayload, shiftKey: boolean = false) {
|
|
const receiver = getReceiverForTrait(trait);
|
|
if (!receiver?.receive) {
|
|
console.warn(`[universell-overføring] Ingen mottaker for ${trait}`);
|
|
return;
|
|
}
|
|
|
|
const intent = receiver.receive(payload);
|
|
const mode = resolveTransferMode(
|
|
payload.sourcePanel,
|
|
trait as import('$lib/transfer').ToolType,
|
|
shiftKey,
|
|
);
|
|
// Override intent mode with centrally resolved mode
|
|
intent.mode = mode;
|
|
|
|
if (!accessToken) {
|
|
console.error('[universell-overføring] Mangler accessToken — kan ikke utføre overføring');
|
|
return;
|
|
}
|
|
|
|
console.log(`[universell-overføring] ${payload.sourcePanel} → ${trait} (${mode}${shiftKey ? ', shift' : ''}):`, intent);
|
|
|
|
executeTransfer(accessToken, payload, intent, shiftKey)
|
|
.then((result) => {
|
|
console.log(`[universell-overføring] Fullført:`, result);
|
|
})
|
|
.catch((err) => {
|
|
console.error(`[universell-overføring] Feil:`, err);
|
|
});
|
|
}
|
|
</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>
|
|
|
|
<!-- Main content -->
|
|
{#if workspaceLoading}
|
|
<div class="workspace-message">
|
|
<p>Laster arbeidsflate...</p>
|
|
</div>
|
|
{:else if workspaceError}
|
|
<div class="workspace-message workspace-message-warn">
|
|
<p class="workspace-message-title">Kunne ikke laste arbeidsflate</p>
|
|
<p>{workspaceError}</p>
|
|
</div>
|
|
{:else if !connected}
|
|
<div class="workspace-message">
|
|
<p>Venter på tilkobling...</p>
|
|
</div>
|
|
{:else if layout.panels.length === 0}
|
|
<div class="workspace-empty">
|
|
<div class="workspace-empty-inner">
|
|
<p class="workspace-empty-title">Din personlige arbeidsflate</p>
|
|
<p class="workspace-empty-desc">
|
|
Legg til verktøy-paneler for å bygge opp arbeidsflaten din.
|
|
Arrangementet huskes mellom besøk.
|
|
</p>
|
|
<div class="workspace-empty-tools">
|
|
{#each Object.entries(TRAIT_PANEL_INFO) as [key, info] (key)}
|
|
<button
|
|
class="workspace-empty-tool"
|
|
onclick={() => handleAddPanel(key)}
|
|
>
|
|
<span class="workspace-empty-tool-icon">{info.icon}</span>
|
|
<span class="workspace-empty-tool-label">{info.title}</span>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{:else if isMobile}
|
|
<!-- MOBILE: Stacked tabs -->
|
|
<div class="mobile-tabs">
|
|
{#each layout.panels as panel, i (panel.trait)}
|
|
<button
|
|
class="mobile-tab {activeTab === i ? 'mobile-tab-active' : ''}"
|
|
onclick={() => { activeTab = i; }}
|
|
>
|
|
{getPanelInfo(panel.trait).icon}
|
|
<span class="mobile-tab-label">{getPanelInfo(panel.trait).title}</span>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
|
|
<div class="mobile-panel">
|
|
{#each layout.panels as panel, i (panel.trait)}
|
|
{#if activeTab === i}
|
|
<div class="mobile-panel-content">
|
|
{#if knownTraits.has(panel.trait)}
|
|
{#if panel.trait === 'editor'}
|
|
<EditorTrait collection={undefined} config={{}} userId={nodeId} {accessToken} collectionMetadata={{}} />
|
|
{:else if panel.trait === 'chat'}
|
|
<ChatTrait collection={undefined} config={{}} userId={nodeId} {accessToken} />
|
|
{:else if panel.trait === 'kanban'}
|
|
<KanbanTrait collection={undefined} config={{}} userId={nodeId} />
|
|
{:else if panel.trait === 'calendar'}
|
|
<CalendarTrait collection={undefined} config={{}} userId={nodeId} />
|
|
{:else if panel.trait === 'podcast'}
|
|
<PodcastTrait collection={undefined} config={{}} userId={nodeId} {accessToken} />
|
|
{:else if panel.trait === 'publishing'}
|
|
<PublishingTrait collection={undefined} config={{}} />
|
|
{:else if panel.trait === 'rss'}
|
|
<RssTrait collection={undefined} config={{}} />
|
|
{:else if panel.trait === 'recording'}
|
|
<RecordingTrait collection={undefined} config={{}} {accessToken} />
|
|
{:else if panel.trait === 'transcription'}
|
|
<TranscriptionTrait collection={undefined} config={{}} />
|
|
{:else if panel.trait === 'studio'}
|
|
<StudioTrait collection={undefined} config={{}} userId={nodeId} />
|
|
{:else if panel.trait === 'mixer'}
|
|
<MixerTrait collection={undefined} config={{}} {accessToken} />
|
|
{:else if panel.trait === 'mindmap'}
|
|
<MindMapTrait collection={undefined} config={{}} userId={nodeId} {accessToken} />
|
|
{:else if panel.trait === 'ai'}
|
|
<AiToolPanel {accessToken} userId={nodeId} />
|
|
{:else if panel.trait === 'usage'}
|
|
{#if nodeId && accessToken}
|
|
<NodeUsage nodeId={nodeId} {accessToken} />
|
|
{/if}
|
|
{/if}
|
|
{:else}
|
|
<GenericTrait name={panel.trait} config={{}} />
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
{/each}
|
|
</div>
|
|
{:else}
|
|
<!-- DESKTOP: Spatial Canvas -->
|
|
<div class="workspace-canvas">
|
|
<Canvas
|
|
objects={canvasObjects}
|
|
onObjectMove={handleObjectMove}
|
|
grid={{ enabled: gridEnabled, size: 20 }}
|
|
initialCamera={savedCamera}
|
|
onCameraChange={(cam) => { savedCamera = cam; persistMetadata(); }}
|
|
onGridChange={(enabled) => { gridEnabled = enabled; persistMetadata(); }}
|
|
>
|
|
{#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}
|
|
receiver={getReceiverForTrait(trait)}
|
|
onResize={(w, h, dx, dy) => handlePanelResize(trait, w, h, dx, dy)}
|
|
onClose={() => handlePanelClose(trait)}
|
|
onMinimizeChange={(m) => handlePanelMinimize(trait, m)}
|
|
onDrop={(payload, shiftKey) => handlePanelDrop(trait, payload, shiftKey)}
|
|
>
|
|
{#if knownTraits.has(trait)}
|
|
{#if trait === 'editor'}
|
|
<EditorTrait collection={undefined} config={{}} userId={nodeId} {accessToken} collectionMetadata={{}} />
|
|
{:else if trait === 'chat'}
|
|
<ChatTrait collection={undefined} config={{}} userId={nodeId} {accessToken} />
|
|
{:else if trait === 'kanban'}
|
|
<KanbanTrait collection={undefined} config={{}} userId={nodeId} />
|
|
{:else if trait === 'calendar'}
|
|
<CalendarTrait collection={undefined} config={{}} userId={nodeId} />
|
|
{:else if trait === 'podcast'}
|
|
<PodcastTrait collection={undefined} config={{}} userId={nodeId} {accessToken} />
|
|
{:else if trait === 'publishing'}
|
|
<PublishingTrait collection={undefined} config={{}} />
|
|
{:else if trait === 'rss'}
|
|
<RssTrait collection={undefined} config={{}} />
|
|
{:else if trait === 'recording'}
|
|
<RecordingTrait collection={undefined} config={{}} {accessToken} />
|
|
{:else if trait === 'transcription'}
|
|
<TranscriptionTrait collection={undefined} config={{}} />
|
|
{:else if trait === 'studio'}
|
|
<StudioTrait collection={undefined} config={{}} userId={nodeId} />
|
|
{:else if trait === 'mixer'}
|
|
<MixerTrait collection={undefined} config={{}} {accessToken} />
|
|
{:else if trait === 'mindmap'}
|
|
<MindMapTrait collection={undefined} config={{}} userId={nodeId} {accessToken} />
|
|
{:else if trait === 'ai'}
|
|
<AiToolPanel {accessToken} userId={nodeId} />
|
|
{:else if trait === 'usage'}
|
|
{#if nodeId && accessToken}
|
|
<NodeUsage nodeId={nodeId} {accessToken} />
|
|
{/if}
|
|
{/if}
|
|
{:else}
|
|
<GenericTrait name={trait} config={{}} />
|
|
{/if}
|
|
</BlockShell>
|
|
{/snippet}
|
|
</Canvas>
|
|
</div>
|
|
{/if}
|
|
|
|
</div>
|
|
|
|
<style>
|
|
.workspace-page {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100vh;
|
|
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 */
|
|
/* ================================================================= */
|
|
.workspace-message {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex: 1;
|
|
padding: 24px;
|
|
text-align: center;
|
|
font-size: 14px;
|
|
color: #8a8a96;
|
|
}
|
|
|
|
.workspace-message-warn {
|
|
color: #92400e;
|
|
background: #fffbeb;
|
|
}
|
|
|
|
.workspace-message-title {
|
|
font-weight: 600;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
/* ================================================================= */
|
|
/* Empty state (no panels) */
|
|
/* ================================================================= */
|
|
.workspace-empty {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex: 1;
|
|
padding: 24px;
|
|
}
|
|
|
|
.workspace-empty-inner {
|
|
text-align: center;
|
|
max-width: 480px;
|
|
}
|
|
|
|
.workspace-empty-title {
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
color: #e8e8ec;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.workspace-empty-desc {
|
|
font-size: 14px;
|
|
color: #8a8a96;
|
|
margin-bottom: 24px;
|
|
}
|
|
|
|
.workspace-empty-tools {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
justify-content: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.workspace-empty-tool {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 8px 14px;
|
|
border: 1px solid var(--color-border, #2a2a2e);
|
|
border-radius: 8px;
|
|
background: var(--color-surface, #1c1c20);
|
|
cursor: pointer;
|
|
font-size: 13px;
|
|
color: #8a8a96;
|
|
transition: border-color 0.12s, box-shadow 0.12s;
|
|
}
|
|
|
|
.workspace-empty-tool:hover {
|
|
border-color: var(--color-accent, #4f46e5);
|
|
box-shadow: 0 2px 8px rgba(79, 70, 229, 0.1);
|
|
}
|
|
|
|
.workspace-empty-tool-icon {
|
|
font-size: 16px;
|
|
}
|
|
|
|
.workspace-empty-tool-label {
|
|
font-weight: 500;
|
|
}
|
|
|
|
/* ================================================================= */
|
|
/* Canvas */
|
|
/* ================================================================= */
|
|
.workspace-canvas {
|
|
flex: 1;
|
|
min-height: 0;
|
|
}
|
|
|
|
/* ================================================================= */
|
|
/* Mobile tabs */
|
|
/* ================================================================= */
|
|
.mobile-tabs {
|
|
display: flex;
|
|
overflow-x: auto;
|
|
gap: 0;
|
|
background: var(--color-surface, #1c1c20);
|
|
border-bottom: 1px solid var(--color-border, #2a2a2e);
|
|
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: #8a8a96;
|
|
cursor: pointer;
|
|
white-space: nowrap;
|
|
border-bottom: 2px solid transparent;
|
|
transition: color 0.12s, border-color 0.12s;
|
|
}
|
|
|
|
.mobile-tab:hover {
|
|
color: #8a8a96;
|
|
}
|
|
|
|
.mobile-tab-active {
|
|
color: var(--color-accent, #4f46e5);
|
|
border-bottom-color: var(--color-accent, #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%;
|
|
}
|
|
|
|
/* ================================================================= */
|
|
/* 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;
|
|
}
|
|
|
|
.workspace-empty-tool {
|
|
padding: 6px 10px;
|
|
font-size: 12px;
|
|
}
|
|
}
|
|
</style>
|