230 lines
12 KiB
Markdown
230 lines
12 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)` 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/<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.
|
|
|
|
# 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_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`.
|