feat: add notes app
This commit is contained in:
@@ -3,5 +3,6 @@ NUXT_BASEROW_TOKEN=
|
|||||||
NUXT_BASEROW_REVIEWS_TABLE_ID=866
|
NUXT_BASEROW_REVIEWS_TABLE_ID=866
|
||||||
NUXT_BASEROW_NOTES_TABLE_ID=864
|
NUXT_BASEROW_NOTES_TABLE_ID=864
|
||||||
NUXT_BASEROW_WISHLIST_TABLE_ID=870
|
NUXT_BASEROW_WISHLIST_TABLE_ID=870
|
||||||
|
NUXT_BASEROW_NOTES_APP_TABLE_ID=
|
||||||
NUXT_DIARY_PASSWORD=
|
NUXT_DIARY_PASSWORD=
|
||||||
NUXT_SESSION_SECRET=
|
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)
|
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
|
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
|
picked up automatically. The root only holds what apps share:
|
||||||
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.
|
- 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 |
|
| Path | App |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `/diary` | [Diary](#diary) — `layers/diary/` (password protected) |
|
| `/diary` | [Diary](#diary) — `layers/diary/` (password protected) |
|
||||||
| `/wishlist` | [Wishlist](#wishlist) — `layers/wishlist/` |
|
| `/wishlist` | [Wishlist](#wishlist) — `layers/wishlist/` |
|
||||||
|
| `/notes` | [Notes](#notes) — `layers/notes/` (password protected) |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install
|
pnpm install
|
||||||
@@ -26,6 +35,42 @@ pnpm dev
|
|||||||
names that won't clash with another app's (e.g. `DiaryEntry`, not `Entry`).
|
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.
|
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
|
# Wishlist
|
||||||
|
|
||||||
A public shelf of the books in the Baserow wishlist table (`NUXT_BASEROW_WISHLIST_TABLE_ID`): cover, German title
|
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_REVIEWS_TABLE_ID` | Reviews table |
|
||||||
| `NUXT_BASEROW_NOTES_TABLE_ID` | Notes table |
|
| `NUXT_BASEROW_NOTES_TABLE_ID` | Notes table |
|
||||||
| `NUXT_BASEROW_WISHLIST_TABLE_ID` | Wishlist table (see [Wishlist](#wishlist)) |
|
| `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`) |
|
| `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.
|
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.
|
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;
|
- "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`.
|
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
|
- Until the password is entered, only a password prompt is shown and every `/api/diary/*` route returns 401
|
||||||
`/api/diary/session` and `/api/diary/login` returns 401. A correct password sets a signed, http-only cookie valid for 30 days.
|
(the shared login, see the top of this file). Changing `NUXT_SESSION_SECRET` logs out every browser.
|
||||||
Changing `NUXT_SESSION_SECRET` logs out every browser.
|
|
||||||
|
|
||||||
## Code layout
|
## Code layout
|
||||||
|
|
||||||
@@ -107,22 +152,18 @@ app/
|
|||||||
DiaryEntry.vue one timeline entry (date, title, text, read more)
|
DiaryEntry.vue one timeline entry (date, title, text, read more)
|
||||||
LogModal.vue Note / Review switch around the two forms
|
LogModal.vue Note / Review switch around the two forms
|
||||||
NoteForm.vue, ReviewForm.vue
|
NoteForm.vue, ReviewForm.vue
|
||||||
PasswordModal.vue
|
|
||||||
CoverTile.vue generated cover, used when showCovers is on
|
CoverTile.vue generated cover, used when showCovers is on
|
||||||
composables/
|
composables/
|
||||||
useDiary.ts loads reviews + notes, searches, sorts and groups them by year
|
useDiary.ts loads reviews + notes, searches, sorts and groups them by year
|
||||||
useSaveEntry.ts shared submit logic of the forms
|
useSaveEntry.ts shared submit logic of the forms
|
||||||
useLog.ts open/close state of the Log modal
|
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)
|
types/entry.ts a timeline entry (review or note)
|
||||||
server/
|
server/
|
||||||
middleware/diary-auth.ts locks /api/diary/* until unlocked
|
api/diary/ reviews and notes (GET list, POST create), books (for the review form)
|
||||||
api/diary/ session, login, reviews and notes (GET list, POST create), books (for the review form)
|
|
||||||
utils/
|
utils/
|
||||||
diaryTable.ts the diary's table IDs (the Baserow client itself is in the root server/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
|
review.ts, note.ts, book.ts Baserow row -> app type
|
||||||
auth.ts session cookie and password check
|
|
||||||
validation.ts readValidBody(event, zodSchema)
|
|
||||||
shared/
|
shared/
|
||||||
types/ Review, Note
|
types/ Review, Note
|
||||||
utils/medium.ts the review media: label and creator label
|
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).
|
**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.
|
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">
|
<script setup lang="ts">
|
||||||
|
defineProps<{ title: string }>()
|
||||||
const emit = defineEmits<{ unlocked: [] }>()
|
const emit = defineEmits<{ unlocked: [] }>()
|
||||||
|
|
||||||
const password = ref('')
|
const password = ref('')
|
||||||
@@ -10,10 +11,10 @@ async function unlock() {
|
|||||||
unlocking.value = true
|
unlocking.value = true
|
||||||
error.value = undefined
|
error.value = undefined
|
||||||
try {
|
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')
|
emit('unlocked')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = errorMessage(e) ?? 'Couldn\'t unlock the diary'
|
error.value = errorMessage(e) ?? 'Couldn\'t unlock'
|
||||||
password.value = ''
|
password.value = ''
|
||||||
} finally {
|
} finally {
|
||||||
unlocking.value = false
|
unlocking.value = false
|
||||||
@@ -22,7 +23,7 @@ async function unlock() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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>
|
<template #body>
|
||||||
<form class="space-y-4" @submit.prevent="unlock">
|
<form class="space-y-4" @submit.prevent="unlock">
|
||||||
<UFormField label="Password" :error="error">
|
<UFormField label="Password" :error="error">
|
||||||
@@ -1,12 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
useHead({ title: 'Diary' })
|
useHead({ title: 'Diary' })
|
||||||
|
|
||||||
const { data: session, refresh } = await useFetch<{ unlocked: boolean }>('/api/diary/session', { key: 'diary-session' })
|
|
||||||
const unlocked = computed(() => !!session.value?.unlocked)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<template v-if="unlocked">
|
<PasswordGate title="Diary">
|
||||||
<UMain>
|
<UMain>
|
||||||
<UContainer class="py-8">
|
<UContainer class="py-8">
|
||||||
<DiaryTimeline />
|
<DiaryTimeline />
|
||||||
@@ -14,7 +11,5 @@ const unlocked = computed(() => !!session.value?.unlocked)
|
|||||||
</UMain>
|
</UMain>
|
||||||
|
|
||||||
<LogModal />
|
<LogModal />
|
||||||
</template>
|
</PasswordGate>
|
||||||
|
|
||||||
<PasswordModal v-else @unlocked="refresh()" />
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
runtimeConfig: {
|
runtimeConfig: {
|
||||||
baserowReviewsTableId: '',
|
baserowReviewsTableId: '',
|
||||||
baserowNotesTableId: '',
|
baserowNotesTableId: ''
|
||||||
diaryPassword: '',
|
|
||||||
sessionSecret: ''
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
text: requiredText
|
||||||
})
|
})
|
||||||
|
|
||||||
export const loginSchema = z.object({
|
|
||||||
password: z.string().min(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
export type NewNoteInput = z.output<typeof newNoteSchema>
|
export type NewNoteInput = z.output<typeof newNoteSchema>
|
||||||
export type NewReviewInput = z.output<typeof newReviewSchema>
|
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 },
|
devtools: { enabled: false },
|
||||||
runtimeConfig: {
|
runtimeConfig: {
|
||||||
baserowUrl: '',
|
baserowUrl: '',
|
||||||
baserowToken: ''
|
baserowToken: '',
|
||||||
|
// Shared login of the password-protected apps (diary, notes)
|
||||||
|
diaryPassword: '',
|
||||||
|
sessionSecret: ''
|
||||||
},
|
},
|
||||||
app: {
|
app: {
|
||||||
head: {
|
head: {
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
const FAILED_LOGIN_DELAY_MS = 1000
|
const FAILED_LOGIN_DELAY_MS = 1000
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
password: z.string().min(1)
|
||||||
|
})
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const { password } = await readValidBody(event, loginSchema)
|
const { password } = await readValidBody(event, loginSchema)
|
||||||
|
|
||||||
@@ -8,7 +14,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
throw createError({ statusCode: 401, statusMessage: 'Wrong password' })
|
throw createError({ statusCode: 401, statusMessage: 'Wrong password' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await diarySession(event)
|
const session = await authSession(event)
|
||||||
await session.update({ unlocked: true })
|
await session.update({ unlocked: true })
|
||||||
return { unlocked: true }
|
return { unlocked: true }
|
||||||
})
|
})
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const session = await diarySession(event)
|
const session = await authSession(event)
|
||||||
return { unlocked: !!session.data.unlocked }
|
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'
|
import type { H3Event } from 'h3'
|
||||||
|
|
||||||
interface DiarySession {
|
interface AuthSession {
|
||||||
unlocked?: boolean
|
unlocked?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const SESSION_MAX_AGE_S = 60 * 60 * 24 * 30
|
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)
|
const { sessionSecret } = useRuntimeConfig(event)
|
||||||
if (!sessionSecret || sessionSecret.length < 32) {
|
if (!sessionSecret || sessionSecret.length < 32) {
|
||||||
throw createError({ statusCode: 500, statusMessage: 'NUXT_SESSION_SECRET must be set (at least 32 characters)' })
|
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',
|
name: 'diary',
|
||||||
password: sessionSecret,
|
password: sessionSecret,
|
||||||
maxAge: SESSION_MAX_AGE_S,
|
maxAge: SESSION_MAX_AGE_S,
|
||||||
+12
-4
@@ -13,7 +13,7 @@ const TIMEOUT_MS = 15_000
|
|||||||
async function request<T>(
|
async function request<T>(
|
||||||
event: H3Event,
|
event: H3Event,
|
||||||
path: string,
|
path: string,
|
||||||
options: { method?: 'GET' | 'POST' | 'PATCH', query?: Fields, body?: Fields } = {}
|
options: { method?: 'GET' | 'POST' | 'PATCH' | 'DELETE', query?: Fields, body?: Fields } = {}
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const { baserowUrl, baserowToken } = useRuntimeConfig(event)
|
const { baserowUrl, baserowToken } = useRuntimeConfig(event)
|
||||||
if (!baserowUrl || !baserowToken) {
|
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 }`. */
|
/** 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, filters: Fields = {}): Promise<Row[]> {
|
export async function listRows<Row>(event: H3Event, tableId: string, query: Fields = {}): Promise<Row[]> {
|
||||||
const path = `rows/table/${tableId}/`
|
const path = `rows/table/${tableId}/`
|
||||||
const rows: Row[] = []
|
const rows: Row[] = []
|
||||||
for (let page = 1; ; page++) {
|
for (let page = 1; ; page++) {
|
||||||
const { count, results } = await request<RowPage<Row>>(event, path, {
|
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)
|
rows.push(...results)
|
||||||
if (rows.length >= count || results.length === 0) return rows
|
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> {
|
export function updateRow<Row>(event: H3Event, tableId: string, rowId: number, fields: Fields): Promise<Row> {
|
||||||
return request<Row>(event, `rows/table/${tableId}/${rowId}/`, {
|
return request<Row>(event, `rows/table/${tableId}/${rowId}/`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
|||||||
Reference in New Issue
Block a user