From 7e83292abebe026ec18b32d7500b5c53b31c6c5c Mon Sep 17 00:00:00 2001 From: vegard Date: Sun, 15 Mar 2026 03:03:41 +0100 Subject: [PATCH] Notater: auto-save scratchpad med debounce - Migrering 0004: notes-tabell (nodes i kunnskapsgrafen) - REST API: GET/PATCH notat - PG-adapter med 500ms debounce og 10s polling - NotesBlock: tittel + fritekst med auto-lagring og status - Seed: notater for begge workspaces, kalenderside med 2-1 layout Co-Authored-By: Claude Opus 4.6 --- migrations/0004_notes.sql | 21 +++ migrations/seed_dev.sql | 22 ++- web/src/lib/blocks/NotesBlock.svelte | 161 +++++++++++++++++-- web/src/lib/notes/create.svelte.ts | 6 + web/src/lib/notes/index.ts | 2 + web/src/lib/notes/pg.svelte.ts | 73 +++++++++ web/src/lib/notes/types.ts | 15 ++ web/src/routes/api/notes/[noteId]/+server.ts | 42 +++++ 8 files changed, 327 insertions(+), 15 deletions(-) create mode 100644 migrations/0004_notes.sql create mode 100644 web/src/lib/notes/create.svelte.ts create mode 100644 web/src/lib/notes/index.ts create mode 100644 web/src/lib/notes/pg.svelte.ts create mode 100644 web/src/lib/notes/types.ts create mode 100644 web/src/routes/api/notes/[noteId]/+server.ts diff --git a/migrations/0004_notes.sql b/migrations/0004_notes.sql new file mode 100644 index 0000000..1ec30e8 --- /dev/null +++ b/migrations/0004_notes.sql @@ -0,0 +1,21 @@ +-- Sidelinja: Notater/Scratchpad +-- Avhenger av: 0001_initial_schema.sql (nodes, workspaces) + +BEGIN; + +CREATE TABLE notes ( + id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE, + parent_id UUID NOT NULL REFERENCES nodes(id), + title TEXT NOT NULL DEFAULT '', + content TEXT NOT NULL DEFAULT '', + created_by TEXT REFERENCES users(authentik_id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TRIGGER trg_notes_updated_at BEFORE UPDATE ON notes + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +COMMIT; + +ALTER TYPE node_type ADD VALUE IF NOT EXISTS 'note'; diff --git a/migrations/seed_dev.sql b/migrations/seed_dev.sql index b84dfde..b2e217c 100644 --- a/migrations/seed_dev.sql +++ b/migrations/seed_dev.sql @@ -66,6 +66,12 @@ INSERT INTO nodes (id, workspace_id, node_type) VALUES INSERT INTO calendars (id, parent_id, name, color) VALUES ('b0000000-0000-0000-0000-000000000030', 'b0000000-0000-0000-0000-000000000010', 'Foreningskalender', '#f59e0b'); +-- Notat for Liberalistene +INSERT INTO nodes (id, workspace_id, node_type) VALUES + ('b0000000-0000-0000-0000-000000000040', 'b0000000-0000-0000-0000-000000000001', 'note'); +INSERT INTO notes (id, parent_id, title, content) VALUES + ('b0000000-0000-0000-0000-000000000040', 'b0000000-0000-0000-0000-000000000010', 'Møtenotater', ''); + -- Kanban-brett for Liberalistene INSERT INTO nodes (id, workspace_id, node_type) VALUES ('b0000000-0000-0000-0000-000000000020', 'b0000000-0000-0000-0000-000000000001', 'kanban_board'); @@ -95,9 +101,10 @@ UPDATE workspaces SET settings = jsonb_set( "slug": "kalender", "title": "Kalender", "icon": "📅", - "layout": "single", + "layout": "2-1", "blocks": [ - {"id": "cal-lib-1", "type": "calendar", "title": "Foreningskalender", "props": {"calendarId": "b0000000-0000-0000-0000-000000000030"}} + {"id": "cal-lib-1", "type": "calendar", "title": "Foreningskalender", "props": {"calendarId": "b0000000-0000-0000-0000-000000000030"}}, + {"id": "notes-lib-1", "type": "notes", "title": "Møtenotater", "props": {"noteId": "b0000000-0000-0000-0000-000000000040"}} ] }, { @@ -122,6 +129,12 @@ INSERT INTO nodes (id, workspace_id, node_type) VALUES INSERT INTO calendars (id, parent_id, name, color) VALUES ('a0000000-0000-0000-0000-000000000030', 'a0000000-0000-0000-0000-000000000010', 'Redaksjonskalender', '#3b82f6'); +-- Notat for Sidelinja +INSERT INTO nodes (id, workspace_id, node_type) VALUES + ('a0000000-0000-0000-0000-000000000040', 'a0000000-0000-0000-0000-000000000001', 'note'); +INSERT INTO notes (id, parent_id, title, content) VALUES + ('a0000000-0000-0000-0000-000000000040', 'a0000000-0000-0000-0000-000000000010', 'Show notes', ''); + -- Kanban-brett for redaksjonen INSERT INTO nodes (id, workspace_id, node_type) VALUES ('a0000000-0000-0000-0000-000000000020', 'a0000000-0000-0000-0000-000000000001', 'kanban_board'); @@ -152,9 +165,10 @@ UPDATE workspaces SET settings = jsonb_set( "slug": "kalender", "title": "Kalender", "icon": "📅", - "layout": "single", + "layout": "2-1", "blocks": [ - {"id": "cal-1", "type": "calendar", "title": "Redaksjonskalender", "props": {"calendarId": "a0000000-0000-0000-0000-000000000030"}} + {"id": "cal-1", "type": "calendar", "title": "Redaksjonskalender", "props": {"calendarId": "a0000000-0000-0000-0000-000000000030"}}, + {"id": "notes-1", "type": "notes", "title": "Show notes", "props": {"noteId": "a0000000-0000-0000-0000-000000000040"}} ] }, { diff --git a/web/src/lib/blocks/NotesBlock.svelte b/web/src/lib/blocks/NotesBlock.svelte index 008efa1..be31e98 100644 --- a/web/src/lib/blocks/NotesBlock.svelte +++ b/web/src/lib/blocks/NotesBlock.svelte @@ -1,25 +1,164 @@ -
- 📝 -

Notes

-

Kommer snart

-
+{#if !noteId} +

Ingen notat konfigurert for denne blokken.

+{:else if conn?.loading} +

Laster...

+{:else} +
+ + + +
+ + {#if conn?.error} +
{conn.error}
+ {/if} +{/if} diff --git a/web/src/lib/notes/create.svelte.ts b/web/src/lib/notes/create.svelte.ts new file mode 100644 index 0000000..3d08c99 --- /dev/null +++ b/web/src/lib/notes/create.svelte.ts @@ -0,0 +1,6 @@ +import type { NoteConnection } from './types'; +import { createPgNote } from './pg.svelte'; + +export function createNote(noteId: string): NoteConnection { + return createPgNote(noteId); +} diff --git a/web/src/lib/notes/index.ts b/web/src/lib/notes/index.ts new file mode 100644 index 0000000..7890a83 --- /dev/null +++ b/web/src/lib/notes/index.ts @@ -0,0 +1,2 @@ +export type { Note, NoteConnection } from './types'; +export { createNote } from './create.svelte'; diff --git a/web/src/lib/notes/pg.svelte.ts b/web/src/lib/notes/pg.svelte.ts new file mode 100644 index 0000000..2448850 --- /dev/null +++ b/web/src/lib/notes/pg.svelte.ts @@ -0,0 +1,73 @@ +import type { Note, NoteConnection } from './types'; + +export function createPgNote(noteId: string): NoteConnection { + let _note = $state(null); + let _error = $state(''); + let _loading = $state(true); + let _saving = $state(false); + let _saveTimeout: ReturnType | null = null; + let _interval: ReturnType | null = null; + + async function fetchNote() { + try { + const res = await fetch(`/api/notes/${noteId}`); + if (!res.ok) { + _error = `Feil: ${res.status}`; + return; + } + const data = await res.json(); + // Ikke overskriv lokale endringer mens bruker skriver + if (!_saving) { + _note = data; + } + _error = ''; + } catch (e) { + _error = e instanceof Error ? e.message : 'Ukjent feil'; + } finally { + _loading = false; + } + } + + fetchNote(); + _interval = setInterval(fetchNote, 10000); // Sjeldnere polling for notater + + return { + get note() { return _note; }, + get error() { return _error; }, + get loading() { return _loading; }, + get saving() { return _saving; }, + + async save(updates) { + _saving = true; + + // Debounce: vent 500ms etter siste endring + if (_saveTimeout) clearTimeout(_saveTimeout); + + _saveTimeout = setTimeout(async () => { + try { + const res = await fetch(`/api/notes/${noteId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updates) + }); + if (!res.ok) { + _error = `Lagring feilet: ${res.status}`; + } else { + const data = await res.json(); + _note = data; + _error = ''; + } + } catch (e) { + _error = e instanceof Error ? e.message : 'Lagring feilet'; + } finally { + _saving = false; + } + }, 500); + }, + + destroy() { + if (_interval) clearInterval(_interval); + if (_saveTimeout) clearTimeout(_saveTimeout); + } + }; +} diff --git a/web/src/lib/notes/types.ts b/web/src/lib/notes/types.ts new file mode 100644 index 0000000..9b82998 --- /dev/null +++ b/web/src/lib/notes/types.ts @@ -0,0 +1,15 @@ +export interface Note { + id: string; + title: string; + content: string; + updated_at: string; +} + +export interface NoteConnection { + readonly note: Note | null; + readonly error: string; + readonly loading: boolean; + readonly saving: boolean; + save(updates: { title?: string; content?: string }): Promise; + destroy(): void; +} diff --git a/web/src/routes/api/notes/[noteId]/+server.ts b/web/src/routes/api/notes/[noteId]/+server.ts new file mode 100644 index 0000000..f4e21c9 --- /dev/null +++ b/web/src/routes/api/notes/[noteId]/+server.ts @@ -0,0 +1,42 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { sql } from '$lib/server/db'; + +/** GET /api/notes/:noteId — Hent notat */ +export const GET: RequestHandler = async ({ params, locals }) => { + if (!locals.workspace || !locals.user) error(401); + + const [note] = await sql` + SELECT n.id, n.title, n.content, n.updated_at + FROM notes n + JOIN nodes nd ON nd.id = n.id + WHERE n.id = ${params.noteId} AND nd.workspace_id = ${locals.workspace.id} + `; + if (!note) error(404, 'Notat ikke funnet'); + + return json(note); +}; + +/** PATCH /api/notes/:noteId — Oppdater notat */ +export const PATCH: RequestHandler = async ({ params, request, locals }) => { + if (!locals.workspace || !locals.user) error(401); + + const updates = await request.json(); + + const [note] = await sql` + SELECT n.id FROM notes n + JOIN nodes nd ON nd.id = n.id + WHERE n.id = ${params.noteId} AND nd.workspace_id = ${locals.workspace.id} + `; + if (!note) error(404, 'Notat ikke funnet'); + + const [updated] = await sql` + UPDATE notes SET + title = COALESCE(${updates.title ?? null}, title), + content = CASE WHEN ${updates.content !== undefined} THEN ${updates.content ?? ''} ELSE content END + WHERE id = ${params.noteId} + RETURNING id, title, content, updated_at + `; + + return json(updated); +};