feat: add notes app
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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">
|
||||
@@ -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,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' })
|
||||
}
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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 ?? '' }
|
||||
}
|
||||
@@ -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
@@ -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 }
|
||||
})
|
||||
@@ -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
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user