297 lines
17 KiB
Markdown
297 lines
17 KiB
Markdown
# 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/<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_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)`, 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 `<SavePdfButton>` 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/<name>/` with `app/pages/<name>.vue` (or `app/pages/<name>/index.vue`), and whatever else it needs:
|
|
`app/components`, `app/composables`, `server/api/<name>/…`, `shared/`, and a `nuxt.config.ts` for its runtime config.
|
|
2. Keep it contained: serve its API under `/api/<name>/`, 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_<days>_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/<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](#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`.
|