Website
One Nuxt 4 + Nuxt UI deployment made of self-contained apps. Each app is a Nuxt layer
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) and an empty/ - the Baserow client
server/utils/baserow.ts(listRows,getRow,createRow,updateRow,deleteRow,listFields, each taking a table ID).NUXT_BASEROW_URLandNUXT_BASEROW_TOKENare shared; each app reads its own table IDs. - the login. One password (
NUXT_DIARY_PASSWORD) unlocks every protected app:server/middleware/auth.tsreturns 401 for the API prefixes inPROTECTED_PREFIXESuntil/api/auth/loginhas set the session cookie (signed withNUXT_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 utilerrorMessage(error)
| Path | App |
|---|---|
/diary |
Diary — layers/diary/ (password protected) |
/wishlist |
Wishlist — layers/wishlist/ |
/notes |
Notes — layers/notes/ (password protected) |
/cv |
CV — layers/cv/ |
pnpm install
cp .env.example .env
pnpm dev
Adding an app
- Create
layers/<name>/withapp/pages/<name>.vue(orapp/pages/<name>/index.vue), and whatever else it needs:app/components,app/composables,server/api/<name>/…,shared/, and anuxt.config.tsfor its runtime config. - Keep it contained: serve its API under
/api/<name>/, and prefixuseState/useFetchkeys and app config with the app name. All layers share one auto-import namespace, so give components and exported utils names that won't clash with another app's (e.g.DiaryEntry, notEntry). - Don't use
~/inside a layer — it points to the rootapp/. Use relative imports.
CV
A public CV built from the Baserow CV table (NUXT_BASEROW_CV_TABLE_ID). Only rows with relevant checked are shown,
latest start first, split into Experience and Education (Student and Pupil). An empty To means the
entry is still going on: it shows as "today" and its duration is counted until today (Baserow's Duration formula is
not used).
The header (name, description per language, photo, email, LinkedIn link) is set in layers/cv/app/app.config.ts
under cv. LinkedIn blocks scraping, so the photo and description are not fetched from there; put the photo in
layers/cv/public/.
A DE / EN switch (remembered in the cv-lang cookie, German by default) translates the page's own text: headings,
dates, durations, types, the description. Area comes from area_german on the German page; the rest of the table's text (Institution) is shown as stored.
Table: From, To (dates), Type (single select), Institution, Area (text, English), area_german (text, used on the German page, falls back to Area), Place (single select),
Grade (number), relevant (boolean), file (file; each file is listed under its entry as a download, e.g. a
thesis), urls (long text: one link per line, a blank line between groups such as projects; GitHub links show
owner/repo with a GitHub icon, others their address; write Label https://… for custom text). Rows without From
are skipped. Baserow returns file URLs as http://, so they are rewritten to
NUXT_BASEROW_URL.
layers/cv/
nuxt.config.ts baserowCvTableId
app/
app.config.ts cv: name, description, photo, email, linkedin
pages/cv.vue language switch, header, Experience and Education
composables/useCvLang.ts current language and the translated texts
components/CvSection.vue one section: period, duration, area, institution, type, grade
utils/cvDate.ts period and duration, open-ended until today
server/
api/cv/index.get.ts GET relevant entries
utils/cv.ts Baserow row -> CvEntry
shared/types/cv.ts
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
and author per card. A card opens its Thalia link. Search matches title and author;
sort by author (last name) or title.
Below the shelf, Bereits gelesen lists title and author of every book in the books table with read set
(read_date filled in), so nobody gives a book twice. Above it, Gerade am Lesen lists the books with
currently_reading checked. Search and sort apply to it too. The books table is found through
the wishlist's books link.
Table: isbn (text), thalia (URL), cover (URL), books (link to the books table) and the lookups
title (German title) and authors.
Covers: cover is used if set. Otherwise the browser looks the ISBN up on Open Library
(openlibrary.org/isbn/<isbn>.json) and posts the cover URL to /api/wishlist/books/<id>/cover, which saves it to the
row, so each book is looked up only until it has a cover. That route is public but only accepts
https://covers.openlibrary.org/b/id/<n>-M.jpg and never overwrites a cover. Books Open Library has no cover for
get a generated tile; paste any image URL into cover to give them one.
layers/wishlist/
nuxt.config.ts baserowWishlistTableId
app/
pages/wishlist.vue search, sort, grid
components/WishlistCard.vue cover + title + author, links to Thalia
composables/
useWishlist.ts loads, searches and sorts the books
useWishlistCover.ts cover from the table or Open Library, saves it back
server/
api/wishlist/books/ GET list, POST [id]/cover
api/wishlist/reading.get.ts, read.get.ts books being read / already read
utils/wishlist.ts Baserow row -> WishlistBook
utils/shelf.ts books table lookup, listShelfBooks(filter)
shared/types/wishlist.ts
Diary
A password-protected diary of reviews (books, movies, albums) and notes, built with Nuxt 4 + Nuxt UI on top of two self-hosted Baserow tables.
Configuration
| Variable | Purpose |
|---|---|
NUXT_BASEROW_URL |
Baserow instance, no trailing slash |
NUXT_BASEROW_TOKEN |
Database token with read and create access to both tables |
NUXT_BASEROW_REVIEWS_TABLE_ID |
Reviews table |
NUXT_BASEROW_NOTES_TABLE_ID |
Notes table |
NUXT_BASEROW_WISHLIST_TABLE_ID |
Wishlist table (see Wishlist) |
NUXT_BASEROW_CV_TABLE_ID |
CV table (see CV) |
NUXT_BASEROW_NOTES_APP_TABLE_ID |
Table of the notes app (see 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.
What it does
/diary: reviews and notes on one timeline, newest first, grouped by year.- Only entries with text are shown. Long text is collapsed behind "Read more".
- Search matches every word against the kind (
review,note), the medium (book,movie,album), 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 returns 401 (the shared login, see the top of this file). ChangingNUXT_SESSION_SECRETlogs out every browser.
Code layout
layers/diary/
nuxt.config.ts runtime config (env vars above)
app/
app.config.ts diary.showCovers
pages/diary.vue password gate around the timeline
components/
DiaryTimeline.vue the diary: search, Log button, entries grouped by year
DiaryEntry.vue one timeline entry (date, title, text, read more)
LogModal.vue Note / Review switch around the two forms
NoteForm.vue, ReviewForm.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
types/entry.ts a timeline entry (review or note)
server/
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
shared/
types/ Review, Note
utils/medium.ts the review media: label and creator label
utils/schemas.ts zod schemas used by both forms and API
Baserow tables
Reviews: books (link to the books table), review (long text), medium (single select: Book / Movie /
Album) and the lookups through books: title (title_german), author_or_director (authors) and
date (read_date). The books table is found through the books link, so it needs no table ID.
Books: title, title_german, authors (link) and read_date (date).
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.
Adding a medium
Reviews currently link only to books, so the form always saves Book. Other media need their own link field.
- Add the option to the
mediumsingle select in Baserow. - Add it to
Mediuminlayers/diary/shared/types/review.ts, toMEDIUMSinlayers/diary/shared/utils/medium.ts(itslabelmust match the Baserow option) and toMEDIUM_ICONSinlayers/diary/app/utils/icons.ts.