# 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)`, the client util `errorMessage(error)` and `usePdfExport(fileName)` ("Save as PDF" through the browser's print dialog: names the PDF and prints light also in dark mode; used by CV and Packer, with the `` under the content) | 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/` | | `/packer` | [Packer](#packer) — `layers/packer/` | ```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. # Packer A public packing list built from the Baserow packing table (`NUXT_BASEROW_PACKER_TABLE_ID`). At the top you set the trip: number of days and people, where you sleep, weather, season and activities. The list below is grouped by category in table order, items sorted alphabetically. The settings are kept in the URL, so a list can be bookmarked or sent. - **Activities** are the options of the table's `activity` field that some item uses, sorted alphabetically. Items with activities are only listed when one of them is picked; items without are always listed. Their icons are set in `layers/packer/app/app.config.ts` under `packer.activityIcons` (a tag icon otherwise). - **Sleeping:** the activities in `packer.stays` (Übernachtung, Camping) aren't picked with the others. On a trip of more than one day a switch picks exactly one of them (the first by default); a day trip has neither. - **Counts:** an item with `every_n_days` is packed once every n days (rounded up), the rest once. With the `packer.laundry` activity (Wäsche waschen) picked, items of the laundry categories (Kleidung) are counted for at most 4 days. - **Automatic activities** (`packer.autoActivities`): Wäsche waschen from 5 days, Duschen from 2 days, Kochen when sleeping is Camping. Each turns on when its condition starts to hold and off when it stops; in between it can be switched by hand. - **Weather:** a switch between the options of the `weather` field that some item uses (Sonnig, Bewölkt, Regnerisch; `packer.defaultWeather` on a fresh list), and next to it toggles for the weathers in `packer.weatherExtras` (Windig), which come on top of the picked one. Items with weathers are listed when one of them applies, empty means any weather. `weather` may be a single or a multiple select. Icons and the switch's order in `packer.weatherIcons`. - **Alternatives:** rows with the same `item` name are listed once, when any of them fits the trip. That is how an item gets "or" conditions, e.g. `Schal/Buff` once with season Winter and once with weather Windig. Ticks, deletions and weight changes belong to the name's first row, so they stick whichever row fits. - **Season:** items with `season` set are only listed in those seasons, empty means always. The season defaults to today's in Germany, by meteorological seasons (spring from 1 March, summer from 1 June, autumn from 1 September, winter from 1 December). - **People:** everything that isn't `sharable` is multiplied by the number of people; `sharable` items are packed once for the group. - **Weight:** once `weight_g` is filled in, each item shows its weight (times its count), and under the progress bar the weight already packed next to the list's total; "≥" means some listed items have no weight yet (hover for how many). - **Session only:** you can tick items off, delete items (✕, on hover with a mouse), add items to a category (fields at the bottom of each category: name and optional weight; added items count once) and change any item's weight (click it; it's the weight of one piece, an empty field goes back to the table's weight, changed weights are shown in the primary color). None of this touches the table: it is kept in the browser tab's `sessionStorage`, survives reloads and is gone when the tab is closed. - **Als PDF speichern** (under the list, as on the CV) prints the list as a checklist (with the browser's "Save as PDF"): the trip in one line under the title (days, people, season, stay, activities, total weight), the categories in two columns, ticked items ticked. The settings and the buttons are left out; the file is named `packer__tage`. - **Zurücksetzen** (shown once anything differs from a fresh list) clears all of that and puts the settings back to their defaults (weights changed on the page go back to the table's too): 1 day, 1 person, today's season, Übernachtung and the automatic activities. **Table:** `item` (text), `category` (single select), `sharable` (boolean), `every_n_days` (number), `weight_g` (number), `season` (multiple select: `Frühling`, `Sommer`, `Herbst`, `Winter`), `activity` (multiple select), `weather` (single or multiple select). Rows without `item` are skipped. The token needs read (rows and fields). ``` layers/packer/ nuxt.config.ts baserowPackerTableId app/ app.config.ts packer: activityIcons, weatherIcons, defaultWeather, weatherExtras, stays, laundry, autoActivities pages/packer.vue settings, progress, reset, the list, PDF button components/PackerSettingsBar.vue days, people, sleeping, weather, season, activities components/PackerCategory.vue one category: tick, delete, add composables/usePacker.ts settings in the URL, session changes, filtering and counts server/ api/packer/index.get.ts GET all items, the activities and the weathers utils/packer.ts Baserow row -> PackerItem shared/types/packer.ts ``` # 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_PACKER_TABLE_ID` | Packing table (see [Packer](#packer)) | | `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`.