feat: add notes app

This commit is contained in:
2026-10-01 19:39:20 +02:00
parent ccb8dd3d15
commit ddbb6f37b5
35 changed files with 706 additions and 52 deletions
@@ -1,35 +0,0 @@
<script setup lang="ts">
const emit = defineEmits<{ unlocked: [] }>()
const password = ref('')
const error = ref<string>()
const unlocking = ref(false)
async function unlock() {
if (!password.value) return
unlocking.value = true
error.value = undefined
try {
await $fetch('/api/diary/login', { method: 'POST', body: { password: password.value } })
emit('unlocked')
} catch (e) {
error.value = errorMessage(e) ?? 'Couldn\'t unlock the diary'
password.value = ''
} finally {
unlocking.value = false
}
}
</script>
<template>
<UModal :open="true" :dismissible="false" :close="false" title="Diary" description="Enter your password to open the diary.">
<template #body>
<form class="space-y-4" @submit.prevent="unlock">
<UFormField label="Password" :error="error">
<UInput v-model="password" type="password" autocomplete="current-password" class="w-full" autofocus />
</UFormField>
<UButton type="submit" label="Unlock" icon="i-lucide-lock-open" :loading="unlocking" :disabled="!password" />
</form>
</template>
</UModal>
</template>
+2 -7
View File
@@ -1,12 +1,9 @@
<script setup lang="ts">
useHead({ title: 'Diary' })
const { data: session, refresh } = await useFetch<{ unlocked: boolean }>('/api/diary/session', { key: 'diary-session' })
const unlocked = computed(() => !!session.value?.unlocked)
</script>
<template>
<template v-if="unlocked">
<PasswordGate title="Diary">
<UMain>
<UContainer class="py-8">
<DiaryTimeline />
@@ -14,7 +11,5 @@ const unlocked = computed(() => !!session.value?.unlocked)
</UMain>
<LogModal />
</template>
<PasswordModal v-else @unlocked="refresh()" />
</PasswordGate>
</template>
-10
View File
@@ -1,10 +0,0 @@
interface FetchErrorLike {
data?: { statusMessage?: string }
statusMessage?: string
message?: string
}
export function errorMessage(error: unknown): string | undefined {
const e = error as FetchErrorLike | null
return e?.data?.statusMessage || e?.statusMessage || e?.message
}
+1 -3
View File
@@ -1,8 +1,6 @@
export default defineNuxtConfig({
runtimeConfig: {
baserowReviewsTableId: '',
baserowNotesTableId: '',
diaryPassword: '',
sessionSecret: ''
baserowNotesTableId: ''
}
})
@@ -1,14 +0,0 @@
const FAILED_LOGIN_DELAY_MS = 1000
export default defineEventHandler(async (event) => {
const { password } = await readValidBody(event, loginSchema)
if (!(await passwordMatches(event, password))) {
await new Promise(resolve => setTimeout(resolve, FAILED_LOGIN_DELAY_MS))
throw createError({ statusCode: 401, statusMessage: 'Wrong password' })
}
const session = await diarySession(event)
await session.update({ unlocked: true })
return { unlocked: true }
})
@@ -1,4 +0,0 @@
export default defineEventHandler(async (event) => {
const session = await diarySession(event)
return { unlocked: !!session.data.unlocked }
})
@@ -1,11 +0,0 @@
const PUBLIC_ROUTES = ['/api/diary/session', '/api/diary/login']
export default defineEventHandler(async (event) => {
const path = event.path.split('?')[0]!
if (!path.startsWith('/api/diary/') || PUBLIC_ROUTES.includes(path)) return
const session = await diarySession(event)
if (!session.data.unlocked) {
throw createError({ statusCode: 401, statusMessage: 'The diary is locked' })
}
})
-36
View File
@@ -1,36 +0,0 @@
import type { H3Event } from 'h3'
interface DiarySession {
unlocked?: boolean
}
const SESSION_MAX_AGE_S = 60 * 60 * 24 * 30
export function diarySession(event: H3Event) {
const { sessionSecret } = useRuntimeConfig(event)
if (!sessionSecret || sessionSecret.length < 32) {
throw createError({ statusCode: 500, statusMessage: 'NUXT_SESSION_SECRET must be set (at least 32 characters)' })
}
return useSession<DiarySession>(event, {
name: 'diary',
password: sessionSecret,
maxAge: SESSION_MAX_AGE_S,
cookie: { httpOnly: true, sameSite: 'lax', secure: !import.meta.dev }
})
}
async function sha256(text: string): Promise<Uint8Array> {
return new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)))
}
export async function passwordMatches(event: H3Event, guess: string): Promise<boolean> {
const { diaryPassword } = useRuntimeConfig(event)
if (!diaryPassword) {
throw createError({ statusCode: 500, statusMessage: 'NUXT_DIARY_PASSWORD is not set' })
}
const [a, b] = await Promise.all([sha256(guess), sha256(diaryPassword)])
let difference = 0
for (let i = 0; i < a.length; i++) difference |= a[i]! ^ b[i]!
return difference === 0
}
-10
View File
@@ -1,10 +0,0 @@
import type { H3Event } from 'h3'
import type { z } from 'zod'
export async function readValidBody<Schema extends z.ZodType>(event: H3Event, schema: Schema): Promise<z.output<Schema>> {
const result = schema.safeParse(await readBody(event))
if (!result.success) {
throw createError({ statusCode: 400, statusMessage: result.error.issues[0]?.message ?? 'Invalid request' })
}
return result.data
}
-4
View File
@@ -14,9 +14,5 @@ export const newReviewSchema = z.object({
text: requiredText
})
export const loginSchema = z.object({
password: z.string().min(1)
})
export type NewNoteInput = z.output<typeof newNoteSchema>
export type NewReviewInput = z.output<typeof newReviewSchema>
@@ -0,0 +1,20 @@
<script setup lang="ts">
// "New", bottom right.
// The list opens with NotesDrawer (mobile) and NotesSidebarHandle (desktop).
const { creating, createAndOpen } = useNewNote()
</script>
<template>
<div class="pointer-events-none fixed inset-x-0 bottom-0 z-40 flex justify-end px-4 pb-[max(1rem,env(safe-area-inset-bottom))] md:px-6 md:pb-6">
<UTooltip text="New note" :kbds="['meta', 'alt', 'n']">
<UButton
icon="i-lucide-square-pen"
label="New"
size="lg"
class="pointer-events-auto rounded-full px-5 shadow-lg"
:loading="creating"
@click="createAndOpen"
/>
</UTooltip>
</div>
</template>
@@ -0,0 +1,42 @@
<script setup lang="ts">
// Mobile only: the note list, pulled down from the top. Tap or pull the bar at the top edge to open it;
// push it back up, tap outside or pick a note to close it.
const { drawerOpen } = useNotesSidebar()
const PULL_DISTANCE_PX = 24
let startY: number | null = null
function onTouchStart(event: TouchEvent) {
startY = event.touches[0]?.clientY ?? null
}
function onTouchMove(event: TouchEvent) {
const y = event.touches[0]?.clientY
if (startY === null || y === undefined) return
if (y - startY > PULL_DISTANCE_PX) {
drawerOpen.value = true
startY = null
}
}
</script>
<template>
<div class="md:hidden">
<button
type="button"
aria-label="Show notes"
class="fixed inset-x-0 top-0 z-30 flex h-7 touch-none items-start justify-center pt-2"
@click="drawerOpen = true"
@touchstart.passive="onTouchStart"
@touchmove.passive="onTouchMove"
>
<span class="h-1.5 w-12 rounded-full bg-accented" />
</button>
<UDrawer v-model:open="drawerOpen" direction="top" title="Notes" :ui="{ content: 'max-h-[85dvh]', body: 'overflow-y-auto p-0 sm:p-0' }">
<template #body>
<NotesList />
</template>
</UDrawer>
</div>
</template>
+59
View File
@@ -0,0 +1,59 @@
<script setup lang="ts">
// The note list, in the desktop sidebar and in the mobile drawer
const { docs: allDocs, error } = useNotes()
const { query, matches, searching } = useNotesSearch()
const docs = computed(() => matches.value ? allDocs.value.filter(doc => matches.value!.includes(doc.id)) : allDocs.value)
const { closeOnMobile } = useNotesSidebar()
const deleteDoc = useDeleteNote()
const route = useRoute()
</script>
<template>
<div>
<div class="px-2 pb-2">
<UInput
v-model="query"
icon="i-lucide-search"
placeholder="Search"
:loading="searching"
class="w-full"
@keydown.esc="query = ''"
>
<template v-if="query" #trailing>
<UButton icon="i-lucide-x" color="neutral" variant="link" size="sm" aria-label="Clear search" @click="query = ''" />
</template>
</UInput>
</div>
<p v-if="error" class="px-4 text-sm text-error">Couldn't load the notes: {{ errorMessage(error) }}</p>
<nav class="px-2 pb-4">
<!-- The delete button shows on hover on desktop (instead of the date), always on mobile -->
<div v-for="doc in docs" :key="doc.id" class="group relative">
<NuxtLink
:to="`/notes/${doc.id}`"
class="flex items-baseline gap-2 rounded-md py-2.5 ps-2 pe-11 text-base md:py-1.5 md:pe-2 md:text-sm"
:class="String(doc.id) === route.params.id ? 'bg-elevated text-highlighted' : 'text-default hover:bg-elevated/50'"
@click="closeOnMobile"
>
<span class="flex-1 truncate" :class="{ 'text-muted': !doc.title }">{{ notesDocName(doc) }}</span>
<span class="hidden shrink-0 text-xs text-dimmed tabular-nums md:inline md:group-hover:invisible">{{ notesDocDay(doc.created) }}</span>
</NuxtLink>
<UButton
icon="i-lucide-trash-2"
color="neutral"
variant="ghost"
size="sm"
:aria-label="`Delete ${notesDocName(doc)}`"
title="Delete"
class="absolute inset-y-0 end-1 my-auto h-fit text-dimmed hover:text-error md:opacity-0 md:group-hover:opacity-100 md:focus-visible:opacity-100"
@click="deleteDoc(doc)"
/>
</div>
<p v-if="!docs.length && !error && !searching" class="px-2 py-4 text-sm text-muted">
{{ query.trim() ? 'No notes match.' : 'No notes yet.' }}
</p>
</nav>
</div>
</template>
@@ -0,0 +1,11 @@
<script setup lang="ts">
// Desktop only; on mobile the list is pulled down from the top (NotesDrawer)
const { open } = useNotesSidebar()
</script>
<template>
<aside v-show="open" class="hidden w-72 shrink-0 flex-col border-e border-default md:flex">
<h1 class="px-4 py-4 text-lg font-semibold text-highlighted">Notes</h1>
<NotesList class="flex-1 overflow-y-auto" />
</aside>
</template>
@@ -0,0 +1,25 @@
<script setup lang="ts">
// Desktop only: a chevron on the sidebar's right border (or the window's left edge when the
// sidebar is hidden) that appears on hover and shows/hides the note list
const { open, toggle } = useNotesSidebar()
</script>
<template>
<!-- The whole hover area is the button. It only reaches to the right of the line (never over the note list's
delete buttons), as wide as the note's side padding (md:px-10), so it never covers the text either -->
<button
type="button"
:title="`${open ? 'Hide' : 'Show'} notes (⌘E)`"
:aria-label="open ? 'Hide notes' : 'Show notes'"
class="group fixed inset-y-0 z-20 hidden w-10 cursor-pointer items-center focus:outline-none md:flex"
:class="open ? 'left-72' : 'left-0'"
@click="toggle"
>
<span
class="flex size-6 items-center justify-center rounded-full bg-default text-muted opacity-0 shadow-sm ring-1 ring-accented transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100 group-focus-visible:ring-2 group-focus-visible:ring-primary"
:class="open ? '-translate-x-1/2' : 'ms-2'"
>
<UIcon :name="open ? 'i-lucide-chevron-left' : 'i-lucide-chevron-right'" class="size-4" />
</span>
</button>
</template>
+137
View File
@@ -0,0 +1,137 @@
// European numeric dates: 01.10.2026, 14:32
const nameFormat = new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
const dayFormat = new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit' })
/** A note is named by its title, or by when it was created if it has none */
export function notesDocName(doc: Pick<NotesDocSummary, 'title' | 'created'>): string {
return doc.title || (doc.created ? nameFormat.format(new Date(doc.created)) : 'Untitled')
}
export function notesDocDay(iso: string | null): string {
return iso ? dayFormat.format(new Date(iso)) : ''
}
export function useNotes() {
const { data, error, status, refresh } = useFetch<NotesDocSummary[]>('/api/notes/docs', { key: 'notes-docs', default: () => [] })
// Newest first by creation, so editing a note never moves it
const docs = computed(() => data.value.toSorted((a, b) => (b.created ?? '').localeCompare(a.created ?? '') || b.id - a.id))
/** Reflects a change in the browser without reloading the list */
function patchSummary(id: number, changes: Partial<NotesDocSummary>) {
data.value = data.value.map(doc => doc.id === id ? { ...doc, ...changes } : doc)
}
async function create() {
const doc = await $fetch<NotesDoc>('/api/notes/docs', { method: 'POST' })
data.value = [...data.value, doc]
return doc
}
async function remove(id: number) {
await $fetch(`/api/notes/docs/${id}`, { method: 'DELETE' })
data.value = data.value.filter(doc => doc.id !== id)
}
return { docs, error, status, refresh, patchSummary, create, remove }
}
/**
* `open`: the desktop sidebar (remembered in a cookie).
* `drawerOpen`: the list that is pulled down from the top on mobile; it closes once a note is opened.
*/
export function useNotesSidebar() {
const open = useCookie<boolean>('notes-sidebar', { default: () => true, maxAge: 60 * 60 * 24 * 365 })
const drawerOpen = useState('notes-drawer', () => false)
return {
open,
drawerOpen,
toggle: () => { open.value = !open.value },
/** The desktop sidebar, or the drawer below md */
toggleList: () => {
if (window.matchMedia('(max-width: 767px)').matches) drawerOpen.value = !drawerOpen.value
else open.value = !open.value
},
closeOnMobile: () => { drawerOpen.value = false }
}
}
/** Creates a note and opens it; used by the action bar */
export function useNewNote() {
const { create } = useNotes()
const { closeOnMobile } = useNotesSidebar()
const toast = useToast()
const creating = useState('notes-creating', () => false)
async function createAndOpen() {
creating.value = true
try {
const doc = await create()
closeOnMobile()
await navigateTo(`/notes/${doc.id}`)
} catch (e) {
toast.add({ title: 'Couldn\'t create a note', description: errorMessage(e), color: 'error', icon: 'i-lucide-triangle-alert' })
} finally {
creating.value = false
}
}
return { creating, createAndOpen }
}
/** Deletes a note right away; used by the list's trash buttons */
export function useDeleteNote() {
const { remove } = useNotes()
const route = useRoute()
const toast = useToast()
return async function deleteNote(doc: NotesDocSummary) {
try {
// Leave the note first, so its last unsaved edits aren't saved into a deleted row
if (String(doc.id) === route.params.id) await navigateTo('/notes')
await remove(doc.id)
} catch (e) {
toast.add({ title: 'Couldn\'t delete the note', description: errorMessage(e), color: 'error', icon: 'i-lucide-triangle-alert' })
}
}
}
const SEARCH_DELAY_MS = 250
/**
* The search over title and text. `query` is what's typed; `matches` the ids of matching notes, or null when
* not searching. The list is mounted twice (sidebar and drawer), so the searching itself runs once, in notes.vue.
*/
export function useNotesSearch() {
const query = useState('notes-search', () => '')
const matches = useState<number[] | null>('notes-search-matches', () => null)
const searching = useState('notes-searching', () => false)
function runSearch() {
let timer: ReturnType<typeof setTimeout> | undefined
let latest = 0
watch(query, (value) => {
clearTimeout(timer)
const q = value.trim()
if (!q) {
matches.value = null
searching.value = false
return
}
searching.value = true
timer = setTimeout(async () => {
const request = ++latest
try {
const docs = await $fetch<NotesDocSummary[]>('/api/notes/docs', { query: { q } })
if (request === latest) matches.value = docs.map(doc => doc.id)
} finally {
if (request === latest) searching.value = false
}
}, SEARCH_DELAY_MS)
})
}
return { query, matches, searching, runSearch }
}
@@ -0,0 +1,51 @@
const SAVE_DELAY_MS = 800
export type NotesSaveState = 'saved' | 'unsaved' | 'saving' | 'error'
/** Collects edits to a note and saves them shortly after typing stops, and when leaving the note or the page */
export function useNotesAutosave(id: number, onSaved: (summary: NotesDocSummary) => void) {
const url = `/api/notes/docs/${id}`
const state = ref<NotesSaveState>('saved')
let pending: NotesDocUpdate = {}
let timer: ReturnType<typeof setTimeout> | undefined
const hasPending = () => Object.keys(pending).length > 0
async function flush() {
clearTimeout(timer)
if (!hasPending()) return
const body = pending
pending = {}
state.value = 'saving'
try {
onSaved(await $fetch<NotesDocSummary>(url, { method: 'PATCH', body }))
state.value = hasPending() ? 'unsaved' : 'saved'
} catch {
pending = { ...body, ...pending }
state.value = 'error'
}
}
function queue(changes: NotesDocUpdate) {
Object.assign(pending, changes)
state.value = 'unsaved'
clearTimeout(timer)
timer = setTimeout(flush, SAVE_DELAY_MS)
}
// A normal request may not finish when the tab closes; keepalive lets it
function saveOnUnload() {
if (!hasPending()) return
fetch(url, { method: 'PATCH', keepalive: true, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(pending) })
pending = {}
}
onMounted(() => window.addEventListener('pagehide', saveOnUnload))
onBeforeUnmount(() => {
window.removeEventListener('pagehide', saveOnUnload)
flush()
})
onBeforeRouteLeave(() => flush())
return { state, queue, flush }
}
+29
View File
@@ -0,0 +1,29 @@
<script setup lang="ts">
useHead({ title: 'Notes' })
const { toggleList } = useNotesSidebar()
const { createAndOpen } = useNewNote()
useNotesSearch().runSearch()
// ⌘ on Mac, Ctrl elsewhere. ⌘N itself is reserved by the browser. ⌘E is kept from the editor in notes/[id].vue.
defineShortcuts({
meta_alt_n: { usingInput: true, handler: createAndOpen },
meta_e: { usingInput: true, handler: toggleList }
})
</script>
<template>
<PasswordGate title="Notes">
<div class="flex h-dvh">
<NotesSidebar />
<main class="relative min-w-0 flex-1 overflow-y-auto">
<NuxtPage :page-key="route => route.fullPath" />
</main>
</div>
<NotesSidebarHandle />
<NotesDrawer />
<NotesActionBar />
</PasswordGate>
</template>
+112
View File
@@ -0,0 +1,112 @@
<script setup lang="ts">
import type { EditorToolbarItem } from '@nuxt/ui'
const route = useRoute()
const id = Number(route.params.id)
const { data: doc, error } = await useFetch<NotesDoc>(`/api/notes/docs/${id}`, { key: `notes-doc-${id}` })
const { patchSummary } = useNotes()
const title = ref(doc.value?.title ?? '')
const content = ref(doc.value?.content ?? '')
let savedContent = content.value
// ⌘E opens/closes the note list (notes.vue), so the editor must not also toggle inline code with it
const editorProps = {
handleKeyDown: (_view: unknown, event: KeyboardEvent) =>
(event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'e'
}
const { queue } = useNotesAutosave(id, summary => patchSummary(id, summary))
useHead({ title: () => doc.value ? notesDocName({ title: title.value, created: doc.value.created }) : 'Notes' })
watch(title, (value) => {
patchSummary(id, { title: value.trim() })
queue({ title: value })
})
// The editor also reports content on load; only real edits are saved
watch(content, (value) => {
if (value === savedContent) return
savedContent = value
queue({ content: value })
})
// Phones get a toolbar that stays at the top instead of the bubble on selection, which competes with the
// system's own selection menu there
const isMobile = ref(false)
let mobileQuery: MediaQueryList | undefined
const updateMobile = () => { isMobile.value = !!mobileQuery?.matches }
onMounted(() => {
mobileQuery = window.matchMedia('(max-width: 767px)')
updateMobile()
mobileQuery.addEventListener('change', updateMobile)
})
onBeforeUnmount(() => mobileQuery?.removeEventListener('change', updateMobile))
const toolbarItems: EditorToolbarItem[][] = [
[
{ kind: 'heading', level: 1, icon: 'i-lucide-heading-1', 'aria-label': 'Heading 1' },
{ kind: 'heading', level: 2, icon: 'i-lucide-heading-2', 'aria-label': 'Heading 2' }
],
[
{ kind: 'mark', mark: 'bold', icon: 'i-lucide-bold', 'aria-label': 'Bold' },
{ kind: 'mark', mark: 'italic', icon: 'i-lucide-italic', 'aria-label': 'Italic' },
{ kind: 'mark', mark: 'strike', icon: 'i-lucide-strikethrough', 'aria-label': 'Strikethrough' },
{ kind: 'mark', mark: 'code', icon: 'i-lucide-code', 'aria-label': 'Code' },
{ kind: 'link', icon: 'i-lucide-link', 'aria-label': 'Link' }
],
[
{ kind: 'bulletList', icon: 'i-lucide-list', 'aria-label': 'Bullet list' },
{ kind: 'orderedList', icon: 'i-lucide-list-ordered', 'aria-label': 'Numbered list' },
{ kind: 'blockquote', icon: 'i-lucide-text-quote', 'aria-label': 'Quote' }
]
]
</script>
<template>
<div class="mx-auto max-w-3xl px-6 pt-8 pb-32 md:px-10 md:pt-14 md:pb-24">
<UAlert
v-if="error"
color="error"
variant="subtle"
icon="i-lucide-triangle-alert"
title="Couldn't load this note"
:description="errorMessage(error)"
/>
<template v-else-if="doc">
<div class="mb-6 flex items-center gap-3">
<input
v-model="title"
:placeholder="notesDocName({ title: '', created: doc.created })"
aria-label="Title"
class="min-w-0 flex-1 bg-transparent text-3xl font-semibold text-highlighted outline-none placeholder:text-dimmed"
>
</div>
<UEditor
v-slot="{ editor }"
v-model="content"
content-type="markdown"
placeholder="Write something…"
:mention="false"
:starter-kit="{ link: { openOnClick: true } }"
:editor-props="editorProps"
:ui="{ base: 'sm:px-0 [&_a]:cursor-pointer' }"
class="min-h-[60vh]"
>
<UEditorToolbar
v-if="isMobile"
:editor="editor"
:items="toolbarItems"
layout="fixed"
class="sticky top-7 z-10 -mx-6 mb-4 overflow-x-auto border-b border-default bg-default/90 px-4 py-1 backdrop-blur"
/>
<UEditorToolbar v-else :editor="editor" :items="toolbarItems" layout="bubble" />
</UEditor>
</template>
</div>
</template>
+14
View File
@@ -0,0 +1,14 @@
<script setup lang="ts">
// On mobile there is nothing to see without a note, so the list starts open
const { drawerOpen } = useNotesSidebar()
onMounted(() => {
if (window.matchMedia('(max-width: 767px)').matches) drawerOpen.value = true
})
</script>
<template>
<div class="flex h-full items-center justify-center p-8 text-center text-sm text-muted">
<span class="md:hidden">Pull down from the top for your notes, or start a new one.</span>
<span class="hidden md:inline">Pick a note, or start a new one.</span>
</div>
</template>
+5
View File
@@ -0,0 +1,5 @@
export default defineNuxtConfig({
runtimeConfig: {
baserowNotesAppTableId: ''
}
})
@@ -0,0 +1,4 @@
export default defineEventHandler(async (event) => {
await deleteRow(event, notesTable(event), notesDocId(event))
return { deleted: true }
})
@@ -0,0 +1,3 @@
export default defineEventHandler(async (event) => {
return toNotesDoc(await getRow<NotesDocRow>(event, notesTable(event), notesDocId(event)))
})
@@ -0,0 +1,5 @@
export default defineEventHandler(async (event) => {
const changes = await readValidBody(event, notesDocUpdateSchema)
const row = await updateRow<NotesDocRow>(event, notesTable(event), notesDocId(event), { ...changes, updated: notesNow() })
return toNotesDocSummary(row)
})
@@ -0,0 +1,11 @@
// ?q= searches title and text with Baserow's search. Baserow only searches (and filters) the fields a request
// includes, so a search has to include `content` even though the list only needs the summary.
export default defineEventHandler(async (event) => {
const q = String(getQuery(event).q ?? '').trim()
const query = q
? { include: `${NOTES_SUMMARY_FIELDS},content`, search: q }
: { include: NOTES_SUMMARY_FIELDS }
const rows = await listRows<NotesDocRow>(event, notesTable(event), query)
return rows.map(toNotesDocSummary)
})
@@ -0,0 +1,4 @@
export default defineEventHandler(async (event) => {
const row = await createRow<NotesDocRow>(event, notesTable(event), { title: '', content: '', created: notesNow(), updated: notesNow() })
return toNotesDoc(row)
})
+40
View File
@@ -0,0 +1,40 @@
import type { H3Event } from 'h3'
export interface NotesDocRow {
id: number
title: string | null
content?: string | null
created: string | null
updated: string | null
}
export const NOTES_SUMMARY_FIELDS = 'title,created,updated'
export function notesTable(event: H3Event): string {
const { baserowNotesAppTableId } = useRuntimeConfig(event)
if (!baserowNotesAppTableId) {
throw createError({ statusCode: 500, statusMessage: 'NUXT_BASEROW_NOTES_APP_TABLE_ID is not set' })
}
return baserowNotesAppTableId
}
export function notesDocId(event: H3Event): number {
const id = Number(getRouterParam(event, 'id'))
if (!Number.isInteger(id) || id < 1) {
throw createError({ statusCode: 400, statusMessage: 'Invalid note' })
}
return id
}
/** `created` and `updated` are plain date fields, so the server keeps them up to date */
export function notesNow(): string {
return new Date().toISOString()
}
export function toNotesDocSummary(row: NotesDocRow): NotesDocSummary {
return { id: row.id, title: (row.title ?? '').trim(), created: row.created, updated: row.updated }
}
export function toNotesDoc(row: NotesDocRow): NotesDoc {
return { ...toNotesDocSummary(row), content: row.content ?? '' }
}
+12
View File
@@ -0,0 +1,12 @@
export interface NotesDocSummary {
id: number
title: string
/** ISO timestamps, set by the server; null for rows added in Baserow directly */
created: string | null
updated: string | null
}
export interface NotesDoc extends NotesDocSummary {
/** Markdown */
content: string
}
@@ -0,0 +1,8 @@
import { z } from 'zod'
export const notesDocUpdateSchema = z.object({
title: z.string().max(255).optional(),
content: z.string().optional()
})
export type NotesDocUpdate = z.output<typeof notesDocUpdateSchema>