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
|
INSERT INTO calendars (id, parent_id, name, color) VALUES
|
||||||
('b0000000-0000-0000-0000-000000000030', 'b0000000-0000-0000-0000-000000000010', 'Foreningskalender', '#f59e0b');
|
('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
|
-- Kanban-brett for Liberalistene
|
||||||
INSERT INTO nodes (id, workspace_id, node_type) VALUES
|
INSERT INTO nodes (id, workspace_id, node_type) VALUES
|
||||||
('b0000000-0000-0000-0000-000000000020', 'b0000000-0000-0000-0000-000000000001', 'kanban_board');
|
('b0000000-0000-0000-0000-000000000020', 'b0000000-0000-0000-0000-000000000001', 'kanban_board');
|
||||||
|
|
@ -95,9 +101,10 @@ UPDATE workspaces SET settings = jsonb_set(
|
||||||
"slug": "kalender",
|
"slug": "kalender",
|
||||||
"title": "Kalender",
|
"title": "Kalender",
|
||||||
"icon": "📅",
|
"icon": "📅",
|
||||||
"layout": "single",
|
"layout": "2-1",
|
||||||
"blocks": [
|
"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
|
INSERT INTO calendars (id, parent_id, name, color) VALUES
|
||||||
('a0000000-0000-0000-0000-000000000030', 'a0000000-0000-0000-0000-000000000010', 'Redaksjonskalender', '#3b82f6');
|
('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
|
-- Kanban-brett for redaksjonen
|
||||||
INSERT INTO nodes (id, workspace_id, node_type) VALUES
|
INSERT INTO nodes (id, workspace_id, node_type) VALUES
|
||||||
('a0000000-0000-0000-0000-000000000020', 'a0000000-0000-0000-0000-000000000001', 'kanban_board');
|
('a0000000-0000-0000-0000-000000000020', 'a0000000-0000-0000-0000-000000000001', 'kanban_board');
|
||||||
|
|
@ -152,9 +165,10 @@ UPDATE workspaces SET settings = jsonb_set(
|
||||||
"slug": "kalender",
|
"slug": "kalender",
|
||||||
"title": "Kalender",
|
"title": "Kalender",
|
||||||
"icon": "📅",
|
"icon": "📅",
|
||||||
"layout": "single",
|
"layout": "2-1",
|
||||||
"blocks": [
|
"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">
|
<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();
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="placeholder">
|
{#if !noteId}
|
||||||
<span class="icon">📝</span>
|
<div class="no-note"><p>Ingen notat konfigurert for denne blokken.</p></div>
|
||||||
<p class="label">Notes</p>
|
{:else if conn?.loading}
|
||||||
<p class="hint">Kommer snart</p>
|
<div class="no-note"><p>Laster...</p></div>
|
||||||
</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>
|
<style>
|
||||||
.placeholder {
|
.notes-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 200px;
|
|
||||||
color: #8b92a5;
|
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>
|
</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