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:
parent
7397c8ad93
commit
7e83292abe
8 changed files with 327 additions and 15 deletions
21
migrations/0004_notes.sql
Normal file
21
migrations/0004_notes.sql
Normal 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';
|
||||
|
|
@ -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"}}
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
6
web/src/lib/notes/create.svelte.ts
Normal file
6
web/src/lib/notes/create.svelte.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import type { NoteConnection } from './types';
|
||||
import { createPgNote } from './pg.svelte';
|
||||
|
||||
export function createNote(noteId: string): NoteConnection {
|
||||
return createPgNote(noteId);
|
||||
}
|
||||
2
web/src/lib/notes/index.ts
Normal file
2
web/src/lib/notes/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export type { Note, NoteConnection } from './types';
|
||||
export { createNote } from './create.svelte';
|
||||
73
web/src/lib/notes/pg.svelte.ts
Normal file
73
web/src/lib/notes/pg.svelte.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
15
web/src/lib/notes/types.ts
Normal file
15
web/src/lib/notes/types.ts
Normal 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;
|
||||
}
|
||||
42
web/src/routes/api/notes/[noteId]/+server.ts
Normal file
42
web/src/routes/api/notes/[noteId]/+server.ts
Normal 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);
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue