feat: add hub

This commit is contained in:
2026-10-03 23:57:41 +02:00
parent 5452f72087
commit fb7b560696
52 changed files with 290 additions and 162 deletions
+32 -19
View File
@@ -2,9 +2,14 @@
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:
picked up automatically. The UI is German (`lang="de"` and Nuxt UI's German locale in `app/app.vue`; only the CV
has an English version too), and so are the error messages of the API. Every app has a house button back to the hub
and a light / dark switch in its header, both from `<AppHeaderActions>` (one size and style everywhere) (Notizen: in the title row); the
choice of light or dark applies to the whole site. The root only holds what apps share:
- the shell (`app/app.vue`, global CSS, theme) and an empty `/`
- the shell (`app/app.vue`, global CSS, theme) and the hub on `/`: a tile per app (icon, title, a lock
for the protected ones), sorted by title. Each app registers its tile in its own `app/app.config.ts` under
`hub.<name>` (`HubApp` in `shared/types/hub.ts`).
- 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.
@@ -13,7 +18,7 @@ picked up automatically. The root only holds what apps share:
(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)
through the browser's print dialog: names the PDF and prints light also in dark mode; used by CV and Packliste, with the `<SavePdfButton>` under the content)
| Path | App |
|---|---|
@@ -21,7 +26,7 @@ picked up automatically. The root only holds what apps share:
| `/wishlist` | [Wishlist](#wishlist) — `layers/wishlist/` |
| `/notes` | [Notes](#notes) — `layers/notes/` (password protected) |
| `/cv` | [CV](#cv) — `layers/cv/` |
| `/packer` | [Packer](#packer) — `layers/packer/` |
| `/packer` | [Packliste](#packliste) — `layers/packer/` |
```bash
pnpm install
@@ -36,9 +41,13 @@ pnpm dev
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.
3. Add its tile to the hub: `hub: { <name>: { title, icon, to: '/<name>' } }` in its
`app/app.config.ts` (`locked: true` if it is behind the password).
4. Don't use `~/` inside a layer — it points to the root `app/`. Use relative imports.
# Packer
# Packliste
The app is named Packliste everywhere; only its URL, folder and code keep `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.
@@ -77,7 +86,7 @@ The settings are kept in the URL, so a list can be bookmarked or sent.
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`.
settings and the buttons are left out; the file is named `packliste_<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.
@@ -113,13 +122,17 @@ The header (name, description per language, photo, email, LinkedIn link) is set
under `cv`. LinkedIn blocks scraping, so the photo and description are not fetched from there; put the photo in
`layers/cv/public/`.
Next to it a light / dark switch (Nuxt UI's color mode, so it applies to the whole site; the PDF is always light).
The accent color `cv.accent` is used in light mode and in the PDF; dark mode uses the site's amber like the other apps.
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`
`owner/repo` with a GitHub icon, others their address; write `Label https://…` for custom text). On phones the
links (also email, LinkedIn and files) show only their icon, with the text as tooltip. Rows without `From`
are skipped. Baserow returns file URLs as `http://`, so they are rewritten to
`NUXT_BASEROW_URL`.
@@ -127,8 +140,8 @@ are skipped. Baserow returns file URLs as `http://`, so they are rewritten to
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
app.config.ts cv: name, description, photo, email, linkedin, accent
pages/cv.vue light / dark and 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
@@ -154,7 +167,7 @@ Shortcuts (Ctrl instead of ⌘ outside macOS): **⌘ ⌥ N** new note, **⌘ E**
**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.
"Ohne Titel" at the bottom. The token needs read, create, update and delete.
```
layers/notes/
@@ -165,7 +178,7 @@ app/
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/NotesActionBar.vue "Neu", 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
@@ -226,7 +239,7 @@ self-hosted Baserow tables.
| `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_PACKER_TABLE_ID` | Packing table (see [Packliste](#packliste)) |
| `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`) |
@@ -236,10 +249,10 @@ In `layers/diary/app/app.config.ts`, `diary.showCovers: true` shows a generated
## 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;
- Only entries with text are shown. Long text is collapsed behind "Weiterlesen".
- Search matches every word against the kind (`rezension`, `notiz`), the medium (`buch`, `film`, `album`),
the date (`okt 2025`, `2025-08`), the title, the author and the text. Matches in the text are highlighted.
- "Eintragen" opens a form with a Notiz / Rezension 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.
@@ -253,9 +266,9 @@ 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
DiaryTimeline.vue the diary: search, Eintragen button, entries grouped by year
DiaryEntry.vue one timeline entry (date, title, text, read more)
LogModal.vue Note / Review switch around the two forms
LogModal.vue Notiz / Rezension switch around the two forms
NoteForm.vue, ReviewForm.vue
CoverTile.vue generated cover, used when showCovers is on
composables/