# Website 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//` 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_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 `` 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) | | `/cv` | [CV](#cv) — `layers/cv/` | ```bash pnpm install cp .env.example .env pnpm dev ``` ## Adding an app 1. Create `layers//` with `app/pages/.vue` (or `app/pages//index.vue`), and whatever else it needs: `app/components`, `app/composables`, `server/api//…`, `shared/`, and a `nuxt.config.ts` for its runtime config. 2. Keep it contained: serve its API under `/api//`, and prefix `useState` / `useFetch` keys 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`, not `Entry`). 3. Don't use `~/` inside a layer — it points to the root `app/`. 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/.json`) and posts the cover URL to `/api/wishlist/books//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/-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](#wishlist)) | | `NUXT_BASEROW_CV_TABLE_ID` | CV table (see [CV](#cv)) | | `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. ## 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). Changing `NUXT_SESSION_SECRET` logs 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. 1. Add the option to the `medium` single select in Baserow. 2. Add it to `Medium` in `layers/diary/shared/types/review.ts`, to `MEDIUMS` in `layers/diary/shared/utils/medium.ts` (its `label` must match the Baserow option) and to `MEDIUM_ICONS` in `layers/diary/app/utils/icons.ts`.