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