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/
+3 -1
View File
@@ -4,5 +4,7 @@ export default defineAppConfig({
primary: 'amber',
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
View File
@@ -1,5 +1,9 @@
<script setup lang="ts">
import { de } from '@nuxt/ui/locale'
</script>
<template>
<UApp>
<UApp :locale="de">
<NuxtPage />
</UApp>
</template>
+11
View File
@@ -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>
+4 -4
View File
@@ -14,7 +14,7 @@ async function unlock() {
await $fetch('/api/auth/login', { method: 'POST', body: { password: password.value } })
emit('unlocked')
} catch (e) {
error.value = errorMessage(e) ?? 'Couldn\'t unlock'
error.value = errorMessage(e) ?? 'Entsperren fehlgeschlagen'
password.value = ''
} finally {
unlocking.value = false
@@ -23,13 +23,13 @@ async function unlock() {
</script>
<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>
<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 />
</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>
</template>
</UModal>
+35 -1
View File
@@ -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>
<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>
+4 -1
View File
@@ -13,7 +13,10 @@ export default defineAppConfig({
/** Printed with the date at the bottom of the PDF, e.g. "Berlin, 02.10.2026" */
city: 'Berlin',
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)'
},
hub: {
cv: { title: 'Lebenslauf', icon: 'i-lucide-file-user', to: '/cv' }
}
})
+6
View File
@@ -38,10 +38,13 @@ const { lang, t } = useCvLang()
target="_blank"
:icon="link.kind === 'github' ? 'i-lucide-github' : 'i-lucide-globe'"
:label="link.label"
:title="link.label"
:aria-label="link.label"
color="primary"
variant="link"
size="sm"
class="p-0 print:hidden"
:ui="CV_LINK_UI"
/>
<a
v-for="link in group"
@@ -58,10 +61,13 @@ const { lang, t } = useCvLang()
target="_blank"
icon="i-lucide-file-down"
:label="file.name"
:title="file.name"
:aria-label="file.name"
color="primary"
variant="link"
size="sm"
class="p-0"
:ui="CV_LINK_UI"
/>
</li>
</ul>
+46 -36
View File
@@ -1,7 +1,4 @@
<script setup lang="ts">
// Always light: white background, also when the device is in dark mode
definePageMeta({ colorMode: 'light' })
const { cv: profile } = useAppConfig()
const { lang, t } = useCvLang()
@@ -33,10 +30,11 @@ const experience = computed(() => entries.value.filter(entry => !EDUCATION.inclu
</script>
<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">
<div class="mb-6 flex justify-end print:hidden">
<UFieldGroup size="xs">
<div class="mb-6 flex justify-end gap-2 print:hidden">
<AppHeaderActions />
<UFieldGroup>
<UButton
v-for="option in (['de', 'en'] as const)"
:key="option"
@@ -49,36 +47,43 @@ const experience = computed(() => entries.value.filter(entry => !EDUCATION.inclu
</UFieldGroup>
</div>
<header class="mb-10 flex items-center gap-5">
<UAvatar v-if="profile.photo" :src="profile.photo" :alt="profile.name || t.photo" class="size-20 shrink-0" />
<div class="min-w-0">
<h1 class="text-2xl font-semibold text-highlighted">
{{ profile.name || 'CV' }}
</h1>
<p v-if="profile.description[lang]" class="mt-1 text-sm text-muted">
{{ profile.description[lang] }}
</p>
<div class="mt-1 flex flex-wrap gap-x-4">
<UButton
v-if="profile.email"
:to="`mailto:${profile.email}`"
:label="profile.email"
color="primary"
variant="link"
class="p-0"
/>
<UButton
v-if="profile.linkedin"
:to="profile.linkedin"
target="_blank"
label="LinkedIn"
color="primary"
variant="link"
class="p-0 print:hidden"
/>
<!-- 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>
</div>
<header class="mb-10">
<UAvatar v-if="profile.photo" :src="profile.photo" :alt="profile.name || t.photo" class="mb-4 size-20" />
<!-- Under the photo: name, description, links -->
<h1 class="text-2xl font-semibold text-highlighted">
{{ profile.name || 'CV' }}
</h1>
<p v-if="profile.description[lang]" class="mt-1 text-sm text-muted">
{{ profile.description[lang] }}
</p>
<div class="mt-3 flex flex-wrap gap-x-4 gap-y-1">
<UButton
v-if="profile.email"
:to="`mailto:${profile.email}`"
icon="i-lucide-mail"
:label="profile.email"
:title="profile.email"
:aria-label="profile.email"
color="primary"
variant="link"
class="p-0"
:ui="CV_LINK_UI"
/>
<UButton
v-if="profile.linkedin"
:to="profile.linkedin"
target="_blank"
icon="i-lucide-linkedin"
label="LinkedIn"
title="LinkedIn"
aria-label="LinkedIn"
color="primary"
variant="link"
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 -->
<a v-if="profile.linkedin" :href="profile.linkedin" class="hidden text-sm font-medium text-primary print:inline">{{ cvLinkText(profile.linkedin) }}</a>
</div>
</header>
@@ -109,6 +114,11 @@ const experience = computed(() => entries.value.filter(entry => !EDUCATION.inclu
</template>
<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
print padding, which also works in browsers that ignore @page margins */
@page {
+3
View File
@@ -2,3 +2,6 @@
export function cvLinkText(url: string): string {
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' }
+1 -1
View File
@@ -26,7 +26,7 @@ export interface CvRow {
export function cvTable(event: H3Event): string {
const { baserowCvTableId } = useRuntimeConfig(event)
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
}
+3
View File
@@ -1,5 +1,8 @@
export default defineAppConfig({
diary: {
showCovers: false
},
hub: {
diary: { title: 'Tagebuch', icon: 'i-lucide-book-open', to: '/diary', locked: true }
}
})
+1 -1
View File
@@ -16,7 +16,7 @@ const background = computed(() => {
class="relative size-full overflow-hidden text-white [container-type:inline-size]"
:style="{ background }"
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" />
<div class="flex size-full flex-col justify-between gap-1 p-[8cqw] pl-[12cqw]">
+1 -1
View File
@@ -47,7 +47,7 @@ const parts = computed(() => highlight(props.entry.text, props.search))
color="neutral"
variant="link"
class="mt-1 px-0"
:label="expanded ? 'Show less' : `Read more (${wordCount} words)`"
:label="expanded ? 'Weniger anzeigen' : `Weiterlesen (${wordCount} Wörter)`"
@click="expanded = !expanded"
/>
</article>
@@ -7,10 +7,11 @@ const log = useLog()
<template>
<div class="mx-auto max-w-3xl">
<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">
<UInput v-model="search" icon="i-lucide-search" placeholder="Search the diary" class="flex-1 sm:w-64 sm:flex-none" />
<UButton icon="i-lucide-plus" label="Log" @click="log.show()" />
<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="Eintragen" @click="log.show()" />
<AppHeaderActions />
</div>
</div>
@@ -20,7 +21,7 @@ const log = useLog()
color="error"
variant="subtle"
icon="i-lucide-triangle-alert"
:title="`Couldn't load your ${what}`"
:title="`${what} konnten nicht geladen werden`"
:description="errorMessage(error)"
class="mb-6"
/>
@@ -38,7 +39,7 @@ const log = useLog()
</section>
<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>
</div>
</template>
+3 -3
View File
@@ -2,13 +2,13 @@
const { open, close } = useLog()
const tabs = [
{ label: 'Note', value: 'note', icon: NOTE_ICON, slot: 'note' as const },
{ label: 'Review', value: 'review', icon: REVIEW_ICON, slot: 'review' as const }
{ label: 'Notiz', value: 'note', icon: NOTE_ICON, slot: 'note' as const },
{ label: 'Rezension', value: 'review', icon: REVIEW_ICON, slot: 'review' as const }
]
</script>
<template>
<UModal v-model:open="open" title="Log">
<UModal v-model:open="open" title="Eintragen">
<template #body>
<UTabs :items="tabs" default-value="note" class="w-full" :ui="{ content: 'pt-4' }">
<template #note>
+4 -4
View File
@@ -4,7 +4,7 @@ import type { FormSubmitEvent } from '@nuxt/ui'
const emit = defineEmits<{ saved: [] }>()
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>) {
return save(event.data)
@@ -13,14 +13,14 @@ function onSubmit(event: FormSubmitEvent<NewNoteInput>) {
<template>
<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" />
</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 />
</UFormField>
<UButton type="submit" label="Save note" :icon="NOTE_ICON" :loading="saving" />
<UButton type="submit" label="Notiz speichern" :icon="NOTE_ICON" :loading="saving" />
</UForm>
</template>
+8 -7
View File
@@ -19,7 +19,8 @@ const state = reactive({ bookId: undefined as number | undefined, date: todayIso
const { saving, save } = useSaveEntry({
url: '/api/diary/reviews',
dataKey: ['diary-reviews', 'diary-books'],
noun: 'review',
saved: 'Rezension gespeichert',
failed: 'Rezension konnte nicht gespeichert werden',
onSaved: () => emit('saved')
})
@@ -34,29 +35,29 @@ function onSubmit(event: FormSubmitEvent<NewReviewInput>) {
<template>
<UForm :schema="newReviewSchema" :state="state" class="space-y-5" @submit="onSubmit">
<UFormField label="Book" name="bookId" required>
<UFormField label="Buch" name="bookId" required>
<USelectMenu
v-model="state.bookId"
:items="bookItems"
value-key="value"
:filter-fields="['label', 'description', 'originalTitle']"
:loading="status === 'pending'"
:search-input="{ placeholder: 'Title or author' }"
placeholder="Pick a book"
:search-input="{ placeholder: 'Titel oder Autor' }"
placeholder="Buch auswählen"
icon="i-lucide-book-open"
virtualize
class="w-full"
/>
</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" />
</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" />
</UFormField>
<UButton type="submit" label="Save review" :icon="REVIEW_ICON" :loading="saving" />
<UButton type="submit" label="Rezension speichern" :icon="REVIEW_ICON" :loading="saving" />
</UForm>
</template>
+4 -4
View File
@@ -12,8 +12,8 @@ function toEntries(reviews: Review[], notes: Note[]): Entry[] {
function searchableText(entry: Entry): string {
const date = `${formatDate(entry.date)} ${entry.date}`
const text = entry.kind === 'review'
? `review ${entry.review.medium ?? ''} ${date} ${entry.review.title} ${entry.review.author ?? ''} ${entry.text}`
: `note ${date} ${entry.text}`
? `rezension ${entry.review.medium ? MEDIUM_NAMES[entry.review.medium] : ''} ${date} ${entry.review.title} ${entry.review.author ?? ''} ${entry.text}`
: `notiz ${date} ${entry.text}`
return text.toLowerCase()
}
@@ -55,8 +55,8 @@ export function useDiary(search: Ref<string>) {
})
const errors = computed(() => [
{ what: 'reviews', error: reviewsError.value },
{ what: 'notes', error: notesError.value }
{ what: 'Rezensionen', error: reviewsError.value },
{ what: 'Notizen', error: notesError.value }
].filter(({ error }) => error))
return { years, errors }
+3 -7
View File
@@ -1,8 +1,4 @@
function capitalize(word: string): string {
return word.charAt(0).toUpperCase() + word.slice(1)
}
export function useSaveEntry(options: { url: string, dataKey: string | string[], noun: string, onSaved: () => void }) {
export function useSaveEntry(options: { url: string, dataKey: string | string[], saved: string, failed: string, onSaved: () => void }) {
const toast = useToast()
const saving = ref(false)
@@ -11,11 +7,11 @@ export function useSaveEntry(options: { url: string, dataKey: string | string[],
try {
await $fetch(options.url, { method: 'POST', body })
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()
} catch (error) {
toast.add({
title: `Couldn't save the ${options.noun}`,
title: options.failed,
description: errorMessage(error),
color: 'error',
icon: 'i-lucide-triangle-alert'
+2 -2
View File
@@ -1,9 +1,9 @@
<script setup lang="ts">
useHead({ title: 'Diary' })
useHead({ title: 'Tagebuch' })
</script>
<template>
<PasswordGate title="Diary">
<PasswordGate title="Tagebuch">
<UMain>
<UContainer class="py-8">
<DiaryTimeline />
+1 -1
View File
@@ -1,5 +1,5 @@
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 {
+7
View File
@@ -1,6 +1,13 @@
export const NOTE_ICON = 'i-lucide-notebook-pen'
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> = {
book: 'i-lucide-book-open',
movie: 'i-lucide-clapperboard',
+1 -1
View File
@@ -4,7 +4,7 @@ export function diaryTable(event: H3Event, table: 'reviews' | 'notes'): string {
const config = useRuntimeConfig(event)
const id = { reviews: config.baserowReviewsTableId, notes: config.baserowNotesTableId }[table]
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
}
+2 -2
View File
@@ -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 id = options.find(option => toMedium(option.value) === medium)?.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
}
@@ -69,7 +69,7 @@ export async function mediumOptionId(event: H3Event, medium: Medium): Promise<nu
export async function booksTableId(event: H3Event): Promise<string> {
const id = (await fields(event)).find(field => field.name === 'books')?.link_row_table_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)
}
+3 -3
View File
@@ -1,7 +1,7 @@
import { z } from 'zod'
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Pick a date')
const requiredText = z.string().trim().min(1, 'Write something first')
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Datum auswählen')
const requiredText = z.string().trim().min(1, 'Erst etwas schreiben')
export const newNoteSchema = z.object({
date: isoDate,
@@ -9,7 +9,7 @@ export const newNoteSchema = 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,
text: requiredText
})
+5
View File
@@ -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">
// "New", bottom right.
// "Neu", bottom right.
// The list opens with NotesDrawer (mobile) and NotesSidebarHandle (desktop).
const { creating, createAndOpen } = useNewNote()
</script>
<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">
<UTooltip text="New note" :kbds="['meta', 'alt', 'n']">
<UTooltip text="Neue Notiz" :kbds="['meta', 'alt', 'n']">
<UButton
icon="i-lucide-square-pen"
label="New"
label="Neu"
size="lg"
class="pointer-events-auto rounded-full px-5 shadow-lg"
:loading="creating"
+6 -2
View File
@@ -24,7 +24,7 @@ function onTouchMove(event: TouchEvent) {
<div class="md:hidden">
<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"
@click="drawerOpen = true"
@touchstart.passive="onTouchStart"
@@ -33,7 +33,11 @@ function onTouchMove(event: TouchEvent) {
<span class="h-1.5 w-12 rounded-full bg-accented" />
</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>
<NotesList />
</template>
+9 -8
View File
@@ -14,20 +14,21 @@ const route = useRoute()
<UInput
v-model="query"
icon="i-lucide-search"
placeholder="Search"
placeholder="Suchen"
:loading="searching"
class="w-full"
@keydown.esc="query = ''"
>
<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>
</UInput>
</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 -->
<div v-for="doc in docs" :key="doc.id" class="group relative">
<NuxtLink
@@ -44,15 +45,15 @@ const route = useRoute()
color="neutral"
variant="ghost"
size="sm"
:aria-label="`Delete ${notesDocName(doc)}`"
title="Delete"
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"
:aria-label="`${notesDocName(doc)} löschen`"
title="Löschen"
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)"
/>
</div>
<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>
</nav>
</div>
+4 -1
View File
@@ -5,7 +5,10 @@ const { open } = useNotesSidebar()
<template>
<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" />
</aside>
</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 -->
<button
type="button"
:title="`${open ? 'Hide' : 'Show'} notes (⌘E)`"
:aria-label="open ? 'Hide notes' : 'Show notes'"
:title="`Notizen ${open ? 'ausblenden' : 'anzeigen'} (⌘E)`"
: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="open ? 'left-72' : 'left-0'"
@click="toggle"
+3 -3
View File
@@ -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 */
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 {
@@ -70,7 +70,7 @@ export function useNewNote() {
closeOnMobile()
await navigateTo(`/notes/${doc.id}`)
} 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 {
creating.value = false
}
@@ -91,7 +91,7 @@ export function useDeleteNote() {
if (String(doc.id) === route.params.id) await navigateTo('/notes')
await remove(doc.id)
} 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' })
}
}
}
+2 -2
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
useHead({ title: 'Notes' })
useHead({ title: 'Notizen' })
const { toggleList } = useNotesSidebar()
const { createAndOpen } = useNewNote()
@@ -13,7 +13,7 @@ defineShortcuts({
</script>
<template>
<PasswordGate title="Notes">
<PasswordGate title="Notizen">
<div class="flex h-dvh">
<NotesSidebar />
+12 -12
View File
@@ -19,7 +19,7 @@ const editorProps = {
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) => {
patchSummary(id, { title: value.trim() })
@@ -47,20 +47,20 @@ onBeforeUnmount(() => mobileQuery?.removeEventListener('change', updateMobile))
const toolbarItems: EditorToolbarItem[][] = [
[
{ kind: 'heading', level: 1, icon: 'i-lucide-heading-1', 'aria-label': 'Heading 1' },
{ kind: 'heading', level: 2, icon: 'i-lucide-heading-2', 'aria-label': 'Heading 2' }
{ kind: 'heading', level: 1, icon: 'i-lucide-heading-1', 'aria-label': 'Überschrift 1' },
{ 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: 'italic', icon: 'i-lucide-italic', 'aria-label': 'Italic' },
{ kind: 'mark', mark: 'strike', icon: 'i-lucide-strikethrough', 'aria-label': 'Strikethrough' },
{ kind: 'mark', mark: 'bold', icon: 'i-lucide-bold', 'aria-label': 'Fett' },
{ kind: 'mark', mark: 'italic', icon: 'i-lucide-italic', 'aria-label': 'Kursiv' },
{ kind: 'mark', mark: 'strike', icon: 'i-lucide-strikethrough', 'aria-label': 'Durchgestrichen' },
{ kind: 'mark', mark: 'code', icon: 'i-lucide-code', 'aria-label': 'Code' },
{ kind: 'link', icon: 'i-lucide-link', 'aria-label': 'Link' }
],
[
{ kind: 'bulletList', icon: 'i-lucide-list', 'aria-label': 'Bullet list' },
{ kind: 'orderedList', icon: 'i-lucide-list-ordered', 'aria-label': 'Numbered list' },
{ kind: 'blockquote', icon: 'i-lucide-text-quote', 'aria-label': 'Quote' }
{ kind: 'bulletList', icon: 'i-lucide-list', 'aria-label': 'Aufzählung' },
{ kind: 'orderedList', icon: 'i-lucide-list-ordered', 'aria-label': 'Nummerierte Liste' },
{ kind: 'blockquote', icon: 'i-lucide-text-quote', 'aria-label': 'Zitat' }
]
]
@@ -73,7 +73,7 @@ const toolbarItems: EditorToolbarItem[][] = [
color="error"
variant="subtle"
icon="i-lucide-triangle-alert"
title="Couldn't load this note"
title="Notiz konnte nicht geladen werden"
:description="errorMessage(error)"
/>
@@ -82,7 +82,7 @@ const toolbarItems: EditorToolbarItem[][] = [
<input
v-model="title"
: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"
>
</div>
@@ -91,7 +91,7 @@ const toolbarItems: EditorToolbarItem[][] = [
v-slot="{ editor }"
v-model="content"
content-type="markdown"
placeholder="Write something…"
placeholder="Schreib etwas …"
:mention="false"
:starter-kit="{ link: { openOnClick: true } }"
:editor-props="editorProps"
+2 -2
View File
@@ -8,7 +8,7 @@ onMounted(() => {
<template>
<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="hidden md:inline">Pick a note, 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">Notiz auswählen oder eine neue beginnen.</span>
</div>
</template>
+2 -2
View File
@@ -13,7 +13,7 @@ export const NOTES_SUMMARY_FIELDS = 'title,created,updated'
export function notesTable(event: H3Event): string {
const { baserowNotesAppTableId } = useRuntimeConfig(event)
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
}
@@ -21,7 +21,7 @@ export function notesTable(event: H3Event): string {
export function notesDocId(event: H3Event): number {
const id = Number(getRouterParam(event, 'id'))
if (!Number.isInteger(id) || id < 1) {
throw createError({ statusCode: 400, statusMessage: 'Invalid note' })
throw createError({ statusCode: 400, statusMessage: 'Ungültige Notiz' })
}
return id
}
+3
View File
@@ -50,5 +50,8 @@ export default defineAppConfig({
{ activity: 'Duschen', minDays: 2 },
{ activity: 'Kochen', stay: 'Camping' }
] as { activity: string, minDays?: number, stay?: string }[]
},
hub: {
packer: { title: 'Packliste', icon: 'i-lucide-backpack', to: '/packer' }
}
})
+5 -4
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
useHead({ title: 'Packer', htmlAttrs: { lang: 'de' } })
useHead({ title: 'Packliste' })
const { settings, changed: settingsChanged, reset: resetSettings } = usePackerSettings()
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)}`] : [])
].join(' · '))
// The PDF is named after the trip, e.g. "packer_3_tage"
const { savePdf } = usePdfExport(() => `packer_${settings.days}_${settings.days === 1 ? 'tag' : 'tage'}`)
// The PDF is named after the trip, e.g. "packliste_3_tage"
const { savePdf } = usePdfExport(() => `packliste_${settings.days}_${settings.days === 1 ? 'tag' : 'tage'}`)
</script>
<template>
@@ -44,7 +44,7 @@ const { savePdf } = usePdfExport(() => `packer_${settings.days}_${settings.days
<div class="mb-6 flex items-center gap-3">
<div class="me-auto">
<h1 class="text-2xl font-semibold text-highlighted">
Packer
Packliste
</h1>
<p class="mt-1 hidden text-sm text-muted print:block">
{{ summary }}
@@ -61,6 +61,7 @@ const { savePdf } = usePdfExport(() => `packer_${settings.days}_${settings.days
class="print:hidden"
@click="reset"
/>
<AppHeaderActions />
</div>
<PackerSettingsBar
+1 -1
View File
@@ -25,7 +25,7 @@ interface PackerField {
export function packerTable(event: H3Event): string {
const { baserowPackerTableId } = useRuntimeConfig(event)
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
}
+5
View File
@@ -0,0 +1,5 @@
export default defineAppConfig({
hub: {
wishlist: { title: 'Wunschzettel', icon: 'i-lucide-gift', to: '/wishlist' }
}
})
+6 -3
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
useHead({ title: 'Wunschzettel', htmlAttrs: { lang: 'de' } })
useHead({ title: 'Wunschzettel' })
const search = ref('')
const sort = ref<WishlistSort>('title')
@@ -35,8 +35,11 @@ onBeforeUnmount(() => observer?.disconnect())
Überrasch mich gern auch mit etwas, das hier nicht zu finden ist <3
</p>
</div>
<div ref="headerControls" class="hidden w-96 sm:block">
<WishlistControls v-model:search="search" v-model:sort="sort" />
<div class="flex shrink-0 items-center gap-2">
<div ref="headerControls" class="hidden w-96 sm:block">
<WishlistControls v-model:search="search" v-model:sort="sort" />
</div>
<AppHeaderActions />
</div>
</div>
@@ -9,13 +9,13 @@ export default defineEventHandler(async (event) => {
const id = Number(getRouterParam(event, 'id'))
const body = coverSchema.safeParse(await readBody(event))
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 rows = await listRows<WishlistRow>(event, table)
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)
return toWishlistBook(await updateRow<WishlistRow>(event, table, id, { cover: body.data.cover }))
+1 -1
View File
@@ -24,7 +24,7 @@ export async function wishlistBooksTable(event: H3Event): Promise<string> {
const fields = await listFields<Field>(event, wishlistTable(event))
const id = fields.find(field => field.name === 'books')?.link_row_table_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)
}
+1 -1
View File
@@ -17,7 +17,7 @@ export interface WishlistRow {
export function wishlistTable(event: H3Event): string {
const { baserowWishlistTableId } = useRuntimeConfig(event)
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
}
+1 -1
View File
@@ -13,7 +13,7 @@ export default defineNuxtConfig({
},
app: {
head: {
htmlAttrs: { lang: 'en' }
htmlAttrs: { lang: 'de' }
}
}
})
+1 -1
View File
@@ -11,7 +11,7 @@ export default defineEventHandler(async (event) => {
if (!(await passwordMatches(event, password))) {
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)
+1 -1
View File
@@ -6,6 +6,6 @@ export default defineEventHandler(async (event) => {
const session = await authSession(event)
if (!session.data.unlocked) {
throw createError({ statusCode: 401, statusMessage: 'Locked' })
throw createError({ statusCode: 401, statusMessage: 'Gesperrt' })
}
})
+2 -2
View File
@@ -10,7 +10,7 @@ const SESSION_MAX_AGE_S = 60 * 60 * 24 * 30
export function authSession(event: H3Event) {
const { sessionSecret } = useRuntimeConfig(event)
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, {
// 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> {
const { diaryPassword } = useRuntimeConfig(event)
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)])
+2 -2
View File
@@ -17,7 +17,7 @@ async function request<T>(
): Promise<T> {
const { baserowUrl, baserowToken } = useRuntimeConfig(event)
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 {
@@ -33,7 +33,7 @@ async function request<T>(
const detail = error?.data?.detail
throw createError({
statusCode: error?.statusCode ?? 502,
statusMessage: typeof detail === 'string' ? detail : 'Baserow request failed'
statusMessage: typeof detail === 'string' ? detail : 'Baserow-Anfrage fehlgeschlagen'
})
}
}
+1 -1
View File
@@ -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>> {
const result = schema.safeParse(await readBody(event))
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
}
+9
View File
@@ -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
}