Skip to content

Commit a9d33cd

Browse files
committed
feat(notes): implement Notes page with markdown viewer and editor
Add full Notes CRUD: sidebar list with search, markdown viewer pane, create/edit modal with live preview toggle, and delete confirmation. Auto-selects the most recently updated note on load. Add updatedAt index to the notes Dexie store (DB version 1 → 2), required for orderBy('updatedAt'). Add notes.test.ts covering CRUD, ordering, search filtering, and content edge cases (161 tests passing).
1 parent 2472f69 commit a9d33cd

3 files changed

Lines changed: 533 additions & 4 deletions

File tree

src/db/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ class ViktoraniDB extends Dexie {
186186

187187
constructor() {
188188
super('viktorani')
189-
this.version(1).stores({
189+
this.version(2).stores({
190190
categories: 'id, name',
191191
difficulties: 'id, name, order',
192192
tags: 'id, name',
@@ -198,7 +198,7 @@ class ViktoraniDB extends Dexie {
198198
buzzEvents: 'id, gameId, playerId, questionId, timestamp',
199199
layouts: 'id, gameId, target',
200200
widgets: 'id, layoutId, order',
201-
notes: 'id, name, createdAt',
201+
notes: 'id, name, createdAt, updatedAt',
202202
timers: 'id, gameId',
203203
gameQuestions: 'id, gameId, roundId, order',
204204
})

src/pages/admin/Notes.tsx

Lines changed: 366 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,373 @@
1+
import { useEffect, useState } from 'react'
2+
import ReactMarkdown from 'react-markdown'
13
import AdminLayout from '@/components/AdminLayout'
4+
import { Button, Input, Textarea, Modal, Empty } from '@/components/ui'
5+
import { db } from '@/db'
6+
import type { Note } from '@/db'
7+
8+
// ── Helpers ───────────────────────────────────────────────────────────────────
9+
10+
function formatDate(ts: number) {
11+
return new Date(ts).toLocaleDateString(undefined, {
12+
day: 'numeric',
13+
month: 'short',
14+
year: 'numeric',
15+
})
16+
}
17+
18+
// ── Note Form Modal ───────────────────────────────────────────────────────────
19+
20+
interface NoteFormProps {
21+
note: Partial<Note> | null
22+
onSave: (data: { id?: string; name: string; content: string }) => void
23+
onClose: () => void
24+
}
25+
26+
function NoteForm({ note, onSave, onClose }: NoteFormProps) {
27+
const isNew = !note?.id
28+
const [name, setName] = useState(note?.name ?? '')
29+
const [content, setContent] = useState(note?.content ?? '')
30+
const [preview, setPreview] = useState(false)
31+
32+
function handleSubmit() {
33+
if (!name.trim()) return
34+
onSave({ ...(note?.id ? { id: note.id } : {}), name: name.trim(), content })
35+
}
36+
37+
return (
38+
<Modal open title={isNew ? 'New note' : 'Edit note'} onClose={onClose} maxWidth="680px">
39+
<div className="flex flex-col gap-4">
40+
<Input
41+
label="Title *"
42+
value={name}
43+
onChange={e => setName(e.target.value)}
44+
placeholder="Note title…"
45+
/>
46+
47+
<div className="flex flex-col gap-2">
48+
<div className="flex items-center justify-between">
49+
<span className="text-xs font-medium" style={{ color: 'var(--color-muted)' }}>
50+
Content (markdown)
51+
</span>
52+
<button
53+
className="text-xs px-2 py-0.5 rounded border transition-all"
54+
style={{
55+
borderColor: 'var(--color-border)',
56+
color: preview ? 'var(--color-gold)' : 'var(--color-muted)',
57+
background: preview ? 'var(--color-gold-light)' : 'transparent',
58+
}}
59+
onClick={() => setPreview(p => !p)}
60+
>
61+
{preview ? '✎ Edit' : '◉ Preview'}
62+
</button>
63+
</div>
64+
65+
{preview ? (
66+
<div
67+
className="rounded border px-4 py-3 prose prose-sm overflow-y-auto"
68+
style={{
69+
borderColor: 'var(--color-border)',
70+
background: 'var(--color-cream)',
71+
minHeight: 200,
72+
maxHeight: 320,
73+
fontFamily: 'Lora, Georgia, serif',
74+
color: 'var(--color-ink)',
75+
}}
76+
>
77+
{content.trim() ? (
78+
<ReactMarkdown>{content}</ReactMarkdown>
79+
) : (
80+
<p style={{ color: 'var(--color-muted)' }}>Nothing to preview.</p>
81+
)}
82+
</div>
83+
) : (
84+
<Textarea
85+
value={content}
86+
onChange={e => setContent(e.target.value)}
87+
placeholder="Write in markdown…"
88+
style={{
89+
minHeight: 200,
90+
maxHeight: 320,
91+
fontFamily: 'DM Mono, monospace',
92+
fontSize: 13,
93+
}}
94+
/>
95+
)}
96+
</div>
97+
98+
<div
99+
className="flex justify-end gap-2 pt-2 border-t"
100+
style={{ borderColor: 'var(--color-border)' }}
101+
>
102+
<Button variant="ghost" onClick={onClose}>
103+
Cancel
104+
</Button>
105+
<Button variant="primary" onClick={handleSubmit} disabled={!name.trim()}>
106+
{isNew ? 'Create note' : 'Save changes'}
107+
</Button>
108+
</div>
109+
</div>
110+
</Modal>
111+
)
112+
}
113+
114+
// ── Delete confirm modal ──────────────────────────────────────────────────────
115+
116+
function DeleteModal({
117+
note,
118+
onConfirm,
119+
onClose,
120+
}: {
121+
note: Note
122+
onConfirm: () => void
123+
onClose: () => void
124+
}) {
125+
return (
126+
<Modal open title="Delete note" onClose={onClose} maxWidth="400px">
127+
<div className="flex flex-col gap-4">
128+
<p className="text-sm" style={{ color: 'var(--color-ink)' }}>
129+
Delete <strong>{note.name}</strong>? This cannot be undone.
130+
</p>
131+
<div className="flex justify-end gap-2">
132+
<Button variant="ghost" onClick={onClose}>
133+
Cancel
134+
</Button>
135+
<Button variant="danger" onClick={onConfirm}>
136+
Delete
137+
</Button>
138+
</div>
139+
</div>
140+
</Modal>
141+
)
142+
}
143+
144+
// ── Note viewer ───────────────────────────────────────────────────────────────
145+
146+
function NoteViewer({
147+
note,
148+
onEdit,
149+
onDelete,
150+
}: {
151+
note: Note
152+
onEdit: () => void
153+
onDelete: () => void
154+
}) {
155+
return (
156+
<div className="flex flex-col h-full">
157+
<div
158+
className="px-8 py-5 border-b flex items-center justify-between shrink-0"
159+
style={{ borderColor: 'var(--color-border)' }}
160+
>
161+
<div>
162+
<h2
163+
className="text-2xl font-bold"
164+
style={{ fontFamily: 'Playfair Display, serif', color: 'var(--color-ink)' }}
165+
>
166+
{note.name}
167+
</h2>
168+
<p className="text-xs mt-0.5" style={{ color: 'var(--color-muted)' }}>
169+
Updated {formatDate(note.updatedAt)}
170+
</p>
171+
</div>
172+
<div className="flex gap-2">
173+
<Button variant="secondary" size="sm" onClick={onEdit}>
174+
Edit
175+
</Button>
176+
<Button
177+
variant="ghost"
178+
size="sm"
179+
onClick={onDelete}
180+
style={{ color: 'var(--color-red)' }}
181+
>
182+
Delete
183+
</Button>
184+
</div>
185+
</div>
186+
187+
<div
188+
className="flex-1 overflow-y-auto px-8 py-6"
189+
style={{ fontFamily: 'Lora, Georgia, serif', color: 'var(--color-ink)' }}
190+
>
191+
{note.content.trim() ? (
192+
<div className="prose prose-sm max-w-none">
193+
<ReactMarkdown>{note.content}</ReactMarkdown>
194+
</div>
195+
) : (
196+
<p style={{ color: 'var(--color-muted)', fontStyle: 'italic' }}>This note is empty.</p>
197+
)}
198+
</div>
199+
</div>
200+
)
201+
}
202+
203+
// ── Main page ─────────────────────────────────────────────────────────────────
2204

3205
export default function Notes() {
206+
const [notes, setNotes] = useState<Note[]>([])
207+
const [selected, setSelected] = useState<string | null>(null)
208+
const [editing, setEditing] = useState<Partial<Note> | null | false>(false)
209+
const [deleting, setDeleting] = useState<Note | null>(null)
210+
const [search, setSearch] = useState('')
211+
212+
async function load() {
213+
const all = await db.notes.orderBy('updatedAt').reverse().toArray()
214+
setNotes(all)
215+
}
216+
217+
useEffect(() => {
218+
db.notes.orderBy('updatedAt').reverse().toArray().then(setNotes)
219+
}, [])
220+
221+
const filtered = search.trim()
222+
? notes.filter(
223+
n =>
224+
n.name.toLowerCase().includes(search.toLowerCase()) ||
225+
n.content.toLowerCase().includes(search.toLowerCase())
226+
)
227+
: notes
228+
229+
// Derive active note — fall back to first in list when selection is stale or null
230+
const activeNote = notes.find(n => n.id === selected) ?? notes[0] ?? null
231+
232+
async function handleSave(data: { id?: string; name: string; content: string }) {
233+
const ts = Date.now()
234+
if (data.id) {
235+
await db.notes.update(data.id, { name: data.name, content: data.content, updatedAt: ts })
236+
} else {
237+
const id = crypto.randomUUID()
238+
await db.notes.add({
239+
id,
240+
name: data.name,
241+
content: data.content,
242+
createdAt: ts,
243+
updatedAt: ts,
244+
})
245+
setSelected(id)
246+
}
247+
setEditing(false)
248+
await load()
249+
}
250+
251+
async function handleDelete(note: Note) {
252+
await db.notes.delete(note.id)
253+
setDeleting(null)
254+
await load()
255+
}
256+
4257
return (
5-
<AdminLayout title="Notes">
6-
<p style={{ color: 'var(--color-muted)' }}>Notes — coming soon.</p>
258+
<AdminLayout>
259+
<div className="flex h-full -mx-8 -my-6" style={{ height: 'calc(100vh - 64px)' }}>
260+
{/* Sidebar */}
261+
<aside
262+
className="w-64 shrink-0 border-r flex flex-col"
263+
style={{ borderColor: 'var(--color-border)' }}
264+
>
265+
<div
266+
className="px-4 py-3 border-b flex items-center justify-between"
267+
style={{ borderColor: 'var(--color-border)' }}
268+
>
269+
<span
270+
className="text-xs font-semibold uppercase tracking-wider"
271+
style={{ color: 'var(--color-muted)' }}
272+
>
273+
Notes {notes.length > 0 && ${notes.length}`}
274+
</span>
275+
<button
276+
onClick={() => setEditing({})}
277+
className="text-xl leading-none hover:opacity-60 transition-opacity"
278+
title="New note"
279+
>
280+
+
281+
</button>
282+
</div>
283+
284+
<div className="px-3 py-2 border-b" style={{ borderColor: 'var(--color-border)' }}>
285+
<input
286+
className="w-full px-2.5 py-1.5 rounded border text-sm outline-none"
287+
style={{
288+
borderColor: 'var(--color-border)',
289+
background: 'var(--color-cream)',
290+
color: 'var(--color-ink)',
291+
}}
292+
placeholder="Search notes…"
293+
value={search}
294+
onChange={e => setSearch(e.target.value)}
295+
/>
296+
</div>
297+
298+
<div className="flex-1 overflow-y-auto">
299+
{filtered.length === 0 ? (
300+
<div className="px-4 py-8 text-center">
301+
<p className="text-xs" style={{ color: 'var(--color-muted)' }}>
302+
{search ? 'No notes match.' : 'No notes yet.'}
303+
</p>
304+
</div>
305+
) : (
306+
filtered.map(note => (
307+
<button
308+
key={note.id}
309+
onClick={() => setSelected(note.id)}
310+
className="w-full text-left px-4 py-3 border-b transition-all"
311+
style={{
312+
borderColor: 'var(--color-border)',
313+
background: selected === note.id ? 'var(--color-gold-light)' : 'transparent',
314+
}}
315+
>
316+
<p
317+
className="text-sm truncate"
318+
style={{ fontWeight: selected === note.id ? 600 : 400 }}
319+
>
320+
{note.name}
321+
</p>
322+
<p className="text-xs mt-0.5 truncate" style={{ color: 'var(--color-muted)' }}>
323+
{formatDate(note.updatedAt)}
324+
{note.content.trim()
325+
? ' · ' +
326+
note.content
327+
.trim()
328+
.slice(0, 40)
329+
.replace(/[#*`_]/g, '')
330+
: ''}
331+
</p>
332+
</button>
333+
))
334+
)}
335+
</div>
336+
337+
<div className="px-4 py-3 border-t" style={{ borderColor: 'var(--color-border)' }}>
338+
<Button variant="primary" size="sm" onClick={() => setEditing({})}>
339+
+ New note
340+
</Button>
341+
</div>
342+
</aside>
343+
344+
{/* Viewer pane */}
345+
<div className="flex-1 overflow-hidden">
346+
{activeNote ? (
347+
<NoteViewer
348+
note={activeNote}
349+
onEdit={() => setEditing(activeNote)}
350+
onDelete={() => setDeleting(activeNote)}
351+
/>
352+
) : (
353+
<div className="flex items-center justify-center h-full">
354+
<Empty icon="✎" message="Select a note or create a new one." />
355+
</div>
356+
)}
357+
</div>
358+
</div>
359+
360+
{editing !== false && (
361+
<NoteForm note={editing} onSave={handleSave} onClose={() => setEditing(false)} />
362+
)}
363+
364+
{deleting && (
365+
<DeleteModal
366+
note={deleting}
367+
onConfirm={() => handleDelete(deleting)}
368+
onClose={() => setDeleting(null)}
369+
/>
370+
)}
7371
</AdminLayout>
8372
)
9373
}

0 commit comments

Comments
 (0)