Implementer personlig arbeidsflate (oppgave 19.6)
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) <noreply@anthropic.com>
This commit is contained in:
parent
1a2e122edf
commit
4eef7d79bb
6 changed files with 1271 additions and 1 deletions
|
|
@ -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<string, unknown>;
|
||||
created: boolean;
|
||||
}
|
||||
|
||||
/** Hent (eller opprett) brukerens personlige workspace. */
|
||||
export async function fetchMyWorkspace(accessToken: string): Promise<WorkspaceResponse> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -195,6 +195,14 @@
|
|||
/>
|
||||
</div>
|
||||
<div class="context-selector-list">
|
||||
{#if !searchQuery.trim()}
|
||||
<button
|
||||
class="context-selector-item"
|
||||
onclick={() => { selectorOpen = false; goto('/workspace'); }}
|
||||
>
|
||||
<span class="context-selector-item-title">Min arbeidsflate</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#each filteredCollections as node (node.id)}
|
||||
<button
|
||||
class="context-selector-item"
|
||||
|
|
|
|||
|
|
@ -92,7 +92,8 @@
|
|||
collection: 'Samling',
|
||||
communication: 'Samtale',
|
||||
topic: 'Tema',
|
||||
media: 'Media'
|
||||
media: 'Media',
|
||||
workspace: 'Arbeidsflate'
|
||||
};
|
||||
return labels[kind] ?? kind;
|
||||
}
|
||||
|
|
@ -328,6 +329,12 @@
|
|||
<h2 class="text-xl font-semibold text-gray-800">Mottak</h2>
|
||||
{#if connected && accessToken}
|
||||
<div class="flex gap-2">
|
||||
<a
|
||||
href="/workspace"
|
||||
class="rounded-lg bg-indigo-500 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-600"
|
||||
>
|
||||
Min flate
|
||||
</a>
|
||||
<a
|
||||
href="/diary"
|
||||
class="rounded-lg bg-violet-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-violet-700"
|
||||
|
|
|
|||
1048
frontend/src/routes/workspace/+page.svelte
Normal file
1048
frontend/src/routes/workspace/+page.svelte
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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))
|
||||
|
|
|
|||
182
maskinrommet/src/workspace.rs
Normal file
182
maskinrommet/src/workspace.rs
Normal file
|
|
@ -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<AppState>,
|
||||
user: AuthUser,
|
||||
) -> Result<Json<WorkspaceResponse>, (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,
|
||||
}))
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue