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 <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-15 03:03:41 +01:00
parent 7397c8ad93
commit 7e83292abe
8 changed files with 327 additions and 15 deletions

21
migrations/0004_notes.sql Normal file
View file

@ -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';

View file

@ -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"}}
]
},
{

View file

@ -1,25 +1,164 @@
<script lang="ts">
import { onMount } from 'svelte';
import { createNote } from '$lib/notes/create.svelte';
import type { NoteConnection } from '$lib/notes/types';
let { props = {} }: { props?: Record<string, unknown> } = $props();
const noteId = props.noteId as string | undefined;
let conn = $state<NoteConnection | null>(null);
let title = $state('');
let content = $state('');
let initialized = $state(false);
// Synk fra server ved første lasting
$effect(() => {
if (conn?.note && !initialized) {
title = conn.note.title;
content = conn.note.content;
initialized = true;
}
});
function handleTitleInput() {
conn?.save({ title });
}
function handleContentInput() {
conn?.save({ content });
}
let updatedAt = $derived.by(() => {
if (!conn?.note?.updated_at) return '';
const d = new Date(conn.note.updated_at);
return d.toLocaleString('nb-NO', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit'
});
});
onMount(() => {
if (noteId) {
conn = createNote(noteId);
}
return () => conn?.destroy();
});
</script>
<div class="placeholder">
<span class="icon">📝</span>
<p class="label">Notes</p>
<p class="hint">Kommer snart</p>
</div>
{#if !noteId}
<div class="no-note"><p>Ingen notat konfigurert for denne blokken.</p></div>
{:else if conn?.loading}
<div class="no-note"><p>Laster...</p></div>
{:else}
<div class="notes-wrapper">
<input
type="text"
class="note-title"
placeholder="Tittel..."
bind:value={title}
oninput={handleTitleInput}
/>
<textarea
class="note-content"
placeholder="Skriv her..."
bind:value={content}
oninput={handleContentInput}
></textarea>
<div class="note-footer">
{#if conn?.saving}
<span class="status saving">Lagrer...</span>
{:else if updatedAt}
<span class="status saved">Lagret {updatedAt}</span>
{/if}
</div>
</div>
{#if conn?.error}
<div class="error">{conn.error}</div>
{/if}
{/if}
<style>
.placeholder {
.notes-wrapper {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
gap: 0.5rem;
}
.note-title {
background: transparent;
border: none;
border-bottom: 1px solid #2d3148;
color: #e1e4e8;
font-size: 1rem;
font-weight: 600;
font-family: inherit;
padding: 0.25rem 0;
}
.note-title:focus {
outline: none;
border-bottom-color: #3b82f6;
}
.note-title::placeholder {
color: #8b92a5;
}
.note-content {
flex: 1;
background: transparent;
border: none;
color: #e1e4e8;
font-size: 0.85rem;
font-family: inherit;
line-height: 1.6;
resize: none;
padding: 0;
min-height: 100px;
}
.note-content:focus {
outline: none;
}
.note-content::placeholder {
color: #8b92a5;
}
.note-footer {
display: flex;
justify-content: flex-end;
padding-top: 0.25rem;
border-top: 1px solid #2d3148;
}
.status {
font-size: 0.7rem;
color: #8b92a5;
}
.status.saving {
color: #f59e0b;
}
.no-note {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: 200px;
color: #8b92a5;
gap: 0.5rem;
font-size: 0.85rem;
}
.error {
font-size: 0.75rem;
color: #f87171;
padding: 0.25rem 0;
}
.icon { font-size: 2rem; }
.label { font-weight: 600; color: #e1e4e8; }
.hint { font-size: 0.8rem; }
</style>

View file

@ -0,0 +1,6 @@
import type { NoteConnection } from './types';
import { createPgNote } from './pg.svelte';
export function createNote(noteId: string): NoteConnection {
return createPgNote(noteId);
}

View file

@ -0,0 +1,2 @@
export type { Note, NoteConnection } from './types';
export { createNote } from './create.svelte';

View file

@ -0,0 +1,73 @@
import type { Note, NoteConnection } from './types';
export function createPgNote(noteId: string): NoteConnection {
let _note = $state<Note | null>(null);
let _error = $state('');
let _loading = $state(true);
let _saving = $state(false);
let _saveTimeout: ReturnType<typeof setTimeout> | null = null;
let _interval: ReturnType<typeof setInterval> | 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);
}
};
}

View file

@ -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<void>;
destroy(): void;
}

View file

@ -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);
};