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
+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
}