feat: add hub
This commit is contained in:
@@ -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)
|
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
|
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`,
|
- 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
|
`listFields`, each taking a table ID). `NUXT_BASEROW_URL` and `NUXT_BASEROW_TOKEN` are shared; each app reads its
|
||||||
own table IDs.
|
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
|
(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.
|
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"
|
- `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 |
|
| Path | App |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -21,7 +26,7 @@ picked up automatically. The root only holds what apps share:
|
|||||||
| `/wishlist` | [Wishlist](#wishlist) — `layers/wishlist/` |
|
| `/wishlist` | [Wishlist](#wishlist) — `layers/wishlist/` |
|
||||||
| `/notes` | [Notes](#notes) — `layers/notes/` (password protected) |
|
| `/notes` | [Notes](#notes) — `layers/notes/` (password protected) |
|
||||||
| `/cv` | [CV](#cv) — `layers/cv/` |
|
| `/cv` | [CV](#cv) — `layers/cv/` |
|
||||||
| `/packer` | [Packer](#packer) — `layers/packer/` |
|
| `/packer` | [Packliste](#packliste) — `layers/packer/` |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install
|
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
|
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
|
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`).
|
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
|
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.
|
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.
|
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
|
- **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
|
(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
|
- **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.
|
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
|
under `cv`. LinkedIn blocks scraping, so the photo and description are not fetched from there; put the photo in
|
||||||
`layers/cv/public/`.
|
`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,
|
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.
|
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),
|
**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
|
`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
|
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
|
are skipped. Baserow returns file URLs as `http://`, so they are rewritten to
|
||||||
`NUXT_BASEROW_URL`.
|
`NUXT_BASEROW_URL`.
|
||||||
|
|
||||||
@@ -127,8 +140,8 @@ are skipped. Baserow returns file URLs as `http://`, so they are rewritten to
|
|||||||
layers/cv/
|
layers/cv/
|
||||||
nuxt.config.ts baserowCvTableId
|
nuxt.config.ts baserowCvTableId
|
||||||
app/
|
app/
|
||||||
app.config.ts cv: name, description, photo, email, linkedin
|
app.config.ts cv: name, description, photo, email, linkedin, accent
|
||||||
pages/cv.vue language switch, header, Experience and Education
|
pages/cv.vue light / dark and language switch, header, Experience and Education
|
||||||
composables/useCvLang.ts current language and the translated texts
|
composables/useCvLang.ts current language and the translated texts
|
||||||
components/CvSection.vue one section: period, duration, area, institution, type, grade
|
components/CvSection.vue one section: period, duration, area, institution, type, grade
|
||||||
utils/cvDate.ts period and duration, open-ended until today
|
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
|
**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
|
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
|
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/
|
layers/notes/
|
||||||
@@ -165,7 +178,7 @@ app/
|
|||||||
components/NotesList.vue the note list (open, 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/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/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
|
components/NotesSidebarHandle.vue desktop: chevron on the sidebar edge, on hover, shows/hides the list
|
||||||
composables/
|
composables/
|
||||||
useNotes.ts the list, create/delete, note names
|
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_NOTES_TABLE_ID` | Notes table |
|
||||||
| `NUXT_BASEROW_WISHLIST_TABLE_ID` | Wishlist table (see [Wishlist](#wishlist)) |
|
| `NUXT_BASEROW_WISHLIST_TABLE_ID` | Wishlist table (see [Wishlist](#wishlist)) |
|
||||||
| `NUXT_BASEROW_CV_TABLE_ID` | CV table (see [CV](#cv)) |
|
| `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_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_DIARY_PASSWORD` | Password that unlocks the diary and notes |
|
||||||
| `NUXT_SESSION_SECRET` | Signs the login cookie, at least 32 characters (`openssl rand -hex 32`) |
|
| `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
|
## What it does
|
||||||
|
|
||||||
- `/diary`: reviews and notes on one timeline, newest first, grouped by year.
|
- `/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".
|
- Only entries with text are shown. Long text is collapsed behind "Weiterlesen".
|
||||||
- Search matches every word against the kind (`review`, `note`), the medium (`book`, `movie`, `album`),
|
- Search matches every word against the kind (`rezension`, `notiz`), the medium (`buch`, `film`, `album`),
|
||||||
the date (`aug 2025`, `2025-08`), the title, the author and the text. Matches in the text are highlighted.
|
the date (`okt 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;
|
- "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`.
|
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
|
- 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.
|
(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
|
app.config.ts diary.showCovers
|
||||||
pages/diary.vue password gate around the timeline
|
pages/diary.vue password gate around the timeline
|
||||||
components/
|
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)
|
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
|
NoteForm.vue, ReviewForm.vue
|
||||||
CoverTile.vue generated cover, used when showCovers is on
|
CoverTile.vue generated cover, used when showCovers is on
|
||||||
composables/
|
composables/
|
||||||
|
|||||||
+3
-1
@@ -4,5 +4,7 @@ export default defineAppConfig({
|
|||||||
primary: 'amber',
|
primary: 'amber',
|
||||||
neutral: 'stone'
|
neutral: 'stone'
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
/** Tiles of the hub on `/`; each app adds its own in its layer's app.config.ts, keyed by the app name */
|
||||||
|
hub: {} as Record<string, HubApp>
|
||||||
})
|
})
|
||||||
|
|||||||
+5
-1
@@ -1,5 +1,9 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { de } from '@nuxt/ui/locale'
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<UApp>
|
<UApp :locale="de">
|
||||||
<NuxtPage />
|
<NuxtPage />
|
||||||
</UApp>
|
</UApp>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// The buttons in every app's header: back to the hub and light / dark. One size and style everywhere; don't override it.
|
||||||
|
withDefaults(defineProps<{ hubLink?: boolean }>(), { hubLink: true })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex shrink-0 items-center gap-1 print:hidden">
|
||||||
|
<UButton v-if="hubLink" to="/" icon="i-lucide-house" color="neutral" variant="ghost" aria-label="Zur Übersicht" title="huaqo.de" />
|
||||||
|
<UColorModeButton color="neutral" variant="ghost" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -14,7 +14,7 @@ async function unlock() {
|
|||||||
await $fetch('/api/auth/login', { method: 'POST', body: { password: password.value } })
|
await $fetch('/api/auth/login', { method: 'POST', body: { password: password.value } })
|
||||||
emit('unlocked')
|
emit('unlocked')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = errorMessage(e) ?? 'Couldn\'t unlock'
|
error.value = errorMessage(e) ?? 'Entsperren fehlgeschlagen'
|
||||||
password.value = ''
|
password.value = ''
|
||||||
} finally {
|
} finally {
|
||||||
unlocking.value = false
|
unlocking.value = false
|
||||||
@@ -23,13 +23,13 @@ async function unlock() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<UModal :open="true" :dismissible="false" :close="false" :title="title" description="Enter your password to continue.">
|
<UModal :open="true" :dismissible="false" :close="false" :title="title" description="Passwort eingeben, um fortzufahren.">
|
||||||
<template #body>
|
<template #body>
|
||||||
<form class="space-y-4" @submit.prevent="unlock">
|
<form class="space-y-4" @submit.prevent="unlock">
|
||||||
<UFormField label="Password" :error="error">
|
<UFormField label="Passwort" :error="error">
|
||||||
<UInput v-model="password" type="password" autocomplete="current-password" class="w-full" autofocus />
|
<UInput v-model="password" type="password" autocomplete="current-password" class="w-full" autofocus />
|
||||||
</UFormField>
|
</UFormField>
|
||||||
<UButton type="submit" label="Unlock" icon="i-lucide-lock-open" :loading="unlocking" :disabled="!password" />
|
<UButton type="submit" label="Entsperren" icon="i-lucide-lock-open" :loading="unlocking" :disabled="!password" />
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
</UModal>
|
</UModal>
|
||||||
|
|||||||
+35
-1
@@ -1,3 +1,37 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
useHead({ title: 'huaqo.de' })
|
||||||
|
|
||||||
|
const { hub } = useAppConfig()
|
||||||
|
|
||||||
|
const apps = computed(() => Object.values(hub as Record<string, HubApp>).sort((a, b) => a.title.localeCompare(b.title, 'de')))
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div />
|
<UMain>
|
||||||
|
<UContainer class="max-w-3xl py-8">
|
||||||
|
<div class="mb-6 flex items-center justify-between gap-3">
|
||||||
|
<h1 class="text-2xl font-semibold text-highlighted">
|
||||||
|
huaqo.de
|
||||||
|
</h1>
|
||||||
|
<AppHeaderActions :hub-link="false" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="grid gap-3 sm:grid-cols-2">
|
||||||
|
<li v-for="app in apps" :key="app.to">
|
||||||
|
<NuxtLink
|
||||||
|
:to="app.to"
|
||||||
|
class="group flex h-full items-center gap-4 rounded-lg border border-default p-4 transition-colors hover:border-primary hover:bg-elevated/50 focus-visible:outline-2 focus-visible:outline-primary"
|
||||||
|
>
|
||||||
|
<span class="flex size-10 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
|
||||||
|
<UIcon :name="app.icon" class="size-5" />
|
||||||
|
</span>
|
||||||
|
<span class="flex min-w-0 items-center gap-1.5 font-medium text-highlighted">
|
||||||
|
{{ app.title }}
|
||||||
|
<UIcon v-if="app.locked" name="i-lucide-lock" class="size-3.5 text-muted" aria-label="Passwortgeschützt" />
|
||||||
|
</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</UContainer>
|
||||||
|
</UMain>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ export default defineAppConfig({
|
|||||||
/** Printed with the date at the bottom of the PDF, e.g. "Berlin, 02.10.2026" */
|
/** Printed with the date at the bottom of the PDF, e.g. "Berlin, 02.10.2026" */
|
||||||
city: 'Berlin',
|
city: 'Berlin',
|
||||||
linkedin: 'https://linkedin.com/in/joaquingottlebe',
|
linkedin: 'https://linkedin.com/in/joaquingottlebe',
|
||||||
/** Accent color of the CV page (links, active language); the rest of the site keeps its primary color */
|
/** Accent color of the CV page in light mode and in the PDF (links, active language); dark mode uses the site's primary color */
|
||||||
accent: 'rgb(53 116 110)'
|
accent: 'rgb(53 116 110)'
|
||||||
|
},
|
||||||
|
hub: {
|
||||||
|
cv: { title: 'Lebenslauf', icon: 'i-lucide-file-user', to: '/cv' }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -38,10 +38,13 @@ const { lang, t } = useCvLang()
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
:icon="link.kind === 'github' ? 'i-lucide-github' : 'i-lucide-globe'"
|
:icon="link.kind === 'github' ? 'i-lucide-github' : 'i-lucide-globe'"
|
||||||
:label="link.label"
|
:label="link.label"
|
||||||
|
:title="link.label"
|
||||||
|
:aria-label="link.label"
|
||||||
color="primary"
|
color="primary"
|
||||||
variant="link"
|
variant="link"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="p-0 print:hidden"
|
class="p-0 print:hidden"
|
||||||
|
:ui="CV_LINK_UI"
|
||||||
/>
|
/>
|
||||||
<a
|
<a
|
||||||
v-for="link in group"
|
v-for="link in group"
|
||||||
@@ -58,10 +61,13 @@ const { lang, t } = useCvLang()
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
icon="i-lucide-file-down"
|
icon="i-lucide-file-down"
|
||||||
:label="file.name"
|
:label="file.name"
|
||||||
|
:title="file.name"
|
||||||
|
:aria-label="file.name"
|
||||||
color="primary"
|
color="primary"
|
||||||
variant="link"
|
variant="link"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="p-0"
|
class="p-0"
|
||||||
|
:ui="CV_LINK_UI"
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
+21
-11
@@ -1,7 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// Always light: white background, also when the device is in dark mode
|
|
||||||
definePageMeta({ colorMode: 'light' })
|
|
||||||
|
|
||||||
const { cv: profile } = useAppConfig()
|
const { cv: profile } = useAppConfig()
|
||||||
const { lang, t } = useCvLang()
|
const { lang, t } = useCvLang()
|
||||||
|
|
||||||
@@ -33,10 +30,11 @@ const experience = computed(() => entries.value.filter(entry => !EDUCATION.inclu
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<UMain :style="{ '--ui-primary': profile.accent }">
|
<UMain class="cv" :style="{ '--cv-accent': profile.accent }">
|
||||||
<UContainer class="max-w-3xl py-8 print:max-w-none print:px-[12mm] print:pt-[15mm] print:pb-0">
|
<UContainer class="max-w-3xl py-8 print:max-w-none print:px-[12mm] print:pt-[15mm] print:pb-0">
|
||||||
<div class="mb-6 flex justify-end print:hidden">
|
<div class="mb-6 flex justify-end gap-2 print:hidden">
|
||||||
<UFieldGroup size="xs">
|
<AppHeaderActions />
|
||||||
|
<UFieldGroup>
|
||||||
<UButton
|
<UButton
|
||||||
v-for="option in (['de', 'en'] as const)"
|
v-for="option in (['de', 'en'] as const)"
|
||||||
:key="option"
|
:key="option"
|
||||||
@@ -49,37 +47,44 @@ const experience = computed(() => entries.value.filter(entry => !EDUCATION.inclu
|
|||||||
</UFieldGroup>
|
</UFieldGroup>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<header class="mb-10 flex items-center gap-5">
|
<header class="mb-10">
|
||||||
<UAvatar v-if="profile.photo" :src="profile.photo" :alt="profile.name || t.photo" class="size-20 shrink-0" />
|
<UAvatar v-if="profile.photo" :src="profile.photo" :alt="profile.name || t.photo" class="mb-4 size-20" />
|
||||||
<div class="min-w-0">
|
<!-- Under the photo: name, description, links -->
|
||||||
<h1 class="text-2xl font-semibold text-highlighted">
|
<h1 class="text-2xl font-semibold text-highlighted">
|
||||||
{{ profile.name || 'CV' }}
|
{{ profile.name || 'CV' }}
|
||||||
</h1>
|
</h1>
|
||||||
<p v-if="profile.description[lang]" class="mt-1 text-sm text-muted">
|
<p v-if="profile.description[lang]" class="mt-1 text-sm text-muted">
|
||||||
{{ profile.description[lang] }}
|
{{ profile.description[lang] }}
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-1 flex flex-wrap gap-x-4">
|
<div class="mt-3 flex flex-wrap gap-x-4 gap-y-1">
|
||||||
<UButton
|
<UButton
|
||||||
v-if="profile.email"
|
v-if="profile.email"
|
||||||
:to="`mailto:${profile.email}`"
|
:to="`mailto:${profile.email}`"
|
||||||
|
icon="i-lucide-mail"
|
||||||
:label="profile.email"
|
:label="profile.email"
|
||||||
|
:title="profile.email"
|
||||||
|
:aria-label="profile.email"
|
||||||
color="primary"
|
color="primary"
|
||||||
variant="link"
|
variant="link"
|
||||||
class="p-0"
|
class="p-0"
|
||||||
|
:ui="CV_LINK_UI"
|
||||||
/>
|
/>
|
||||||
<UButton
|
<UButton
|
||||||
v-if="profile.linkedin"
|
v-if="profile.linkedin"
|
||||||
:to="profile.linkedin"
|
:to="profile.linkedin"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
icon="i-lucide-linkedin"
|
||||||
label="LinkedIn"
|
label="LinkedIn"
|
||||||
|
title="LinkedIn"
|
||||||
|
aria-label="LinkedIn"
|
||||||
color="primary"
|
color="primary"
|
||||||
variant="link"
|
variant="link"
|
||||||
class="p-0 print:hidden"
|
class="p-0 print:hidden"
|
||||||
|
:ui="CV_LINK_UI"
|
||||||
/>
|
/>
|
||||||
<!-- In the PDF links are written out, since the label alone can't be followed on paper -->
|
<!-- In the PDF links are written out, since the label alone can't be followed on paper -->
|
||||||
<a v-if="profile.linkedin" :href="profile.linkedin" class="hidden text-sm font-medium text-primary print:inline">{{ cvLinkText(profile.linkedin) }}</a>
|
<a v-if="profile.linkedin" :href="profile.linkedin" class="hidden text-sm font-medium text-primary print:inline">{{ cvLinkText(profile.linkedin) }}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<UAlert
|
<UAlert
|
||||||
@@ -109,6 +114,11 @@ const experience = computed(() => entries.value.filter(entry => !EDUCATION.inclu
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
/* The CV's accent instead of the site's primary color in light mode (and so in the PDF); dark mode keeps the site's */
|
||||||
|
html:not(.dark) .cv {
|
||||||
|
--ui-primary: var(--cv-accent);
|
||||||
|
}
|
||||||
|
|
||||||
/* Top and bottom of every printed page. The sides and the extra space above the first page come from the container's
|
/* Top and bottom of every printed page. The sides and the extra space above the first page come from the container's
|
||||||
print padding, which also works in browsers that ignore @page margins */
|
print padding, which also works in browsers that ignore @page margins */
|
||||||
@page {
|
@page {
|
||||||
|
|||||||
@@ -2,3 +2,6 @@
|
|||||||
export function cvLinkText(url: string): string {
|
export function cvLinkText(url: string): string {
|
||||||
return url.replace(/^https?:\/\//, '').replace(/\/$/, '')
|
return url.replace(/^https?:\/\//, '').replace(/\/$/, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Link buttons show only their icon on phones, where long labels don't fit; the label is then the tooltip and aria-label. The PDF shows the text without the icon (only the email is printed this way) */
|
||||||
|
export const CV_LINK_UI = { label: 'hidden sm:inline print:inline', leadingIcon: 'print:hidden' }
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export interface CvRow {
|
|||||||
export function cvTable(event: H3Event): string {
|
export function cvTable(event: H3Event): string {
|
||||||
const { baserowCvTableId } = useRuntimeConfig(event)
|
const { baserowCvTableId } = useRuntimeConfig(event)
|
||||||
if (!baserowCvTableId) {
|
if (!baserowCvTableId) {
|
||||||
throw createError({ statusCode: 500, statusMessage: 'NUXT_BASEROW_CV_TABLE_ID is not set' })
|
throw createError({ statusCode: 500, statusMessage: 'NUXT_BASEROW_CV_TABLE_ID ist nicht gesetzt' })
|
||||||
}
|
}
|
||||||
return baserowCvTableId
|
return baserowCvTableId
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
export default defineAppConfig({
|
export default defineAppConfig({
|
||||||
diary: {
|
diary: {
|
||||||
showCovers: false
|
showCovers: false
|
||||||
|
},
|
||||||
|
hub: {
|
||||||
|
diary: { title: 'Tagebuch', icon: 'i-lucide-book-open', to: '/diary', locked: true }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const background = computed(() => {
|
|||||||
class="relative size-full overflow-hidden text-white [container-type:inline-size]"
|
class="relative size-full overflow-hidden text-white [container-type:inline-size]"
|
||||||
:style="{ background }"
|
:style="{ background }"
|
||||||
role="img"
|
role="img"
|
||||||
:aria-label="`Cover of ${title}`"
|
:aria-label="`Cover von ${title}`"
|
||||||
>
|
>
|
||||||
<span class="absolute inset-y-0 left-[7cqw] w-px bg-white/25" aria-hidden="true" />
|
<span class="absolute inset-y-0 left-[7cqw] w-px bg-white/25" aria-hidden="true" />
|
||||||
<div class="flex size-full flex-col justify-between gap-1 p-[8cqw] pl-[12cqw]">
|
<div class="flex size-full flex-col justify-between gap-1 p-[8cqw] pl-[12cqw]">
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ const parts = computed(() => highlight(props.entry.text, props.search))
|
|||||||
color="neutral"
|
color="neutral"
|
||||||
variant="link"
|
variant="link"
|
||||||
class="mt-1 px-0"
|
class="mt-1 px-0"
|
||||||
:label="expanded ? 'Show less' : `Read more (${wordCount} words)`"
|
:label="expanded ? 'Weniger anzeigen' : `Weiterlesen (${wordCount} Wörter)`"
|
||||||
@click="expanded = !expanded"
|
@click="expanded = !expanded"
|
||||||
/>
|
/>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ const log = useLog()
|
|||||||
<template>
|
<template>
|
||||||
<div class="mx-auto max-w-3xl">
|
<div class="mx-auto max-w-3xl">
|
||||||
<div class="mb-8 flex flex-wrap items-center justify-between gap-3">
|
<div class="mb-8 flex flex-wrap items-center justify-between gap-3">
|
||||||
<h1 class="text-2xl font-semibold text-highlighted">Diary</h1>
|
<h1 class="text-2xl font-semibold text-highlighted">Tagebuch</h1>
|
||||||
<div class="flex w-full items-center gap-2 sm:w-auto">
|
<div class="flex w-full items-center gap-2 sm:w-auto">
|
||||||
<UInput v-model="search" icon="i-lucide-search" placeholder="Search the diary" class="flex-1 sm:w-64 sm:flex-none" />
|
<UInput v-model="search" icon="i-lucide-search" placeholder="Tagebuch durchsuchen" class="flex-1 sm:w-64 sm:flex-none" />
|
||||||
<UButton icon="i-lucide-plus" label="Log" @click="log.show()" />
|
<UButton icon="i-lucide-plus" label="Eintragen" @click="log.show()" />
|
||||||
|
<AppHeaderActions />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -20,7 +21,7 @@ const log = useLog()
|
|||||||
color="error"
|
color="error"
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
icon="i-lucide-triangle-alert"
|
icon="i-lucide-triangle-alert"
|
||||||
:title="`Couldn't load your ${what}`"
|
:title="`${what} konnten nicht geladen werden`"
|
||||||
:description="errorMessage(error)"
|
:description="errorMessage(error)"
|
||||||
class="mb-6"
|
class="mb-6"
|
||||||
/>
|
/>
|
||||||
@@ -38,7 +39,7 @@ const log = useLog()
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<p v-if="!years.length && !errors.length" class="py-16 text-center text-muted">
|
<p v-if="!years.length && !errors.length" class="py-16 text-center text-muted">
|
||||||
{{ search ? 'Nothing in the diary matches your search.' : 'Nothing logged yet.' }}
|
{{ search ? 'Nichts im Tagebuch passt zur Suche.' : 'Noch nichts eingetragen.' }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
const { open, close } = useLog()
|
const { open, close } = useLog()
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ label: 'Note', value: 'note', icon: NOTE_ICON, slot: 'note' as const },
|
{ label: 'Notiz', value: 'note', icon: NOTE_ICON, slot: 'note' as const },
|
||||||
{ label: 'Review', value: 'review', icon: REVIEW_ICON, slot: 'review' as const }
|
{ label: 'Rezension', value: 'review', icon: REVIEW_ICON, slot: 'review' as const }
|
||||||
]
|
]
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<UModal v-model:open="open" title="Log">
|
<UModal v-model:open="open" title="Eintragen">
|
||||||
<template #body>
|
<template #body>
|
||||||
<UTabs :items="tabs" default-value="note" class="w-full" :ui="{ content: 'pt-4' }">
|
<UTabs :items="tabs" default-value="note" class="w-full" :ui="{ content: 'pt-4' }">
|
||||||
<template #note>
|
<template #note>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { FormSubmitEvent } from '@nuxt/ui'
|
|||||||
const emit = defineEmits<{ saved: [] }>()
|
const emit = defineEmits<{ saved: [] }>()
|
||||||
|
|
||||||
const state = reactive({ date: todayIso(), text: '' })
|
const state = reactive({ date: todayIso(), text: '' })
|
||||||
const { saving, save } = useSaveEntry({ url: '/api/diary/notes', dataKey: 'diary-notes', noun: 'note', onSaved: () => emit('saved') })
|
const { saving, save } = useSaveEntry({ url: '/api/diary/notes', dataKey: 'diary-notes', saved: 'Notiz gespeichert', failed: 'Notiz konnte nicht gespeichert werden', onSaved: () => emit('saved') })
|
||||||
|
|
||||||
function onSubmit(event: FormSubmitEvent<NewNoteInput>) {
|
function onSubmit(event: FormSubmitEvent<NewNoteInput>) {
|
||||||
return save(event.data)
|
return save(event.data)
|
||||||
@@ -13,14 +13,14 @@ function onSubmit(event: FormSubmitEvent<NewNoteInput>) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<UForm :schema="newNoteSchema" :state="state" class="space-y-5" @submit="onSubmit">
|
<UForm :schema="newNoteSchema" :state="state" class="space-y-5" @submit="onSubmit">
|
||||||
<UFormField label="Date" name="date" required>
|
<UFormField label="Datum" name="date" required>
|
||||||
<UInput v-model="state.date" type="date" />
|
<UInput v-model="state.date" type="date" />
|
||||||
</UFormField>
|
</UFormField>
|
||||||
|
|
||||||
<UFormField label="Note" name="text" required>
|
<UFormField label="Notiz" name="text" required>
|
||||||
<UTextarea v-model="state.text" :rows="8" autoresize :maxrows="18" class="w-full" autofocus />
|
<UTextarea v-model="state.text" :rows="8" autoresize :maxrows="18" class="w-full" autofocus />
|
||||||
</UFormField>
|
</UFormField>
|
||||||
|
|
||||||
<UButton type="submit" label="Save note" :icon="NOTE_ICON" :loading="saving" />
|
<UButton type="submit" label="Notiz speichern" :icon="NOTE_ICON" :loading="saving" />
|
||||||
</UForm>
|
</UForm>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ const state = reactive({ bookId: undefined as number | undefined, date: todayIso
|
|||||||
const { saving, save } = useSaveEntry({
|
const { saving, save } = useSaveEntry({
|
||||||
url: '/api/diary/reviews',
|
url: '/api/diary/reviews',
|
||||||
dataKey: ['diary-reviews', 'diary-books'],
|
dataKey: ['diary-reviews', 'diary-books'],
|
||||||
noun: 'review',
|
saved: 'Rezension gespeichert',
|
||||||
|
failed: 'Rezension konnte nicht gespeichert werden',
|
||||||
onSaved: () => emit('saved')
|
onSaved: () => emit('saved')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -34,29 +35,29 @@ function onSubmit(event: FormSubmitEvent<NewReviewInput>) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<UForm :schema="newReviewSchema" :state="state" class="space-y-5" @submit="onSubmit">
|
<UForm :schema="newReviewSchema" :state="state" class="space-y-5" @submit="onSubmit">
|
||||||
<UFormField label="Book" name="bookId" required>
|
<UFormField label="Buch" name="bookId" required>
|
||||||
<USelectMenu
|
<USelectMenu
|
||||||
v-model="state.bookId"
|
v-model="state.bookId"
|
||||||
:items="bookItems"
|
:items="bookItems"
|
||||||
value-key="value"
|
value-key="value"
|
||||||
:filter-fields="['label', 'description', 'originalTitle']"
|
:filter-fields="['label', 'description', 'originalTitle']"
|
||||||
:loading="status === 'pending'"
|
:loading="status === 'pending'"
|
||||||
:search-input="{ placeholder: 'Title or author' }"
|
:search-input="{ placeholder: 'Titel oder Autor' }"
|
||||||
placeholder="Pick a book"
|
placeholder="Buch auswählen"
|
||||||
icon="i-lucide-book-open"
|
icon="i-lucide-book-open"
|
||||||
virtualize
|
virtualize
|
||||||
class="w-full"
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
</UFormField>
|
</UFormField>
|
||||||
|
|
||||||
<UFormField label="Read on" name="date" help="Saved as the book's read date" required>
|
<UFormField label="Gelesen am" name="date" help="Wird als Lesedatum des Buchs gespeichert" required>
|
||||||
<UInput v-model="state.date" type="date" />
|
<UInput v-model="state.date" type="date" />
|
||||||
</UFormField>
|
</UFormField>
|
||||||
|
|
||||||
<UFormField label="Review" name="text" required>
|
<UFormField label="Rezension" name="text" required>
|
||||||
<UTextarea v-model="state.text" :rows="8" autoresize :maxrows="18" class="w-full" />
|
<UTextarea v-model="state.text" :rows="8" autoresize :maxrows="18" class="w-full" />
|
||||||
</UFormField>
|
</UFormField>
|
||||||
|
|
||||||
<UButton type="submit" label="Save review" :icon="REVIEW_ICON" :loading="saving" />
|
<UButton type="submit" label="Rezension speichern" :icon="REVIEW_ICON" :loading="saving" />
|
||||||
</UForm>
|
</UForm>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ function toEntries(reviews: Review[], notes: Note[]): Entry[] {
|
|||||||
function searchableText(entry: Entry): string {
|
function searchableText(entry: Entry): string {
|
||||||
const date = `${formatDate(entry.date)} ${entry.date}`
|
const date = `${formatDate(entry.date)} ${entry.date}`
|
||||||
const text = entry.kind === 'review'
|
const text = entry.kind === 'review'
|
||||||
? `review ${entry.review.medium ?? ''} ${date} ${entry.review.title} ${entry.review.author ?? ''} ${entry.text}`
|
? `rezension ${entry.review.medium ? MEDIUM_NAMES[entry.review.medium] : ''} ${date} ${entry.review.title} ${entry.review.author ?? ''} ${entry.text}`
|
||||||
: `note ${date} ${entry.text}`
|
: `notiz ${date} ${entry.text}`
|
||||||
return text.toLowerCase()
|
return text.toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,8 +55,8 @@ export function useDiary(search: Ref<string>) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const errors = computed(() => [
|
const errors = computed(() => [
|
||||||
{ what: 'reviews', error: reviewsError.value },
|
{ what: 'Rezensionen', error: reviewsError.value },
|
||||||
{ what: 'notes', error: notesError.value }
|
{ what: 'Notizen', error: notesError.value }
|
||||||
].filter(({ error }) => error))
|
].filter(({ error }) => error))
|
||||||
|
|
||||||
return { years, errors }
|
return { years, errors }
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
function capitalize(word: string): string {
|
export function useSaveEntry(options: { url: string, dataKey: string | string[], saved: string, failed: string, onSaved: () => void }) {
|
||||||
return word.charAt(0).toUpperCase() + word.slice(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSaveEntry(options: { url: string, dataKey: string | string[], noun: string, onSaved: () => void }) {
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
|
|
||||||
@@ -11,11 +7,11 @@ export function useSaveEntry(options: { url: string, dataKey: string | string[],
|
|||||||
try {
|
try {
|
||||||
await $fetch(options.url, { method: 'POST', body })
|
await $fetch(options.url, { method: 'POST', body })
|
||||||
await refreshNuxtData(options.dataKey)
|
await refreshNuxtData(options.dataKey)
|
||||||
toast.add({ title: `${capitalize(options.noun)} saved`, color: 'success', icon: 'i-lucide-check' })
|
toast.add({ title: options.saved, color: 'success', icon: 'i-lucide-check' })
|
||||||
options.onSaved()
|
options.onSaved()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.add({
|
toast.add({
|
||||||
title: `Couldn't save the ${options.noun}`,
|
title: options.failed,
|
||||||
description: errorMessage(error),
|
description: errorMessage(error),
|
||||||
color: 'error',
|
color: 'error',
|
||||||
icon: 'i-lucide-triangle-alert'
|
icon: 'i-lucide-triangle-alert'
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
useHead({ title: 'Diary' })
|
useHead({ title: 'Tagebuch' })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<PasswordGate title="Diary">
|
<PasswordGate title="Tagebuch">
|
||||||
<UMain>
|
<UMain>
|
||||||
<UContainer class="py-8">
|
<UContainer class="py-8">
|
||||||
<DiaryTimeline />
|
<DiaryTimeline />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export function formatDate(iso: string): string {
|
export function formatDate(iso: string): string {
|
||||||
return new Date(`${iso}T00:00:00`).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })
|
return new Date(`${iso}T00:00:00`).toLocaleDateString('de-DE', { day: 'numeric', month: 'short', year: 'numeric' })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function todayIso(): string {
|
export function todayIso(): string {
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
export const NOTE_ICON = 'i-lucide-notebook-pen'
|
export const NOTE_ICON = 'i-lucide-notebook-pen'
|
||||||
export const REVIEW_ICON = 'i-lucide-star'
|
export const REVIEW_ICON = 'i-lucide-star'
|
||||||
|
|
||||||
|
/** German names of the media, shown and searched (MEDIUMS' labels are the Baserow options) */
|
||||||
|
export const MEDIUM_NAMES: Record<Medium, string> = {
|
||||||
|
book: 'Buch',
|
||||||
|
movie: 'Film',
|
||||||
|
album: 'Album'
|
||||||
|
}
|
||||||
|
|
||||||
export const MEDIUM_ICONS: Record<Medium, string> = {
|
export const MEDIUM_ICONS: Record<Medium, string> = {
|
||||||
book: 'i-lucide-book-open',
|
book: 'i-lucide-book-open',
|
||||||
movie: 'i-lucide-clapperboard',
|
movie: 'i-lucide-clapperboard',
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export function diaryTable(event: H3Event, table: 'reviews' | 'notes'): string {
|
|||||||
const config = useRuntimeConfig(event)
|
const config = useRuntimeConfig(event)
|
||||||
const id = { reviews: config.baserowReviewsTableId, notes: config.baserowNotesTableId }[table]
|
const id = { reviews: config.baserowReviewsTableId, notes: config.baserowNotesTableId }[table]
|
||||||
if (!id) {
|
if (!id) {
|
||||||
throw createError({ statusCode: 500, statusMessage: `NUXT_BASEROW_${table.toUpperCase()}_TABLE_ID is not set` })
|
throw createError({ statusCode: 500, statusMessage: `NUXT_BASEROW_${table.toUpperCase()}_TABLE_ID ist nicht gesetzt` })
|
||||||
}
|
}
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export async function mediumOptionId(event: H3Event, medium: Medium): Promise<nu
|
|||||||
const options = (await fields(event)).find(field => field.name === 'medium')?.select_options ?? []
|
const options = (await fields(event)).find(field => field.name === 'medium')?.select_options ?? []
|
||||||
const id = options.find(option => toMedium(option.value) === medium)?.id
|
const id = options.find(option => toMedium(option.value) === medium)?.id
|
||||||
if (!id) {
|
if (!id) {
|
||||||
throw createError({ statusCode: 500, statusMessage: `The medium field has no "${MEDIUMS[medium].label}" option` })
|
throw createError({ statusCode: 500, statusMessage: `Das Feld medium hat keine Option "${MEDIUMS[medium].label}"` })
|
||||||
}
|
}
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
@@ -69,7 +69,7 @@ export async function mediumOptionId(event: H3Event, medium: Medium): Promise<nu
|
|||||||
export async function booksTableId(event: H3Event): Promise<string> {
|
export async function booksTableId(event: H3Event): Promise<string> {
|
||||||
const id = (await fields(event)).find(field => field.name === 'books')?.link_row_table_id
|
const id = (await fields(event)).find(field => field.name === 'books')?.link_row_table_id
|
||||||
if (!id) {
|
if (!id) {
|
||||||
throw createError({ statusCode: 500, statusMessage: 'The reviews table has no "books" link field' })
|
throw createError({ statusCode: 500, statusMessage: 'Die Rezensionen-Tabelle hat kein Verknüpfungsfeld "books"' })
|
||||||
}
|
}
|
||||||
return String(id)
|
return String(id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Pick a date')
|
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Datum auswählen')
|
||||||
const requiredText = z.string().trim().min(1, 'Write something first')
|
const requiredText = z.string().trim().min(1, 'Erst etwas schreiben')
|
||||||
|
|
||||||
export const newNoteSchema = z.object({
|
export const newNoteSchema = z.object({
|
||||||
date: isoDate,
|
date: isoDate,
|
||||||
@@ -9,7 +9,7 @@ export const newNoteSchema = z.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const newReviewSchema = z.object({
|
export const newReviewSchema = z.object({
|
||||||
bookId: z.number({ error: 'Pick a book' }).int().positive('Pick a book'),
|
bookId: z.number({ error: 'Buch auswählen' }).int().positive('Buch auswählen'),
|
||||||
date: isoDate,
|
date: isoDate,
|
||||||
text: requiredText
|
text: requiredText
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export default defineAppConfig({
|
||||||
|
hub: {
|
||||||
|
notes: { title: 'Notizen', icon: 'i-lucide-notebook-pen', to: '/notes', locked: true }
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// "New", bottom right.
|
// "Neu", bottom right.
|
||||||
// The list opens with NotesDrawer (mobile) and NotesSidebarHandle (desktop).
|
// The list opens with NotesDrawer (mobile) and NotesSidebarHandle (desktop).
|
||||||
const { creating, createAndOpen } = useNewNote()
|
const { creating, createAndOpen } = useNewNote()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="pointer-events-none fixed inset-x-0 bottom-0 z-40 flex justify-end px-4 pb-[max(1rem,env(safe-area-inset-bottom))] md:px-6 md:pb-6">
|
<div class="pointer-events-none fixed inset-x-0 bottom-0 z-40 flex justify-end px-4 pb-[max(1rem,env(safe-area-inset-bottom))] md:px-6 md:pb-6">
|
||||||
<UTooltip text="New note" :kbds="['meta', 'alt', 'n']">
|
<UTooltip text="Neue Notiz" :kbds="['meta', 'alt', 'n']">
|
||||||
<UButton
|
<UButton
|
||||||
icon="i-lucide-square-pen"
|
icon="i-lucide-square-pen"
|
||||||
label="New"
|
label="Neu"
|
||||||
size="lg"
|
size="lg"
|
||||||
class="pointer-events-auto rounded-full px-5 shadow-lg"
|
class="pointer-events-auto rounded-full px-5 shadow-lg"
|
||||||
:loading="creating"
|
:loading="creating"
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ function onTouchMove(event: TouchEvent) {
|
|||||||
<div class="md:hidden">
|
<div class="md:hidden">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Show notes"
|
aria-label="Notizen anzeigen"
|
||||||
class="fixed inset-x-0 top-0 z-30 flex h-7 touch-none items-start justify-center pt-2"
|
class="fixed inset-x-0 top-0 z-30 flex h-7 touch-none items-start justify-center pt-2"
|
||||||
@click="drawerOpen = true"
|
@click="drawerOpen = true"
|
||||||
@touchstart.passive="onTouchStart"
|
@touchstart.passive="onTouchStart"
|
||||||
@@ -33,7 +33,11 @@ function onTouchMove(event: TouchEvent) {
|
|||||||
<span class="h-1.5 w-12 rounded-full bg-accented" />
|
<span class="h-1.5 w-12 rounded-full bg-accented" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<UDrawer v-model:open="drawerOpen" direction="top" title="Notes" :ui="{ content: 'max-h-[85dvh]', body: 'overflow-y-auto p-0 sm:p-0' }">
|
<UDrawer v-model:open="drawerOpen" direction="top" title="Notizen" :ui="{ content: 'max-h-[85dvh]', body: 'overflow-y-auto p-0 sm:p-0', header: 'px-2' }">
|
||||||
|
<!-- In the title row, right aligned; the header is inset like the list's search bar (px-2), so they line up -->
|
||||||
|
<template #actions>
|
||||||
|
<AppHeaderActions />
|
||||||
|
</template>
|
||||||
<template #body>
|
<template #body>
|
||||||
<NotesList />
|
<NotesList />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -14,20 +14,21 @@ const route = useRoute()
|
|||||||
<UInput
|
<UInput
|
||||||
v-model="query"
|
v-model="query"
|
||||||
icon="i-lucide-search"
|
icon="i-lucide-search"
|
||||||
placeholder="Search"
|
placeholder="Suchen"
|
||||||
:loading="searching"
|
:loading="searching"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
@keydown.esc="query = ''"
|
@keydown.esc="query = ''"
|
||||||
>
|
>
|
||||||
<template v-if="query" #trailing>
|
<template v-if="query" #trailing>
|
||||||
<UButton icon="i-lucide-x" color="neutral" variant="link" size="sm" aria-label="Clear search" @click="query = ''" />
|
<UButton icon="i-lucide-x" color="neutral" variant="link" size="sm" aria-label="Suche leeren" @click="query = ''" />
|
||||||
</template>
|
</template>
|
||||||
</UInput>
|
</UInput>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="error" class="px-4 text-sm text-error">Couldn't load the notes: {{ errorMessage(error) }}</p>
|
<p v-if="error" class="px-2 text-sm text-error md:px-4">Notizen konnten nicht geladen werden: {{ errorMessage(error) }}</p>
|
||||||
|
|
||||||
<nav class="px-2 pb-4">
|
<!-- On mobile the titles and trash icons line up with the search bar's edges (the highlight reaches past them) -->
|
||||||
|
<nav class="pb-4 md:px-2">
|
||||||
<!-- The delete button shows on hover on desktop (instead of the date), always on mobile -->
|
<!-- The delete button shows on hover on desktop (instead of the date), always on mobile -->
|
||||||
<div v-for="doc in docs" :key="doc.id" class="group relative">
|
<div v-for="doc in docs" :key="doc.id" class="group relative">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
@@ -44,15 +45,15 @@ const route = useRoute()
|
|||||||
color="neutral"
|
color="neutral"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
:aria-label="`Delete ${notesDocName(doc)}`"
|
:aria-label="`${notesDocName(doc)} löschen`"
|
||||||
title="Delete"
|
title="Löschen"
|
||||||
class="absolute inset-y-0 end-1 my-auto h-fit text-dimmed hover:text-error md:opacity-0 md:group-hover:opacity-100 md:focus-visible:opacity-100"
|
class="absolute inset-y-0 end-0.5 my-auto md:end-1 h-fit text-dimmed hover:text-error md:opacity-0 md:group-hover:opacity-100 md:focus-visible:opacity-100"
|
||||||
@click="deleteDoc(doc)"
|
@click="deleteDoc(doc)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="!docs.length && !error && !searching" class="px-2 py-4 text-sm text-muted">
|
<p v-if="!docs.length && !error && !searching" class="px-2 py-4 text-sm text-muted">
|
||||||
{{ query.trim() ? 'No notes match.' : 'No notes yet.' }}
|
{{ query.trim() ? 'Keine Notiz passt.' : 'Noch keine Notizen.' }}
|
||||||
</p>
|
</p>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ const { open } = useNotesSidebar()
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<aside v-show="open" class="hidden w-72 shrink-0 flex-col border-e border-default md:flex">
|
<aside v-show="open" class="hidden w-72 shrink-0 flex-col border-e border-default md:flex">
|
||||||
<h1 class="px-4 py-4 text-lg font-semibold text-highlighted">Notes</h1>
|
<div class="flex items-center gap-1 px-4 py-4">
|
||||||
|
<h1 class="me-auto text-lg font-semibold text-highlighted">Notizen</h1>
|
||||||
|
<AppHeaderActions />
|
||||||
|
</div>
|
||||||
<NotesList class="flex-1 overflow-y-auto" />
|
<NotesList class="flex-1 overflow-y-auto" />
|
||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ const { open, toggle } = useNotesSidebar()
|
|||||||
delete buttons), as wide as the note's side padding (md:px-10), so it never covers the text either -->
|
delete buttons), as wide as the note's side padding (md:px-10), so it never covers the text either -->
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
:title="`${open ? 'Hide' : 'Show'} notes (⌘E)`"
|
:title="`Notizen ${open ? 'ausblenden' : 'anzeigen'} (⌘E)`"
|
||||||
:aria-label="open ? 'Hide notes' : 'Show notes'"
|
:aria-label="open ? 'Notizen ausblenden' : 'Notizen anzeigen'"
|
||||||
class="group fixed inset-y-0 z-20 hidden w-10 cursor-pointer items-center focus:outline-none md:flex"
|
class="group fixed inset-y-0 z-20 hidden w-10 cursor-pointer items-center focus:outline-none md:flex"
|
||||||
:class="open ? 'left-72' : 'left-0'"
|
:class="open ? 'left-72' : 'left-0'"
|
||||||
@click="toggle"
|
@click="toggle"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const dayFormat = new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: '2-d
|
|||||||
|
|
||||||
/** A note is named by its title, or by when it was created if it has none */
|
/** A note is named by its title, or by when it was created if it has none */
|
||||||
export function notesDocName(doc: Pick<NotesDocSummary, 'title' | 'created'>): string {
|
export function notesDocName(doc: Pick<NotesDocSummary, 'title' | 'created'>): string {
|
||||||
return doc.title || (doc.created ? nameFormat.format(new Date(doc.created)) : 'Untitled')
|
return doc.title || (doc.created ? nameFormat.format(new Date(doc.created)) : 'Ohne Titel')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function notesDocDay(iso: string | null): string {
|
export function notesDocDay(iso: string | null): string {
|
||||||
@@ -70,7 +70,7 @@ export function useNewNote() {
|
|||||||
closeOnMobile()
|
closeOnMobile()
|
||||||
await navigateTo(`/notes/${doc.id}`)
|
await navigateTo(`/notes/${doc.id}`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.add({ title: 'Couldn\'t create a note', description: errorMessage(e), color: 'error', icon: 'i-lucide-triangle-alert' })
|
toast.add({ title: 'Notiz konnte nicht erstellt werden', description: errorMessage(e), color: 'error', icon: 'i-lucide-triangle-alert' })
|
||||||
} finally {
|
} finally {
|
||||||
creating.value = false
|
creating.value = false
|
||||||
}
|
}
|
||||||
@@ -91,7 +91,7 @@ export function useDeleteNote() {
|
|||||||
if (String(doc.id) === route.params.id) await navigateTo('/notes')
|
if (String(doc.id) === route.params.id) await navigateTo('/notes')
|
||||||
await remove(doc.id)
|
await remove(doc.id)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.add({ title: 'Couldn\'t delete the note', description: errorMessage(e), color: 'error', icon: 'i-lucide-triangle-alert' })
|
toast.add({ title: 'Notiz konnte nicht gelöscht werden', description: errorMessage(e), color: 'error', icon: 'i-lucide-triangle-alert' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
useHead({ title: 'Notes' })
|
useHead({ title: 'Notizen' })
|
||||||
|
|
||||||
const { toggleList } = useNotesSidebar()
|
const { toggleList } = useNotesSidebar()
|
||||||
const { createAndOpen } = useNewNote()
|
const { createAndOpen } = useNewNote()
|
||||||
@@ -13,7 +13,7 @@ defineShortcuts({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<PasswordGate title="Notes">
|
<PasswordGate title="Notizen">
|
||||||
<div class="flex h-dvh">
|
<div class="flex h-dvh">
|
||||||
<NotesSidebar />
|
<NotesSidebar />
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const editorProps = {
|
|||||||
|
|
||||||
const { queue } = useNotesAutosave(id, summary => patchSummary(id, summary))
|
const { queue } = useNotesAutosave(id, summary => patchSummary(id, summary))
|
||||||
|
|
||||||
useHead({ title: () => doc.value ? notesDocName({ title: title.value, created: doc.value.created }) : 'Notes' })
|
useHead({ title: () => doc.value ? notesDocName({ title: title.value, created: doc.value.created }) : 'Notizen' })
|
||||||
|
|
||||||
watch(title, (value) => {
|
watch(title, (value) => {
|
||||||
patchSummary(id, { title: value.trim() })
|
patchSummary(id, { title: value.trim() })
|
||||||
@@ -47,20 +47,20 @@ onBeforeUnmount(() => mobileQuery?.removeEventListener('change', updateMobile))
|
|||||||
|
|
||||||
const toolbarItems: EditorToolbarItem[][] = [
|
const toolbarItems: EditorToolbarItem[][] = [
|
||||||
[
|
[
|
||||||
{ kind: 'heading', level: 1, icon: 'i-lucide-heading-1', 'aria-label': 'Heading 1' },
|
{ kind: 'heading', level: 1, icon: 'i-lucide-heading-1', 'aria-label': 'Überschrift 1' },
|
||||||
{ kind: 'heading', level: 2, icon: 'i-lucide-heading-2', 'aria-label': 'Heading 2' }
|
{ kind: 'heading', level: 2, icon: 'i-lucide-heading-2', 'aria-label': 'Überschrift 2' }
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
{ kind: 'mark', mark: 'bold', icon: 'i-lucide-bold', 'aria-label': 'Bold' },
|
{ kind: 'mark', mark: 'bold', icon: 'i-lucide-bold', 'aria-label': 'Fett' },
|
||||||
{ kind: 'mark', mark: 'italic', icon: 'i-lucide-italic', 'aria-label': 'Italic' },
|
{ kind: 'mark', mark: 'italic', icon: 'i-lucide-italic', 'aria-label': 'Kursiv' },
|
||||||
{ kind: 'mark', mark: 'strike', icon: 'i-lucide-strikethrough', 'aria-label': 'Strikethrough' },
|
{ kind: 'mark', mark: 'strike', icon: 'i-lucide-strikethrough', 'aria-label': 'Durchgestrichen' },
|
||||||
{ kind: 'mark', mark: 'code', icon: 'i-lucide-code', 'aria-label': 'Code' },
|
{ kind: 'mark', mark: 'code', icon: 'i-lucide-code', 'aria-label': 'Code' },
|
||||||
{ kind: 'link', icon: 'i-lucide-link', 'aria-label': 'Link' }
|
{ kind: 'link', icon: 'i-lucide-link', 'aria-label': 'Link' }
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
{ kind: 'bulletList', icon: 'i-lucide-list', 'aria-label': 'Bullet list' },
|
{ kind: 'bulletList', icon: 'i-lucide-list', 'aria-label': 'Aufzählung' },
|
||||||
{ kind: 'orderedList', icon: 'i-lucide-list-ordered', 'aria-label': 'Numbered list' },
|
{ kind: 'orderedList', icon: 'i-lucide-list-ordered', 'aria-label': 'Nummerierte Liste' },
|
||||||
{ kind: 'blockquote', icon: 'i-lucide-text-quote', 'aria-label': 'Quote' }
|
{ kind: 'blockquote', icon: 'i-lucide-text-quote', 'aria-label': 'Zitat' }
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@ const toolbarItems: EditorToolbarItem[][] = [
|
|||||||
color="error"
|
color="error"
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
icon="i-lucide-triangle-alert"
|
icon="i-lucide-triangle-alert"
|
||||||
title="Couldn't load this note"
|
title="Notiz konnte nicht geladen werden"
|
||||||
:description="errorMessage(error)"
|
:description="errorMessage(error)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ const toolbarItems: EditorToolbarItem[][] = [
|
|||||||
<input
|
<input
|
||||||
v-model="title"
|
v-model="title"
|
||||||
:placeholder="notesDocName({ title: '', created: doc.created })"
|
:placeholder="notesDocName({ title: '', created: doc.created })"
|
||||||
aria-label="Title"
|
aria-label="Titel"
|
||||||
class="min-w-0 flex-1 bg-transparent text-3xl font-semibold text-highlighted outline-none placeholder:text-dimmed"
|
class="min-w-0 flex-1 bg-transparent text-3xl font-semibold text-highlighted outline-none placeholder:text-dimmed"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -91,7 +91,7 @@ const toolbarItems: EditorToolbarItem[][] = [
|
|||||||
v-slot="{ editor }"
|
v-slot="{ editor }"
|
||||||
v-model="content"
|
v-model="content"
|
||||||
content-type="markdown"
|
content-type="markdown"
|
||||||
placeholder="Write something…"
|
placeholder="Schreib etwas …"
|
||||||
:mention="false"
|
:mention="false"
|
||||||
:starter-kit="{ link: { openOnClick: true } }"
|
:starter-kit="{ link: { openOnClick: true } }"
|
||||||
:editor-props="editorProps"
|
:editor-props="editorProps"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex h-full items-center justify-center p-8 text-center text-sm text-muted">
|
<div class="flex h-full items-center justify-center p-8 text-center text-sm text-muted">
|
||||||
<span class="md:hidden">Pull down from the top for your notes, or start a new one.</span>
|
<span class="md:hidden">Von oben herunterziehen für deine Notizen, oder eine neue beginnen.</span>
|
||||||
<span class="hidden md:inline">Pick a note, or start a new one.</span>
|
<span class="hidden md:inline">Notiz auswählen oder eine neue beginnen.</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export const NOTES_SUMMARY_FIELDS = 'title,created,updated'
|
|||||||
export function notesTable(event: H3Event): string {
|
export function notesTable(event: H3Event): string {
|
||||||
const { baserowNotesAppTableId } = useRuntimeConfig(event)
|
const { baserowNotesAppTableId } = useRuntimeConfig(event)
|
||||||
if (!baserowNotesAppTableId) {
|
if (!baserowNotesAppTableId) {
|
||||||
throw createError({ statusCode: 500, statusMessage: 'NUXT_BASEROW_NOTES_APP_TABLE_ID is not set' })
|
throw createError({ statusCode: 500, statusMessage: 'NUXT_BASEROW_NOTES_APP_TABLE_ID ist nicht gesetzt' })
|
||||||
}
|
}
|
||||||
return baserowNotesAppTableId
|
return baserowNotesAppTableId
|
||||||
}
|
}
|
||||||
@@ -21,7 +21,7 @@ export function notesTable(event: H3Event): string {
|
|||||||
export function notesDocId(event: H3Event): number {
|
export function notesDocId(event: H3Event): number {
|
||||||
const id = Number(getRouterParam(event, 'id'))
|
const id = Number(getRouterParam(event, 'id'))
|
||||||
if (!Number.isInteger(id) || id < 1) {
|
if (!Number.isInteger(id) || id < 1) {
|
||||||
throw createError({ statusCode: 400, statusMessage: 'Invalid note' })
|
throw createError({ statusCode: 400, statusMessage: 'Ungültige Notiz' })
|
||||||
}
|
}
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,5 +50,8 @@ export default defineAppConfig({
|
|||||||
{ activity: 'Duschen', minDays: 2 },
|
{ activity: 'Duschen', minDays: 2 },
|
||||||
{ activity: 'Kochen', stay: 'Camping' }
|
{ activity: 'Kochen', stay: 'Camping' }
|
||||||
] as { activity: string, minDays?: number, stay?: string }[]
|
] as { activity: string, minDays?: number, stay?: string }[]
|
||||||
|
},
|
||||||
|
hub: {
|
||||||
|
packer: { title: 'Packliste', icon: 'i-lucide-backpack', to: '/packer' }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
useHead({ title: 'Packer', htmlAttrs: { lang: 'de' } })
|
useHead({ title: 'Packliste' })
|
||||||
|
|
||||||
const { settings, changed: settingsChanged, reset: resetSettings } = usePackerSettings()
|
const { settings, changed: settingsChanged, reset: resetSettings } = usePackerSettings()
|
||||||
const { state, checked, changed: sessionChanged, toggle, add, remove, setWeight, reset: resetSession } = usePackerSession()
|
const { state, checked, changed: sessionChanged, toggle, add, remove, setWeight, reset: resetSession } = usePackerSession()
|
||||||
@@ -34,8 +34,8 @@ const summary = computed(() => [
|
|||||||
...(weighed.value.length ? [`${unweighed.value ? '≥ ' : ''}${packerWeight(weight.value)}`] : [])
|
...(weighed.value.length ? [`${unweighed.value ? '≥ ' : ''}${packerWeight(weight.value)}`] : [])
|
||||||
].join(' · '))
|
].join(' · '))
|
||||||
|
|
||||||
// The PDF is named after the trip, e.g. "packer_3_tage"
|
// The PDF is named after the trip, e.g. "packliste_3_tage"
|
||||||
const { savePdf } = usePdfExport(() => `packer_${settings.days}_${settings.days === 1 ? 'tag' : 'tage'}`)
|
const { savePdf } = usePdfExport(() => `packliste_${settings.days}_${settings.days === 1 ? 'tag' : 'tage'}`)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -44,7 +44,7 @@ const { savePdf } = usePdfExport(() => `packer_${settings.days}_${settings.days
|
|||||||
<div class="mb-6 flex items-center gap-3">
|
<div class="mb-6 flex items-center gap-3">
|
||||||
<div class="me-auto">
|
<div class="me-auto">
|
||||||
<h1 class="text-2xl font-semibold text-highlighted">
|
<h1 class="text-2xl font-semibold text-highlighted">
|
||||||
Packer
|
Packliste
|
||||||
</h1>
|
</h1>
|
||||||
<p class="mt-1 hidden text-sm text-muted print:block">
|
<p class="mt-1 hidden text-sm text-muted print:block">
|
||||||
{{ summary }}
|
{{ summary }}
|
||||||
@@ -61,6 +61,7 @@ const { savePdf } = usePdfExport(() => `packer_${settings.days}_${settings.days
|
|||||||
class="print:hidden"
|
class="print:hidden"
|
||||||
@click="reset"
|
@click="reset"
|
||||||
/>
|
/>
|
||||||
|
<AppHeaderActions />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PackerSettingsBar
|
<PackerSettingsBar
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ interface PackerField {
|
|||||||
export function packerTable(event: H3Event): string {
|
export function packerTable(event: H3Event): string {
|
||||||
const { baserowPackerTableId } = useRuntimeConfig(event)
|
const { baserowPackerTableId } = useRuntimeConfig(event)
|
||||||
if (!baserowPackerTableId) {
|
if (!baserowPackerTableId) {
|
||||||
throw createError({ statusCode: 500, statusMessage: 'NUXT_BASEROW_PACKER_TABLE_ID is not set' })
|
throw createError({ statusCode: 500, statusMessage: 'NUXT_BASEROW_PACKER_TABLE_ID ist nicht gesetzt' })
|
||||||
}
|
}
|
||||||
return baserowPackerTableId
|
return baserowPackerTableId
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export default defineAppConfig({
|
||||||
|
hub: {
|
||||||
|
wishlist: { title: 'Wunschzettel', icon: 'i-lucide-gift', to: '/wishlist' }
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
useHead({ title: 'Wunschzettel', htmlAttrs: { lang: 'de' } })
|
useHead({ title: 'Wunschzettel' })
|
||||||
|
|
||||||
const search = ref('')
|
const search = ref('')
|
||||||
const sort = ref<WishlistSort>('title')
|
const sort = ref<WishlistSort>('title')
|
||||||
@@ -35,9 +35,12 @@ onBeforeUnmount(() => observer?.disconnect())
|
|||||||
Überrasch mich gern auch mit etwas, das hier nicht zu finden ist <3
|
Überrasch mich gern auch mit etwas, das hier nicht zu finden ist <3
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex shrink-0 items-center gap-2">
|
||||||
<div ref="headerControls" class="hidden w-96 sm:block">
|
<div ref="headerControls" class="hidden w-96 sm:block">
|
||||||
<WishlistControls v-model:search="search" v-model:sort="sort" />
|
<WishlistControls v-model:search="search" v-model:sort="sort" />
|
||||||
</div>
|
</div>
|
||||||
|
<AppHeaderActions />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UAlert
|
<UAlert
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ export default defineEventHandler(async (event) => {
|
|||||||
const id = Number(getRouterParam(event, 'id'))
|
const id = Number(getRouterParam(event, 'id'))
|
||||||
const body = coverSchema.safeParse(await readBody(event))
|
const body = coverSchema.safeParse(await readBody(event))
|
||||||
if (!Number.isInteger(id) || !body.success) {
|
if (!Number.isInteger(id) || !body.success) {
|
||||||
throw createError({ statusCode: 400, statusMessage: 'Invalid cover' })
|
throw createError({ statusCode: 400, statusMessage: 'Ungültiges Cover' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const table = wishlistTable(event)
|
const table = wishlistTable(event)
|
||||||
const rows = await listRows<WishlistRow>(event, table)
|
const rows = await listRows<WishlistRow>(event, table)
|
||||||
const row = rows.find(row => row.id === id)
|
const row = rows.find(row => row.id === id)
|
||||||
if (!row) throw createError({ statusCode: 404, statusMessage: 'No such book' })
|
if (!row) throw createError({ statusCode: 404, statusMessage: 'Buch nicht gefunden' })
|
||||||
if (row.cover) return toWishlistBook(row)
|
if (row.cover) return toWishlistBook(row)
|
||||||
|
|
||||||
return toWishlistBook(await updateRow<WishlistRow>(event, table, id, { cover: body.data.cover }))
|
return toWishlistBook(await updateRow<WishlistRow>(event, table, id, { cover: body.data.cover }))
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export async function wishlistBooksTable(event: H3Event): Promise<string> {
|
|||||||
const fields = await listFields<Field>(event, wishlistTable(event))
|
const fields = await listFields<Field>(event, wishlistTable(event))
|
||||||
const id = fields.find(field => field.name === 'books')?.link_row_table_id
|
const id = fields.find(field => field.name === 'books')?.link_row_table_id
|
||||||
if (!id) {
|
if (!id) {
|
||||||
throw createError({ statusCode: 500, statusMessage: 'The wishlist table has no "books" link field' })
|
throw createError({ statusCode: 500, statusMessage: 'Die Wunschzettel-Tabelle hat kein Verknüpfungsfeld "books"' })
|
||||||
}
|
}
|
||||||
booksTable = String(id)
|
booksTable = String(id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export interface WishlistRow {
|
|||||||
export function wishlistTable(event: H3Event): string {
|
export function wishlistTable(event: H3Event): string {
|
||||||
const { baserowWishlistTableId } = useRuntimeConfig(event)
|
const { baserowWishlistTableId } = useRuntimeConfig(event)
|
||||||
if (!baserowWishlistTableId) {
|
if (!baserowWishlistTableId) {
|
||||||
throw createError({ statusCode: 500, statusMessage: 'NUXT_BASEROW_WISHLIST_TABLE_ID is not set' })
|
throw createError({ statusCode: 500, statusMessage: 'NUXT_BASEROW_WISHLIST_TABLE_ID ist nicht gesetzt' })
|
||||||
}
|
}
|
||||||
return baserowWishlistTableId
|
return baserowWishlistTableId
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@ export default defineNuxtConfig({
|
|||||||
},
|
},
|
||||||
app: {
|
app: {
|
||||||
head: {
|
head: {
|
||||||
htmlAttrs: { lang: 'en' }
|
htmlAttrs: { lang: 'de' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
if (!(await passwordMatches(event, password))) {
|
if (!(await passwordMatches(event, password))) {
|
||||||
await new Promise(resolve => setTimeout(resolve, FAILED_LOGIN_DELAY_MS))
|
await new Promise(resolve => setTimeout(resolve, FAILED_LOGIN_DELAY_MS))
|
||||||
throw createError({ statusCode: 401, statusMessage: 'Wrong password' })
|
throw createError({ statusCode: 401, statusMessage: 'Falsches Passwort' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await authSession(event)
|
const session = await authSession(event)
|
||||||
|
|||||||
@@ -6,6 +6,6 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
const session = await authSession(event)
|
const session = await authSession(event)
|
||||||
if (!session.data.unlocked) {
|
if (!session.data.unlocked) {
|
||||||
throw createError({ statusCode: 401, statusMessage: 'Locked' })
|
throw createError({ statusCode: 401, statusMessage: 'Gesperrt' })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const SESSION_MAX_AGE_S = 60 * 60 * 24 * 30
|
|||||||
export function authSession(event: H3Event) {
|
export function authSession(event: H3Event) {
|
||||||
const { sessionSecret } = useRuntimeConfig(event)
|
const { sessionSecret } = useRuntimeConfig(event)
|
||||||
if (!sessionSecret || sessionSecret.length < 32) {
|
if (!sessionSecret || sessionSecret.length < 32) {
|
||||||
throw createError({ statusCode: 500, statusMessage: 'NUXT_SESSION_SECRET must be set (at least 32 characters)' })
|
throw createError({ statusCode: 500, statusMessage: 'NUXT_SESSION_SECRET muss gesetzt sein (mindestens 32 Zeichen)' })
|
||||||
}
|
}
|
||||||
return useSession<AuthSession>(event, {
|
return useSession<AuthSession>(event, {
|
||||||
// Kept from when only the diary had a login, so existing logins stay valid
|
// Kept from when only the diary had a login, so existing logins stay valid
|
||||||
@@ -28,7 +28,7 @@ async function sha256(text: string): Promise<Uint8Array> {
|
|||||||
export async function passwordMatches(event: H3Event, guess: string): Promise<boolean> {
|
export async function passwordMatches(event: H3Event, guess: string): Promise<boolean> {
|
||||||
const { diaryPassword } = useRuntimeConfig(event)
|
const { diaryPassword } = useRuntimeConfig(event)
|
||||||
if (!diaryPassword) {
|
if (!diaryPassword) {
|
||||||
throw createError({ statusCode: 500, statusMessage: 'NUXT_DIARY_PASSWORD is not set' })
|
throw createError({ statusCode: 500, statusMessage: 'NUXT_DIARY_PASSWORD ist nicht gesetzt' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const [a, b] = await Promise.all([sha256(guess), sha256(diaryPassword)])
|
const [a, b] = await Promise.all([sha256(guess), sha256(diaryPassword)])
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ async function request<T>(
|
|||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const { baserowUrl, baserowToken } = useRuntimeConfig(event)
|
const { baserowUrl, baserowToken } = useRuntimeConfig(event)
|
||||||
if (!baserowUrl || !baserowToken) {
|
if (!baserowUrl || !baserowToken) {
|
||||||
throw createError({ statusCode: 500, statusMessage: 'NUXT_BASEROW_URL and NUXT_BASEROW_TOKEN must be set' })
|
throw createError({ statusCode: 500, statusMessage: 'NUXT_BASEROW_URL und NUXT_BASEROW_TOKEN müssen gesetzt sein' })
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -33,7 +33,7 @@ async function request<T>(
|
|||||||
const detail = error?.data?.detail
|
const detail = error?.data?.detail
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: error?.statusCode ?? 502,
|
statusCode: error?.statusCode ?? 502,
|
||||||
statusMessage: typeof detail === 'string' ? detail : 'Baserow request failed'
|
statusMessage: typeof detail === 'string' ? detail : 'Baserow-Anfrage fehlgeschlagen'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { z } from 'zod'
|
|||||||
export async function readValidBody<Schema extends z.ZodType>(event: H3Event, schema: Schema): Promise<z.output<Schema>> {
|
export async function readValidBody<Schema extends z.ZodType>(event: H3Event, schema: Schema): Promise<z.output<Schema>> {
|
||||||
const result = schema.safeParse(await readBody(event))
|
const result = schema.safeParse(await readBody(event))
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw createError({ statusCode: 400, statusMessage: result.error.issues[0]?.message ?? 'Invalid request' })
|
throw createError({ statusCode: 400, statusMessage: result.error.issues[0]?.message ?? 'Ungültige Anfrage' })
|
||||||
}
|
}
|
||||||
return result.data
|
return result.data
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/** One app on the hub (`/`) */
|
||||||
|
export interface HubApp {
|
||||||
|
title: string
|
||||||
|
/** Iconify name, e.g. 'i-lucide-gift' */
|
||||||
|
icon: string
|
||||||
|
to: string
|
||||||
|
/** Behind the shared password; shows a lock */
|
||||||
|
locked?: boolean
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user