From 4eef7d79bb1d42bb2536fd2a2d7654872a04bac6 Mon Sep 17 00:00:00 2001 From: vegard Date: Wed, 18 Mar 2026 07:52:52 +0000 Subject: [PATCH] Implementer personlig arbeidsflate (oppgave 19.6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brukerens standard arbeidsflate med node_kind='workspace'. Vises på /workspace når ikke koblet til en samling. Backend: - GET /my/workspace: finn eller opprett brukerens workspace-node - Automatisk provisjonering ved første besøk (STDB + async PG) - Owner-edge fra bruker til workspace Frontend: - /workspace rute med Canvas + BlockShell (gjenbruker spatial canvas) - Fritt valg av verktøy-paneler fra verktøymeny - Layout persisteres i workspace-nodens metadata via updateNode - Tom-tilstand med verktøy-velger for nye brukere - Responsivt: stacked tabs på mobil, spatial canvas på desktop - Kontekst-velger i header for navigering til samlinger Navigasjon: - "Min flate"-knapp på mottak-siden - "Min arbeidsflate" i ContextHeader dropdown for samlingssider Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/lib/api.ts | 23 + .../src/lib/components/ContextHeader.svelte | 8 + frontend/src/routes/+page.svelte | 9 +- frontend/src/routes/workspace/+page.svelte | 1048 +++++++++++++++++ maskinrommet/src/main.rs | 2 + maskinrommet/src/workspace.rs | 182 +++ 6 files changed, 1271 insertions(+), 1 deletion(-) create mode 100644 frontend/src/routes/workspace/+page.svelte create mode 100644 maskinrommet/src/workspace.rs diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index ad45bed..2daeda5 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1240,6 +1240,29 @@ export function createAiPreset( return post(accessToken, '/intentions/create_ai_preset', data); } +// ============================================================================= +// Personlig arbeidsflate (oppgave 19.6) +// ============================================================================= + +export interface WorkspaceResponse { + node_id: string; + title: string; + metadata: Record; + created: boolean; +} + +/** Hent (eller opprett) brukerens personlige workspace. */ +export async function fetchMyWorkspace(accessToken: string): Promise { + const res = await fetch(`${BASE_URL}/my/workspace`, { + headers: { Authorization: `Bearer ${accessToken}` } + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`workspace failed (${res.status}): ${body}`); + } + return res.json(); +} + /** Hent ressursforbruk for en spesifikk node (kun eier). */ export async function fetchNodeUsage( accessToken: string, diff --git a/frontend/src/lib/components/ContextHeader.svelte b/frontend/src/lib/components/ContextHeader.svelte index e457e41..cc23c16 100644 --- a/frontend/src/lib/components/ContextHeader.svelte +++ b/frontend/src/lib/components/ContextHeader.svelte @@ -195,6 +195,14 @@ />
+ {#if !searchQuery.trim()} + + {/if} {#each filteredCollections as node (node.id)} + + {#if selectorOpen} +
+ +
+ {#each filteredCollections as node (node.id)} + + {:else} +
+ {searchQuery ? 'Ingen treff' : 'Ingen samlinger'} +
+ {/each} +
+
+ {/if} +
+ + +
+
+ + + {#if toolMenuOpen} +
+
Legg til panel
+ {#each availableTools as tool (tool.key)} + + {/each} +
+ {/if} +
+ + {#if connected} + + {:else} + + {/if} +
+ + + + + {#if workspaceLoading} +
+

Laster arbeidsflate...

+
+ {:else if workspaceError} +
+

Kunne ikke laste arbeidsflate

+

{workspaceError}

+
+ {:else if !connected} +
+

Venter på tilkobling...

+
+ {:else if layout.panels.length === 0} +
+
+

Din personlige arbeidsflate

+

+ Legg til verktøy-paneler for å bygge opp arbeidsflaten din. + Arrangementet huskes mellom besøk. +

+
+ {#each Object.entries(TRAIT_PANEL_INFO) as [key, info] (key)} + + {/each} +
+
+
+ {:else if isMobile} + +
+ {#each layout.panels as panel, i (panel.trait)} + + {/each} +
+ +
+ {#each layout.panels as panel, i (panel.trait)} + {#if activeTab === i} +
+ {#if knownTraits.has(panel.trait)} + {#if panel.trait === 'editor'} + + {:else if panel.trait === 'chat'} + + {:else if panel.trait === 'kanban'} + + {:else if panel.trait === 'calendar'} + + {:else if panel.trait === 'podcast'} + + {:else if panel.trait === 'publishing'} + + {:else if panel.trait === 'rss'} + + {:else if panel.trait === 'recording'} + + {:else if panel.trait === 'transcription'} + + {:else if panel.trait === 'studio'} + + {:else if panel.trait === 'mixer'} + + {/if} + {:else} + + {/if} +
+ {/if} + {/each} +
+ {:else} + +
+ + {#snippet renderObject(obj)} + {@const trait = obj.id} + {@const info = getPanelInfo(trait)} + {@const panel = layout.panels.find(p => p.trait === trait)} + handlePanelResize(trait, w, h)} + onClose={() => handlePanelClose(trait)} + onMinimizeChange={(m) => handlePanelMinimize(trait, m)} + > + {#if knownTraits.has(trait)} + {#if trait === 'editor'} + + {:else if trait === 'chat'} + + {:else if trait === 'kanban'} + + {:else if trait === 'calendar'} + + {:else if trait === 'podcast'} + + {:else if trait === 'publishing'} + + {:else if trait === 'rss'} + + {:else if trait === 'recording'} + + {:else if trait === 'transcription'} + + {:else if trait === 'studio'} + + {:else if trait === 'mixer'} + + {/if} + {:else} + + {/if} + + {/snippet} + +
+ {/if} + + + {#if connected && accessToken} + + {/if} + + + diff --git a/maskinrommet/src/main.rs b/maskinrommet/src/main.rs index 9782189..cafe07d 100644 --- a/maskinrommet/src/main.rs +++ b/maskinrommet/src/main.rs @@ -27,6 +27,7 @@ pub mod tts; pub mod usage_overview; pub mod user_usage; mod warmup; +mod workspace; use axum::{extract::State, http::StatusCode, routing::{get, post}, Json, Router}; use serde::Serialize; @@ -246,6 +247,7 @@ async fn main() { .route("/admin/usage", get(usage_overview::usage_overview)) // Brukersynlig forbruk (oppgave 15.9) .route("/my/usage", get(user_usage::my_usage)) + .route("/my/workspace", get(workspace::my_workspace)) .route("/query/node_usage", get(user_usage::node_usage)) // Serverhelse-dashboard (oppgave 15.6) .route("/admin/health", get(health::health_dashboard)) diff --git a/maskinrommet/src/workspace.rs b/maskinrommet/src/workspace.rs new file mode 100644 index 0000000..86fad47 --- /dev/null +++ b/maskinrommet/src/workspace.rs @@ -0,0 +1,182 @@ +// Personlig arbeidsflate — oppgave 19.6 +// +// GET /my/workspace — finn eller opprett brukerens personlige workspace-node. +// +// Hvert bruker-node har én workspace-node (node_kind='workspace') koblet via +// en owner-edge. Ved første kall opprettes noden automatisk. +// +// Ref: docs/retninger/arbeidsflaten.md § "Tre lag" + +use axum::extract::State; +use axum::http::StatusCode; +use axum::Json; +use serde::Serialize; +use uuid::Uuid; + +use crate::auth::AuthUser; +use crate::AppState; + +#[derive(Serialize)] +pub struct WorkspaceResponse { + pub node_id: Uuid, + pub title: String, + pub metadata: serde_json::Value, + pub created: bool, +} + +#[derive(sqlx::FromRow)] +struct WorkspaceRow { + id: Uuid, + title: String, + metadata: String, +} + +/// GET /my/workspace — finn eller opprett brukerens personlige workspace. +pub async fn my_workspace( + State(state): State, + user: AuthUser, +) -> Result, (StatusCode, String)> { + // Søk etter eksisterende workspace-node der brukeren er eier + let existing = sqlx::query_as::<_, WorkspaceRow>( + r#" + SELECT n.id, n.title, n.metadata + FROM nodes n + INNER JOIN edges e ON e.target_id = n.id + WHERE e.source_id = $1 + AND e.edge_type = 'owner' + AND n.node_kind = 'workspace' + LIMIT 1 + "#, + ) + .bind(user.node_id) + .fetch_optional(&state.db) + .await + .map_err(|e| { + tracing::error!("PG-feil ved workspace-oppslag: {e}"); + (StatusCode::INTERNAL_SERVER_ERROR, "Databasefeil".to_string()) + })?; + + if let Some(row) = existing { + let metadata: serde_json::Value = + serde_json::from_str(&row.metadata).unwrap_or(serde_json::json!({})); + return Ok(Json(WorkspaceResponse { + node_id: row.id, + title: row.title, + metadata, + created: false, + })); + } + + // Ingen workspace funnet — opprett ny + let node_id = Uuid::now_v7(); + let title = "Min arbeidsflate".to_string(); + let metadata = serde_json::json!({ + "workspace_layout": { + "panels": [] + } + }); + let metadata_str = metadata.to_string(); + let node_id_str = node_id.to_string(); + let created_by_str = user.node_id.to_string(); + + // Skriv til SpacetimeDB (instant) + state + .stdb + .create_node( + &node_id_str, + "workspace", + &title, + "", + "hidden", + &metadata_str, + &created_by_str, + ) + .await + .map_err(|e| { + tracing::error!("STDB-feil ved workspace-opprettelse: {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "SpacetimeDB-feil".to_string(), + ) + })?; + + // Async PG-skriving (for persistens) + let db = state.db.clone(); + let title_clone = title.clone(); + let metadata_str_clone = metadata_str.clone(); + tokio::spawn(async move { + if let Err(e) = sqlx::query( + r#" + INSERT INTO nodes (id, node_kind, title, content, visibility, metadata, created_by) + VALUES ($1, 'workspace', $2, '', 'hidden', $3, $4) + ON CONFLICT (id) DO NOTHING + "#, + ) + .bind(node_id) + .bind(&title_clone) + .bind(&metadata_str_clone) + .bind(user.node_id) + .execute(&db) + .await + { + tracing::error!("PG-feil ved workspace-skriving: {e}"); + } + }); + + // Opprett owner-edge + let edge_id = Uuid::now_v7(); + let edge_id_str = edge_id.to_string(); + + state + .stdb + .create_edge( + &edge_id_str, + &created_by_str, + &node_id_str, + "owner", + "{}", + false, + &created_by_str, + ) + .await + .map_err(|e| { + tracing::error!("STDB-feil ved workspace owner-edge: {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "SpacetimeDB-feil".to_string(), + ) + })?; + + // Async PG edge-skriving + let db2 = state.db.clone(); + tokio::spawn(async move { + if let Err(e) = sqlx::query( + r#" + INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by) + VALUES ($1, $2, $3, 'owner', '{}', false, $2) + ON CONFLICT (id) DO NOTHING + "#, + ) + .bind(edge_id) + .bind(user.node_id) + .bind(node_id) + .execute(&db2) + .await + { + tracing::error!("PG-feil ved workspace owner-edge skriving: {e}"); + } + }); + + tracing::info!( + node_id = %node_id, + user_id = %user.node_id, + "Personlig workspace opprettet" + ); + + Ok(Json(WorkspaceResponse { + node_id, + title, + metadata, + created: true, + })) +}