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
View File
@@ -3,5 +3,6 @@ NUXT_BASEROW_TOKEN=
NUXT_BASEROW_REVIEWS_TABLE_ID=866
NUXT_BASEROW_NOTES_TABLE_ID=864
NUXT_BASEROW_WISHLIST_TABLE_ID=870
NUXT_BASEROW_NOTES_APP_TABLE_ID=
NUXT_DIARY_PASSWORD=
NUXT_SESSION_SECRET=
+55 -14
View File
@@ -2,14 +2,23 @@
One Nuxt 4 + Nuxt UI deployment made of self-contained apps. Each app is a [Nuxt layer](https://nuxt.com/docs/guide/going-further/layers)
in `layers/<name>/` with its own pages, components, composables, server routes and config; layers in that folder are
picked up automatically. The root only holds what apps share: the shell (`app/app.vue`, global CSS, theme), an empty `/`, and the Baserow
client `server/utils/baserow.ts` (`listRows`, `createRow`, `updateRow`, `listFields`, each taking a table ID).
`NUXT_BASEROW_URL` and `NUXT_BASEROW_TOKEN` are shared; each app reads its own table IDs.
picked up automatically. The root only holds what apps share:
- the shell (`app/app.vue`, global CSS, theme) and an empty `/`
- the Baserow client `server/utils/baserow.ts` (`listRows`, `getRow`, `createRow`, `updateRow`, `deleteRow`,
`listFields`, each taking a table ID). `NUXT_BASEROW_URL` and `NUXT_BASEROW_TOKEN` are shared; each app reads its
own table IDs.
- the login. One password (`NUXT_DIARY_PASSWORD`) unlocks every protected app: `server/middleware/auth.ts` returns
401 for the API prefixes in `PROTECTED_PREFIXES` until `/api/auth/login` has set the session cookie
(signed with `NUXT_SESSION_SECRET`, http-only, 30 days), and a page wraps itself in `<PasswordGate>` to show the
password prompt. To protect a new app, add its API prefix and use the gate.
- `readValidBody(event, zodSchema)` and the client util `errorMessage(error)`
| Path | App |
|---|---|
| `/diary` | [Diary](#diary) — `layers/diary/` (password protected) |
| `/wishlist` | [Wishlist](#wishlist) — `layers/wishlist/` |
| `/notes` | [Notes](#notes) — `layers/notes/` (password protected) |
```bash
pnpm install
@@ -26,6 +35,42 @@ pnpm dev
names that won't clash with another app's (e.g. `DiaryEntry`, not `Entry`).
3. Don't use `~/` inside a layer — it points to the root `app/`. Use relative imports.
# Notes
Minimal notes: a collapsible list of notes on the left, a WYSIWYG Markdown editor (Nuxt UI `UEditor`) on the right.
No folders. A note is named by its title, or by when it was created if the title is empty. Changes save themselves
0.8 s after you stop typing, and when you leave the note or close the tab. Select text for the formatting toolbar;
Markdown shortcuts (`# `, `- `, `**bold**`, …) also work.
Search at the top of the list matches title and text (`/api/notes/docs?q=`, case-insensitive, parts of words too).
It uses Baserow's search; Baserow only searches the fields a request includes, so the search includes `content`.
Shortcuts (Ctrl instead of ⌘ outside macOS): **⌘ ⌥ N** new note, **⌘ E** show/hide the note list (so the editor's
⌘ E for inline code is turned off; use backticks or the toolbar). Plain ⌘ N can't be used, the browser keeps it.
**Table** (`NUXT_BASEROW_NOTES_APP_TABLE_ID`): `title` (single line text), `content` (long text without rich text; holds the
Markdown), `created` and `updated` (dates with time, set by the
server when a note is created or saved). The list is sorted by `created`, newest first. Rows added in Baserow directly have no dates and show as
"Untitled" at the bottom. The token needs read, create, update and delete.
```
layers/notes/
app/
pages/notes.vue password gate, sidebar + the open note
pages/notes/index.vue nothing selected
pages/notes/[id].vue title, editor, delete
components/NotesList.vue the note list (open, delete)
components/NotesSidebar.vue desktop: the list on the left (open/closed is remembered in a cookie)
components/NotesDrawer.vue mobile: the list, pulled down from the top
components/NotesActionBar.vue "New", bottom right
components/NotesSidebarHandle.vue desktop: chevron on the sidebar edge, on hover, shows/hides the list
composables/
useNotes.ts the list, create/delete, note names
useNotesAutosave.ts debounced saving
server/api/notes/docs/ GET list, POST create, GET/PATCH/DELETE [id]
shared/ NotesDoc type, update schema
```
# Wishlist
A public shelf of the books in the Baserow wishlist table (`NUXT_BASEROW_WISHLIST_TABLE_ID`): cover, German title
@@ -77,7 +122,8 @@ self-hosted Baserow tables.
| `NUXT_BASEROW_REVIEWS_TABLE_ID` | Reviews table |
| `NUXT_BASEROW_NOTES_TABLE_ID` | Notes table |
| `NUXT_BASEROW_WISHLIST_TABLE_ID` | Wishlist table (see [Wishlist](#wishlist)) |
| `NUXT_DIARY_PASSWORD` | Password that unlocks the diary |
| `NUXT_BASEROW_NOTES_APP_TABLE_ID` | Table of the notes app (see [Notes](#notes)), not the diary's notes |
| `NUXT_DIARY_PASSWORD` | Password that unlocks the diary and notes |
| `NUXT_SESSION_SECRET` | Signs the login cookie, at least 32 characters (`openssl rand -hex 32`) |
In `layers/diary/app/app.config.ts`, `diary.showCovers: true` shows a generated title tile next to each review.
@@ -90,9 +136,8 @@ In `layers/diary/app/app.config.ts`, `diary.showCovers: true` shows a generated
the date (`aug 2025`, `2025-08`), the title, the author and the text. Matches in the text are highlighted.
- "Log" opens a form with a Note / Review switch. A review is written for a book picked from the books table;
its "Read on" date is saved as that book's `read_date`.
- Until the password is entered, only a password prompt is shown and every `/api/diary/*` route except
`/api/diary/session` and `/api/diary/login` returns 401. A correct password sets a signed, http-only cookie valid for 30 days.
Changing `NUXT_SESSION_SECRET` logs out every browser.
- Until the password is entered, only a password prompt is shown and every `/api/diary/*` route returns 401
(the shared login, see the top of this file). Changing `NUXT_SESSION_SECRET` logs out every browser.
## Code layout
@@ -107,22 +152,18 @@ app/
DiaryEntry.vue one timeline entry (date, title, text, read more)
LogModal.vue Note / Review switch around the two forms
NoteForm.vue, ReviewForm.vue
PasswordModal.vue
CoverTile.vue generated cover, used when showCovers is on
composables/
useDiary.ts loads reviews + notes, searches, sorts and groups them by year
useSaveEntry.ts shared submit logic of the forms
useLog.ts open/close state of the Log modal
utils/ dates, icons, search highlighting, error messages
utils/ dates, icons, search highlighting
types/entry.ts a timeline entry (review or note)
server/
middleware/diary-auth.ts locks /api/diary/* until unlocked
api/diary/ session, login, reviews and notes (GET list, POST create), books (for the review form)
api/diary/ reviews and notes (GET list, POST create), books (for the review form)
utils/
diaryTable.ts the diary's table IDs (the Baserow client itself is in the root server/utils)
review.ts, note.ts, book.ts Baserow row -> app type
auth.ts session cookie and password check
validation.ts readValidBody(event, zodSchema)
shared/
types/ Review, Note
utils/medium.ts the review media: label and creator label
@@ -137,7 +178,7 @@ shared/
**Books:** `title`, `title_german`, `authors` (link) and `read_date` (date).
**Notes:** `Datum` (date) and `Notes` (long text).
**Notes** (the diary's, now named `diary` in Baserow): `Datum` (date) and `Notes` (long text).
Rows without a date are skipped. Text is plain text: line breaks are kept and it is never rendered as HTML.
+11
View File
@@ -0,0 +1,11 @@
<script setup lang="ts">
// Shows its content only after the shared password has been entered
defineProps<{ title: string }>()
const { data: session, refresh } = await useFetch<{ unlocked: boolean }>('/api/auth/session', { key: 'auth-session' })
</script>
<template>
<slot v-if="session?.unlocked" />
<PasswordModal v-else :title="title" @unlocked="refresh()" />
</template>
@@ -1,4 +1,5 @@
<script setup lang="ts">
defineProps<{ title: string }>()
const emit = defineEmits<{ unlocked: [] }>()
const password = ref('')
@@ -10,10 +11,10 @@ async function unlock() {
unlocking.value = true
error.value = undefined
try {
await $fetch('/api/diary/login', { method: 'POST', body: { password: password.value } })
await $fetch('/api/auth/login', { method: 'POST', body: { password: password.value } })
emit('unlocked')
} catch (e) {
error.value = errorMessage(e) ?? 'Couldn\'t unlock the diary'
error.value = errorMessage(e) ?? 'Couldn\'t unlock'
password.value = ''
} finally {
unlocking.value = false
@@ -22,7 +23,7 @@ async function unlock() {
</script>
<template>
<UModal :open="true" :dismissible="false" :close="false" title="Diary" description="Enter your password to open the diary.">
<UModal :open="true" :dismissible="false" :close="false" :title="title" description="Enter your password to continue.">
<template #body>
<form class="space-y-4" @submit.prevent="unlock">
<UFormField label="Password" :error="error">
+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>
+1 -3
View File
@@ -1,8 +1,6 @@
export default defineNuxtConfig({
runtimeConfig: {
baserowReviewsTableId: '',
baserowNotesTableId: '',
diaryPassword: '',
sessionSecret: ''
baserowNotesTableId: ''
}
})
@@ -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' })
}
})
-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>
+4 -1
View File
@@ -6,7 +6,10 @@ export default defineNuxtConfig({
devtools: { enabled: false },
runtimeConfig: {
baserowUrl: '',
baserowToken: ''
baserowToken: '',
// Shared login of the password-protected apps (diary, notes)
diaryPassword: '',
sessionSecret: ''
},
app: {
head: {
@@ -1,5 +1,11 @@
import { z } from 'zod'
const FAILED_LOGIN_DELAY_MS = 1000
const loginSchema = z.object({
password: z.string().min(1)
})
export default defineEventHandler(async (event) => {
const { password } = await readValidBody(event, loginSchema)
@@ -8,7 +14,7 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 401, statusMessage: 'Wrong password' })
}
const session = await diarySession(event)
const session = await authSession(event)
await session.update({ unlocked: true })
return { unlocked: true }
})
@@ -1,4 +1,4 @@
export default defineEventHandler(async (event) => {
const session = await diarySession(event)
const session = await authSession(event)
return { unlocked: !!session.data.unlocked }
})
+11
View File
@@ -0,0 +1,11 @@
const PROTECTED_PREFIXES = ['/api/diary/', '/api/notes/']
export default defineEventHandler(async (event) => {
const path = event.path.split('?')[0]!
if (!PROTECTED_PREFIXES.some(prefix => path.startsWith(prefix))) return
const session = await authSession(event)
if (!session.data.unlocked) {
throw createError({ statusCode: 401, statusMessage: 'Locked' })
}
})
@@ -1,17 +1,19 @@
import type { H3Event } from 'h3'
interface DiarySession {
interface AuthSession {
unlocked?: boolean
}
const SESSION_MAX_AGE_S = 60 * 60 * 24 * 30
export function diarySession(event: H3Event) {
/** The login shared by all password-protected apps (see PROTECTED_PREFIXES in server/middleware/auth.ts) */
export function authSession(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, {
return useSession<AuthSession>(event, {
// Kept from when only the diary had a login, so existing logins stay valid
name: 'diary',
password: sessionSecret,
maxAge: SESSION_MAX_AGE_S,
+12 -4
View File
@@ -13,7 +13,7 @@ const TIMEOUT_MS = 15_000
async function request<T>(
event: H3Event,
path: string,
options: { method?: 'GET' | 'POST' | 'PATCH', query?: Fields, body?: Fields } = {}
options: { method?: 'GET' | 'POST' | 'PATCH' | 'DELETE', query?: Fields, body?: Fields } = {}
): Promise<T> {
const { baserowUrl, baserowToken } = useRuntimeConfig(event)
if (!baserowUrl || !baserowToken) {
@@ -38,13 +38,13 @@ async function request<T>(
}
}
/** All rows of a table. `filters` are Baserow row filters, e.g. `{ filter__read__boolean: true }`. */
export async function listRows<Row>(event: H3Event, tableId: string, filters: Fields = {}): Promise<Row[]> {
/** All rows of a table. `query` adds Baserow list parameters, e.g. `{ filter__read__boolean: true }` or `{ include: 'title' }`. */
export async function listRows<Row>(event: H3Event, tableId: string, query: Fields = {}): Promise<Row[]> {
const path = `rows/table/${tableId}/`
const rows: Row[] = []
for (let page = 1; ; page++) {
const { count, results } = await request<RowPage<Row>>(event, path, {
query: { ...filters, user_field_names: true, size: PAGE_SIZE, page }
query: { ...query, user_field_names: true, size: PAGE_SIZE, page }
})
rows.push(...results)
if (rows.length >= count || results.length === 0) return rows
@@ -59,6 +59,14 @@ export function createRow<Row>(event: H3Event, tableId: string, fields: Fields):
})
}
export function getRow<Row>(event: H3Event, tableId: string, rowId: number): Promise<Row> {
return request<Row>(event, `rows/table/${tableId}/${rowId}/`, { query: { user_field_names: true } })
}
export async function deleteRow(event: H3Event, tableId: string, rowId: number): Promise<void> {
await request(event, `rows/table/${tableId}/${rowId}/`, { method: 'DELETE' })
}
export function updateRow<Row>(event: H3Event, tableId: string, rowId: number, fields: Fields): Promise<Row> {
return request<Row>(event, `rows/table/${tableId}/${rowId}/`, {
method: 'PATCH',