From bea9ed4344c342fa5ba710d19e2cb554d9e183eb Mon Sep 17 00:00:00 2001 From: soryu Date: Mon, 27 Apr 2026 17:27:41 +0100 Subject: WIP: heartbeat checkpoint --- frontend/src/components/ConfigModal.tsx | 35 +++- frontend/src/components/VNInterface.tsx | 38 +++- .../src/components/document/DocumentLayout.css | 20 +++ .../src/components/document/DocumentSettings.tsx | 76 ++++++++ frontend/src/components/document/index.ts | 4 + frontend/src/main.tsx | 9 + frontend/src/stores/index.ts | 23 +++ frontend/tsconfig.tsbuildinfo | 2 +- .../20260427000000_create_user_settings.sql | 11 ++ makima/src/db/models.rs | 27 +++ makima/src/db/repository.rs | 82 +++++++++ makima/src/server/handlers/mod.rs | 1 + makima/src/server/handlers/settings.rs | 196 +++++++++++++++++++++ makima/src/server/mod.rs | 11 +- 14 files changed, 529 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/document/DocumentSettings.tsx create mode 100644 frontend/src/components/document/index.ts create mode 100644 makima/migrations/20260427000000_create_user_settings.sql create mode 100644 makima/src/server/handlers/settings.rs diff --git a/frontend/src/components/ConfigModal.tsx b/frontend/src/components/ConfigModal.tsx index e7b1f9f..9746e4e 100644 --- a/frontend/src/components/ConfigModal.tsx +++ b/frontend/src/components/ConfigModal.tsx @@ -1,4 +1,7 @@ import React from 'react' +import { useStore } from '@nanostores/react' +import { Link } from 'react-router-dom' +import { documentUiEnabledStore, setDocumentUiEnabled } from '../stores' type Props = { isOpen: boolean @@ -8,6 +11,8 @@ type Props = { } export const ConfigModal: React.FC = ({ isOpen, onClose, skipIntro, onSkipIntroChange }) => { + const documentUiEnabled = useStore(documentUiEnabledStore) + if (!isOpen) return null return ( @@ -15,9 +20,9 @@ export const ConfigModal: React.FC = ({ isOpen, onClose, skipIntro, onSki
e.stopPropagation()}>

Configuration

- +
- +
+ +
+ +
+ Replace the directive management interface with an interactive document editor. This is a proof of concept. +
+ {documentUiEnabled && ( + + Open Directives Editor {'\u2192'} + + )} +
- +
diff --git a/frontend/src/components/VNInterface.tsx b/frontend/src/components/VNInterface.tsx index 318a9b9..051c210 100644 --- a/frontend/src/components/VNInterface.tsx +++ b/frontend/src/components/VNInterface.tsx @@ -9,9 +9,11 @@ import { showSettingsModalStore, isVisibleStore, yenBalanceStore, + documentUiEnabledStore, toggleStandby, toggleShowChoices, - updateTime + updateTime, + setDocumentUiEnabled, } from '../stores' interface VNInterfaceProps { @@ -26,6 +28,7 @@ export function VNInterface({ onLogout }: VNInterfaceProps) { const showSettingsModal = useStore(showSettingsModalStore) const isVisible = useStore(isVisibleStore) const yenBalance = useStore(yenBalanceStore) + const documentUiEnabled = useStore(documentUiEnabledStore) // Fade in effect on mount useEffect(() => { @@ -113,6 +116,14 @@ export function VNInterface({ onLogout }: VNInterfaceProps) { Contracts + {documentUiEnabled && ( +
+ + Docs: + Directives + +
+ )} @@ -197,6 +208,31 @@ export function VNInterface({ onLogout }: VNInterfaceProps) { +
+

Experimental

+
+ +

+ Replace the directive management interface with an interactive document editor. This is a proof of concept. +

+ {documentUiEnabled && ( + showSettingsModalStore.set(false)} + > + Open Directives Editor {'\u2192'} + + )} +
+

Audio

diff --git a/frontend/src/components/document/DocumentLayout.css b/frontend/src/components/document/DocumentLayout.css index 5a6d8d5..b18bb81 100644 --- a/frontend/src/components/document/DocumentLayout.css +++ b/frontend/src/components/document/DocumentLayout.css @@ -18,6 +18,26 @@ border-right: 1px solid #2a2a4a; } +/* Back link */ +.document-sidebar-back { + padding: 8px 12px; + border-bottom: 1px solid #2a2a4a; +} + +.document-back-link { + color: #9ca3af; + text-decoration: none; + font-size: 0.85rem; + display: flex; + align-items: center; + gap: 4px; + transition: color 0.15s; +} + +.document-back-link:hover { + color: #e0e0e0; +} + /* Resize handle */ .document-resize-handle { width: 4px; diff --git a/frontend/src/components/document/DocumentSettings.tsx b/frontend/src/components/document/DocumentSettings.tsx new file mode 100644 index 0000000..b575b3d --- /dev/null +++ b/frontend/src/components/document/DocumentSettings.tsx @@ -0,0 +1,76 @@ +import { useState, useCallback } from 'react' +import { upsertUserSetting } from '../../services/directiveApi' + +interface DocumentSettingsProps { + isOpen: boolean + onClose: () => void + enabled: boolean + onToggle: (enabled: boolean) => void +} + +export default function DocumentSettings({ + isOpen, + onClose, + enabled, + onToggle, +}: DocumentSettingsProps) { + const [saving, setSaving] = useState(false) + + const handleToggle = useCallback(async () => { + const newValue = !enabled + setSaving(true) + try { + // Update localStorage immediately for instant UI response + localStorage.setItem('document_ui_enabled', JSON.stringify(newValue)) + onToggle(newValue) + + // Persist to backend + await upsertUserSetting('document_ui_enabled', newValue) + } catch (err) { + console.error('Failed to save document UI setting:', err) + // Revert on failure + localStorage.setItem('document_ui_enabled', JSON.stringify(!newValue)) + onToggle(!newValue) + } finally { + setSaving(false) + } + }, [enabled, onToggle]) + + if (!isOpen) return null + + return ( +
+
e.stopPropagation()}> +
+

Document UI Settings

+ +
+ +
+
+ +
+ Replace the directive management interface with an interactive + document editor. This is a proof of concept. +
+
+
+ +
+ +
+
+
+ ) +} diff --git a/frontend/src/components/document/index.ts b/frontend/src/components/document/index.ts new file mode 100644 index 0000000..af9e362 --- /dev/null +++ b/frontend/src/components/document/index.ts @@ -0,0 +1,4 @@ +export { default as DocumentLayout } from './DocumentLayout' +export { default as DocumentEditor } from './DocumentEditor' +export { DirectiveFileTree } from './DirectiveFileTree' +export { default as DocumentSettings } from './DocumentSettings' diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 04b8cde..3987f30 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -7,6 +7,7 @@ import { ContractDetail } from './components/ContractDetail' import { FileDetail } from './components/FileDetail' import { DaemonList } from './components/DaemonList' import { DaemonDetail } from './components/DaemonDetail' +import { DocumentLayout } from './components/document' import './styles/pc98.css' import './styles/mobile.css' @@ -43,6 +44,14 @@ const router = createBrowserRouter([ path: '/daemons/:id', element: , }, + { + path: '/directives', + element: , + }, + { + path: '/directives/:id', + element: , + }, ]) ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/frontend/src/stores/index.ts b/frontend/src/stores/index.ts index 58f461c..5ee9b08 100644 --- a/frontend/src/stores/index.ts +++ b/frontend/src/stores/index.ts @@ -36,6 +36,29 @@ export const skipIntroStore = atom( })() ) +// Document UI feature flag +export const documentUiEnabledStore = atom( + (() => { + try { + const saved = localStorage.getItem('document_ui_enabled') + return saved === 'true' + } catch { + return false + } + })() +) + +export const setDocumentUiEnabled = (enabled: boolean) => { + documentUiEnabledStore.set(enabled) + localStorage.setItem('document_ui_enabled', JSON.stringify(enabled)) + // Persist to backend (fire-and-forget) + fetch('/api/v1/settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key: 'document_ui_enabled', value: enabled }), + }).catch((err) => console.error('Failed to persist document_ui_enabled:', err)) +} + // Actions export const login = () => { isLoggedInStore.set(true) diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo index 9a49e49..83d0161 100644 --- a/frontend/tsconfig.tsbuildinfo +++ b/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/types.ts","./src/components/bottombar.tsx","./src/components/choicemenu.tsx","./src/components/cityscapebackground.tsx","./src/components/configmodal.tsx","./src/components/contractdetail.tsx","./src/components/contractlist.tsx","./src/components/daemondetail.tsx","./src/components/daemonlist.tsx","./src/components/dialoguebox.tsx","./src/components/filedetail.tsx","./src/components/heartlogo.tsx","./src/components/landingpage.tsx","./src/components/loadingscreen.tsx","./src/components/origamidragonlogo.tsx","./src/components/topbar.tsx","./src/components/vnapp.tsx","./src/components/vninterface.tsx","./src/components/vnviewport.tsx","./src/components/document/autosaveplugin.tsx","./src/components/document/contextmenu.tsx","./src/components/document/documenteditor.tsx","./src/components/document/documentlayout.tsx","./src/components/document/editortheme.ts","./src/components/document/toast.tsx","./src/services/directiveapi.ts","./src/services/ws.ts","./src/stores/index.ts"],"version":"5.9.2"} \ No newline at end of file +{"root":["./src/app.tsx","./src/main.tsx","./src/types.ts","./src/components/bottombar.tsx","./src/components/choicemenu.tsx","./src/components/cityscapebackground.tsx","./src/components/configmodal.tsx","./src/components/contractdetail.tsx","./src/components/contractlist.tsx","./src/components/daemondetail.tsx","./src/components/daemonlist.tsx","./src/components/dialoguebox.tsx","./src/components/filedetail.tsx","./src/components/heartlogo.tsx","./src/components/landingpage.tsx","./src/components/loadingscreen.tsx","./src/components/origamidragonlogo.tsx","./src/components/topbar.tsx","./src/components/vnapp.tsx","./src/components/vninterface.tsx","./src/components/vnviewport.tsx","./src/components/document/autosaveplugin.tsx","./src/components/document/contextmenu.tsx","./src/components/document/directivefiletree.tsx","./src/components/document/documenteditor.tsx","./src/components/document/documentlayout.tsx","./src/components/document/documentsettings.tsx","./src/components/document/editortheme.ts","./src/components/document/toast.tsx","./src/components/document/index.ts","./src/components/document/nodes/stepsdiagramcomponent.tsx","./src/components/document/nodes/stepsdiagramnode.tsx","./src/services/directiveapi.ts","./src/services/ws.ts","./src/stores/index.ts"],"version":"5.9.2"} \ No newline at end of file diff --git a/makima/migrations/20260427000000_create_user_settings.sql b/makima/migrations/20260427000000_create_user_settings.sql new file mode 100644 index 0000000..60acbcc --- /dev/null +++ b/makima/migrations/20260427000000_create_user_settings.sql @@ -0,0 +1,11 @@ +-- Create user_settings table for per-user feature flags and preferences +CREATE TABLE IF NOT EXISTS user_settings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + owner_id UUID NOT NULL, + key TEXT NOT NULL, + value JSONB NOT NULL DEFAULT '"false"'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(owner_id, key) +); +CREATE INDEX idx_user_settings_owner ON user_settings(owner_id); diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs index 97657dc..c03b4ac 100644 --- a/makima/src/db/models.rs +++ b/makima/src/db/models.rs @@ -3050,4 +3050,31 @@ pub struct DirectiveOrderGroupListResponse { pub total: i64, } +/// User setting record from the database (key-value per owner). +#[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct UserSetting { + pub id: Uuid, + pub owner_id: Uuid, + pub key: String, + pub value: serde_json::Value, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// Request body for upserting a user setting. +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct UpsertUserSettingRequest { + pub key: String, + pub value: serde_json::Value, +} + +/// Response containing a list of user settings. +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct UserSettingsResponse { + pub settings: Vec, +} + diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs index 57e8a78..401da94 100644 --- a/makima/src/db/repository.rs +++ b/makima/src/db/repository.rs @@ -21,6 +21,7 @@ use super::models::{ PhaseDefinition, SupervisorHeartbeatRecord, SupervisorState, Task, TaskCheckpoint, TaskEvent, TaskSummary, UpdateContractRequest, UpdateFileRequest, UpdateTaskRequest, UpdateTemplateRequest, + UserSetting, }; /// Repository error types. @@ -6698,3 +6699,84 @@ pub async fn get_available_orders_for_dog_pickup( .await } +// ─── User Settings ─────────────────────────────────────────────────────────── + +/// Get all settings for a given owner. +pub async fn get_user_settings( + pool: &PgPool, + owner_id: Uuid, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, UserSetting>( + r#" + SELECT id, owner_id, key, value, created_at, updated_at + FROM user_settings + WHERE owner_id = $1 + ORDER BY key ASC + "#, + ) + .bind(owner_id) + .fetch_all(pool) + .await +} + +/// Get a single setting by owner and key. +pub async fn get_user_setting( + pool: &PgPool, + owner_id: Uuid, + key: &str, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, UserSetting>( + r#" + SELECT id, owner_id, key, value, created_at, updated_at + FROM user_settings + WHERE owner_id = $1 AND key = $2 + "#, + ) + .bind(owner_id) + .bind(key) + .fetch_optional(pool) + .await +} + +/// Upsert a user setting (insert or update on conflict). +pub async fn upsert_user_setting( + pool: &PgPool, + owner_id: Uuid, + key: &str, + value: &serde_json::Value, +) -> Result { + sqlx::query_as::<_, UserSetting>( + r#" + INSERT INTO user_settings (owner_id, key, value) + VALUES ($1, $2, $3) + ON CONFLICT (owner_id, key) DO UPDATE + SET value = EXCLUDED.value, updated_at = now() + RETURNING id, owner_id, key, value, created_at, updated_at + "#, + ) + .bind(owner_id) + .bind(key) + .bind(value) + .fetch_one(pool) + .await +} + +/// Delete a user setting. Returns true if a row was deleted. +pub async fn delete_user_setting( + pool: &PgPool, + owner_id: Uuid, + key: &str, +) -> Result { + let result = sqlx::query( + r#" + DELETE FROM user_settings + WHERE owner_id = $1 AND key = $2 + "#, + ) + .bind(owner_id) + .bind(key) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) +} + diff --git a/makima/src/server/handlers/mod.rs b/makima/src/server/handlers/mod.rs index 4bdb424..b3c433b 100644 --- a/makima/src/server/handlers/mod.rs +++ b/makima/src/server/handlers/mod.rs @@ -20,6 +20,7 @@ pub mod mesh_merge; pub mod mesh_supervisor; pub mod mesh_ws; pub mod repository_history; +pub mod settings; pub mod speak; pub mod templates; pub mod voice; diff --git a/makima/src/server/handlers/settings.rs b/makima/src/server/handlers/settings.rs new file mode 100644 index 0000000..ae52d5a --- /dev/null +++ b/makima/src/server/handlers/settings.rs @@ -0,0 +1,196 @@ +//! HTTP handlers for user settings (feature flags / preferences). + +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; + +use crate::db::models::{UpsertUserSettingRequest, UserSettingsResponse}; +use crate::db::repository; +use crate::server::auth::Authenticated; +use crate::server::messages::ApiError; +use crate::server::state::SharedState; + +/// List all settings for the authenticated user. +#[utoipa::path( + get, + path = "/api/v1/settings", + responses( + (status = 200, description = "User settings", body = UserSettingsResponse), + (status = 401, description = "Not authenticated", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError), + ), + security( + ("bearer_auth" = []) + ), + tag = "Settings" +)] +pub async fn list_settings( + State(state): State, + Authenticated(user): Authenticated, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::get_user_settings(pool, user.owner_id).await { + Ok(settings) => Json(UserSettingsResponse { settings }).into_response(), + Err(e) => { + tracing::error!("Failed to list user settings: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Get a specific setting by key. +#[utoipa::path( + get, + path = "/api/v1/settings/{key}", + params( + ("key" = String, Path, description = "Setting key") + ), + responses( + (status = 200, description = "User setting", body = crate::db::models::UserSetting), + (status = 401, description = "Not authenticated", body = ApiError), + (status = 404, description = "Setting not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError), + ), + security( + ("bearer_auth" = []) + ), + tag = "Settings" +)] +pub async fn get_setting( + State(state): State, + Authenticated(user): Authenticated, + Path(key): Path, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::get_user_setting(pool, user.owner_id, &key).await { + Ok(Some(setting)) => Json(setting).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", format!("Setting '{}' not found", key))), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to get user setting: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Upsert a user setting (create or update). +#[utoipa::path( + put, + path = "/api/v1/settings", + request_body = UpsertUserSettingRequest, + responses( + (status = 200, description = "Setting upserted", body = crate::db::models::UserSetting), + (status = 401, description = "Not authenticated", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError), + ), + security( + ("bearer_auth" = []) + ), + tag = "Settings" +)] +pub async fn upsert_setting( + State(state): State, + Authenticated(user): Authenticated, + Json(req): Json, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::upsert_user_setting(pool, user.owner_id, &req.key, &req.value).await { + Ok(setting) => Json(setting).into_response(), + Err(e) => { + tracing::error!("Failed to upsert user setting: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Delete a user setting by key. +#[utoipa::path( + delete, + path = "/api/v1/settings/{key}", + params( + ("key" = String, Path, description = "Setting key") + ), + responses( + (status = 200, description = "Setting deleted"), + (status = 401, description = "Not authenticated", body = ApiError), + (status = 404, description = "Setting not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError), + ), + security( + ("bearer_auth" = []) + ), + tag = "Settings" +)] +pub async fn delete_setting( + State(state): State, + Authenticated(user): Authenticated, + Path(key): Path, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::delete_user_setting(pool, user.owner_id, &key).await { + Ok(true) => StatusCode::OK.into_response(), + Ok(false) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", format!("Setting '{}' not found", key))), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to delete user setting: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index b382f04..025ec85 100644 --- a/makima/src/server/mod.rs +++ b/makima/src/server/mod.rs @@ -18,7 +18,7 @@ use tower_http::trace::TraceLayer; use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; -use crate::server::handlers::{api_keys, chat, contract_chat, contract_daemon, contract_discuss, contracts, daemon_download, directives, file_ws, files, history, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, orders, repository_history, speak, templates, transcript_analysis, users, versions}; +use crate::server::handlers::{api_keys, chat, contract_chat, contract_daemon, contract_discuss, contracts, daemon_download, directives, file_ws, files, history, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, orders, repository_history, settings, speak, templates, transcript_analysis, users, versions}; use crate::server::openapi::ApiDoc; use crate::server::state::SharedState; @@ -281,6 +281,15 @@ pub fn make_router(state: SharedState) -> Router { .route("/timeline", get(history::get_timeline)) // Contract type templates (built-in only) .route("/contract-types", get(templates::list_contract_types)) + // User settings (feature flags) endpoints + .route( + "/user-settings", + get(settings::list_settings).put(settings::upsert_setting), + ) + .route( + "/user-settings/{key}", + get(settings::get_setting).delete(settings::delete_setting), + ) // Settings endpoints .route( "/settings/repository-history", -- cgit v1.2.3