feat: add wishlist
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
export default defineAppConfig({
|
||||
diary: {
|
||||
showCovers: false
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
subtitle?: string | null
|
||||
}>()
|
||||
|
||||
const background = computed(() => {
|
||||
let hue = 0
|
||||
for (const char of props.title) hue = (hue * 31 + char.codePointAt(0)!) % 360
|
||||
return `linear-gradient(155deg, hsl(${hue} 38% 40%), hsl(${(hue + 24) % 360} 42% 22%))`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative size-full overflow-hidden text-white [container-type:inline-size]"
|
||||
:style="{ background }"
|
||||
role="img"
|
||||
:aria-label="`Cover of ${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]">
|
||||
<span class="font-serif text-[clamp(0.5rem,11cqw,1.05rem)] leading-tight font-semibold text-balance wrap-anywhere line-clamp-6">
|
||||
{{ title }}
|
||||
</span>
|
||||
<span v-if="subtitle" class="text-[clamp(0.4rem,7cqw,0.7rem)] leading-tight tracking-wide text-white/75 uppercase wrap-anywhere line-clamp-2">
|
||||
{{ subtitle }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import type { Entry } from '../types/entry'
|
||||
|
||||
const props = defineProps<{ entry: Entry, search: string }>()
|
||||
|
||||
const COLLAPSE_AFTER_CHARS = 320
|
||||
const COLLAPSE_AFTER_LINES = 5
|
||||
|
||||
const { showCovers } = useAppConfig().diary
|
||||
const expanded = ref(false)
|
||||
|
||||
const searching = computed(() => searchTerms(props.search).length > 0)
|
||||
const isLong = computed(() =>
|
||||
props.entry.text.length > COLLAPSE_AFTER_CHARS || props.entry.text.split('\n').length > COLLAPSE_AFTER_LINES
|
||||
)
|
||||
const collapsed = computed(() => isLong.value && !expanded.value && !searching.value)
|
||||
const wordCount = computed(() => props.entry.text.split(/\s+/).filter(Boolean).length)
|
||||
const parts = computed(() => highlight(props.entry.text, props.search))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex gap-4">
|
||||
<div
|
||||
v-if="showCovers && entry.kind === 'review'"
|
||||
class="aspect-2/3 w-16 shrink-0 self-start overflow-hidden rounded shadow-sm ring-1 ring-default"
|
||||
>
|
||||
<CoverTile :title="entry.review.title" :subtitle="entry.review.author" />
|
||||
</div>
|
||||
|
||||
<article class="min-w-0 flex-1">
|
||||
<div class="flex min-w-0 items-baseline gap-3">
|
||||
<span class="shrink-0 text-xs text-muted tabular-nums">{{ formatDate(entry.date) }}</span>
|
||||
<p v-if="entry.kind === 'review'" class="min-w-0 truncate">
|
||||
<span class="font-medium text-highlighted">{{ entry.review.title }}</span>
|
||||
<span v-if="entry.review.author" class="ml-3 text-sm text-muted">{{ entry.review.author }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="mt-1.5 text-sm leading-relaxed whitespace-pre-line text-default"
|
||||
:class="{ 'line-clamp-5': collapsed }"
|
||||
><template v-for="(part, index) in parts" :key="index"><mark v-if="part.hit" class="rounded-sm bg-primary/30 px-0.5 text-highlighted">{{ part.text }}</mark><template v-else>{{ part.text }}</template></template></p>
|
||||
|
||||
<UButton
|
||||
v-if="isLong && !searching"
|
||||
size="xs"
|
||||
color="neutral"
|
||||
variant="link"
|
||||
class="mt-1 px-0"
|
||||
:label="expanded ? 'Show less' : `Read more (${wordCount} words)`"
|
||||
@click="expanded = !expanded"
|
||||
/>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
const search = ref('')
|
||||
const { years, errors } = useDiary(search)
|
||||
const log = useLog()
|
||||
</script>
|
||||
|
||||
<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>
|
||||
<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()" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
v-for="{ what, error } in errors"
|
||||
:key="what"
|
||||
color="error"
|
||||
variant="subtle"
|
||||
icon="i-lucide-triangle-alert"
|
||||
:title="`Couldn't load your ${what}`"
|
||||
:description="errorMessage(error)"
|
||||
class="mb-6"
|
||||
/>
|
||||
|
||||
<section v-for="group in years" :key="group.year" class="mb-10">
|
||||
<header class="sticky top-0 z-10 mb-4 bg-default/90 py-2 backdrop-blur">
|
||||
<h2 class="text-xl font-semibold text-highlighted tabular-nums">{{ group.year }}</h2>
|
||||
</header>
|
||||
|
||||
<UTimeline :items="group.items" color="primary" size="md" :ui="{ wrapper: 'pb-6' }">
|
||||
<template #wrapper="{ item }">
|
||||
<DiaryEntry :key="item.value" :entry="item.entry" :search="search" />
|
||||
</template>
|
||||
</UTimeline>
|
||||
</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.' }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
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 }
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UModal v-model:open="open" title="Log">
|
||||
<template #body>
|
||||
<UTabs :items="tabs" default-value="note" class="w-full" :ui="{ content: 'pt-4' }">
|
||||
<template #note>
|
||||
<NoteForm @saved="close()" />
|
||||
</template>
|
||||
<template #review>
|
||||
<ReviewForm @saved="close()" />
|
||||
</template>
|
||||
</UTabs>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
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') })
|
||||
|
||||
function onSubmit(event: FormSubmitEvent<NewNoteInput>) {
|
||||
return save(event.data)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UForm :schema="newNoteSchema" :state="state" class="space-y-5" @submit="onSubmit">
|
||||
<UFormField label="Date" name="date" required>
|
||||
<UInput v-model="state.date" type="date" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Note" 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" />
|
||||
</UForm>
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
const emit = defineEmits<{ unlocked: [] }>()
|
||||
|
||||
const password = ref('')
|
||||
const error = ref<string>()
|
||||
const unlocking = ref(false)
|
||||
|
||||
async function unlock() {
|
||||
if (!password.value) return
|
||||
unlocking.value = true
|
||||
error.value = undefined
|
||||
try {
|
||||
await $fetch('/api/diary/login', { method: 'POST', body: { password: password.value } })
|
||||
emit('unlocked')
|
||||
} catch (e) {
|
||||
error.value = errorMessage(e) ?? 'Couldn\'t unlock the diary'
|
||||
password.value = ''
|
||||
} finally {
|
||||
unlocking.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UModal :open="true" :dismissible="false" :close="false" title="Diary" description="Enter your password to open the diary.">
|
||||
<template #body>
|
||||
<form class="space-y-4" @submit.prevent="unlock">
|
||||
<UFormField label="Password" :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" />
|
||||
</form>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import type { FormSubmitEvent } from '@nuxt/ui'
|
||||
|
||||
const emit = defineEmits<{ saved: [] }>()
|
||||
|
||||
const { data: books, status } = useFetch<DiaryBook[]>('/api/diary/books', { key: 'diary-books', default: () => [], lazy: true })
|
||||
|
||||
// Recently read books first, then the rest by title
|
||||
const bookItems = computed(() => books.value
|
||||
.toSorted((a, b) => (b.readDate ?? '').localeCompare(a.readDate ?? '') || a.title.localeCompare(b.title, 'de'))
|
||||
.map(book => ({
|
||||
label: book.title,
|
||||
description: book.authors.join('; '),
|
||||
originalTitle: book.originalTitle,
|
||||
value: book.id
|
||||
})))
|
||||
|
||||
const state = reactive({ bookId: undefined as number | undefined, date: todayIso(), text: '' })
|
||||
const { saving, save } = useSaveEntry({
|
||||
url: '/api/diary/reviews',
|
||||
dataKey: ['diary-reviews', 'diary-books'],
|
||||
noun: 'review',
|
||||
onSaved: () => emit('saved')
|
||||
})
|
||||
|
||||
watch(() => state.bookId, (id) => {
|
||||
state.date = books.value.find(book => book.id === id)?.readDate ?? todayIso()
|
||||
})
|
||||
|
||||
function onSubmit(event: FormSubmitEvent<NewReviewInput>) {
|
||||
return save(event.data)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UForm :schema="newReviewSchema" :state="state" class="space-y-5" @submit="onSubmit">
|
||||
<UFormField label="Book" 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"
|
||||
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>
|
||||
<UInput v-model="state.date" type="date" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Review" 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" />
|
||||
</UForm>
|
||||
</template>
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { Entry } from '../types/entry'
|
||||
|
||||
const KIND_ORDER: Record<Entry['kind'], number> = { review: 0, note: 1 }
|
||||
|
||||
function toEntries(reviews: Review[], notes: Note[]): Entry[] {
|
||||
return [
|
||||
...reviews.map(review => ({ kind: 'review' as const, key: `review-${review.id}`, date: review.date, text: review.text, review })),
|
||||
...notes.map(note => ({ kind: 'note' as const, key: `note-${note.id}`, date: note.date, text: note.text, note }))
|
||||
]
|
||||
}
|
||||
|
||||
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}`
|
||||
return text.toLowerCase()
|
||||
}
|
||||
|
||||
function newestFirst(a: Entry, b: Entry): number {
|
||||
return b.date.localeCompare(a.date) || KIND_ORDER[a.kind] - KIND_ORDER[b.kind] || b.key.localeCompare(a.key)
|
||||
}
|
||||
|
||||
function entryIcon(entry: Entry): string {
|
||||
if (entry.kind === 'note') return NOTE_ICON
|
||||
return entry.review.medium ? MEDIUM_ICONS[entry.review.medium] : REVIEW_ICON
|
||||
}
|
||||
|
||||
function groupByYear(entries: Entry[]) {
|
||||
const years = new Map<string, Entry[]>()
|
||||
for (const entry of entries) {
|
||||
const year = entry.date.slice(0, 4)
|
||||
years.set(year, [...(years.get(year) ?? []), entry])
|
||||
}
|
||||
return [...years].map(([year, entries]) => ({
|
||||
year,
|
||||
items: entries.map(entry => ({ value: entry.key, icon: entryIcon(entry), entry }))
|
||||
}))
|
||||
}
|
||||
|
||||
export function useDiary(search: Ref<string>) {
|
||||
const { data: reviews, error: reviewsError } = useFetch<Review[]>('/api/diary/reviews', { key: 'diary-reviews', default: () => [] })
|
||||
const { data: notes, error: notesError } = useFetch<Note[]>('/api/diary/notes', { key: 'diary-notes', default: () => [] })
|
||||
|
||||
const years = computed(() => {
|
||||
const terms = searchTerms(search.value)
|
||||
const matches = toEntries(reviews.value, notes.value)
|
||||
.filter(entry => entry.text)
|
||||
.filter((entry) => {
|
||||
const text = searchableText(entry)
|
||||
return terms.every(term => text.includes(term))
|
||||
})
|
||||
.toSorted(newestFirst)
|
||||
return groupByYear(matches)
|
||||
})
|
||||
|
||||
const errors = computed(() => [
|
||||
{ what: 'reviews', error: reviewsError.value },
|
||||
{ what: 'notes', error: notesError.value }
|
||||
].filter(({ error }) => error))
|
||||
|
||||
return { years, errors }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export function useLog() {
|
||||
const open = useState('diary-log-open', () => false)
|
||||
|
||||
return {
|
||||
open,
|
||||
show: () => { open.value = true },
|
||||
close: () => { open.value = false }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
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 }) {
|
||||
const toast = useToast()
|
||||
const saving = ref(false)
|
||||
|
||||
async function save(body: Record<string, unknown>) {
|
||||
saving.value = true
|
||||
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' })
|
||||
options.onSaved()
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
title: `Couldn't save the ${options.noun}`,
|
||||
description: errorMessage(error),
|
||||
color: 'error',
|
||||
icon: 'i-lucide-triangle-alert'
|
||||
})
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { saving, save }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Diary' })
|
||||
|
||||
const { data: session, refresh } = await useFetch<{ unlocked: boolean }>('/api/diary/session', { key: 'diary-session' })
|
||||
const unlocked = computed(() => !!session.value?.unlocked)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="unlocked">
|
||||
<UMain>
|
||||
<UContainer class="py-8">
|
||||
<DiaryTimeline />
|
||||
</UContainer>
|
||||
</UMain>
|
||||
|
||||
<LogModal />
|
||||
</template>
|
||||
|
||||
<PasswordModal v-else @unlocked="refresh()" />
|
||||
</template>
|
||||
@@ -0,0 +1,3 @@
|
||||
export type Entry =
|
||||
| { kind: 'review', key: string, date: string, text: string, review: Review }
|
||||
| { kind: 'note', key: string, date: string, text: string, note: Note }
|
||||
@@ -0,0 +1,10 @@
|
||||
export function formatDate(iso: string): string {
|
||||
return new Date(`${iso}T00:00:00`).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })
|
||||
}
|
||||
|
||||
export function todayIso(): string {
|
||||
const today = new Date()
|
||||
const month = String(today.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(today.getDate()).padStart(2, '0')
|
||||
return `${today.getFullYear()}-${month}-${day}`
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
interface FetchErrorLike {
|
||||
data?: { statusMessage?: string }
|
||||
statusMessage?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
export function errorMessage(error: unknown): string | undefined {
|
||||
const e = error as FetchErrorLike | null
|
||||
return e?.data?.statusMessage || e?.statusMessage || e?.message
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
interface TextPart {
|
||||
text: string
|
||||
hit: boolean
|
||||
}
|
||||
|
||||
export function searchTerms(query: string): string[] {
|
||||
return query.trim().toLowerCase().split(/\s+/).filter(Boolean)
|
||||
}
|
||||
|
||||
export function highlight(text: string, query: string): TextPart[] {
|
||||
const terms = searchTerms(query)
|
||||
if (!terms.length) return [{ text, hit: false }]
|
||||
|
||||
const escaped = terms.map(term => term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
||||
return text
|
||||
.split(new RegExp(`(${escaped.join('|')})`, 'gi'))
|
||||
.map((part, index) => ({ text: part, hit: index % 2 === 1 }))
|
||||
.filter(part => part.text)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export const NOTE_ICON = 'i-lucide-notebook-pen'
|
||||
export const REVIEW_ICON = 'i-lucide-star'
|
||||
|
||||
export const MEDIUM_ICONS: Record<Medium, string> = {
|
||||
book: 'i-lucide-book-open',
|
||||
movie: 'i-lucide-clapperboard',
|
||||
album: 'i-lucide-disc-3'
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export default defineNuxtConfig({
|
||||
runtimeConfig: {
|
||||
baserowReviewsTableId: '',
|
||||
baserowNotesTableId: '',
|
||||
diaryPassword: '',
|
||||
sessionSecret: ''
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const rows = await listRows<DiaryBookRow>(event, await booksTableId(event))
|
||||
return rows.map(toDiaryBook).filter(book => book.title)
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
const FAILED_LOGIN_DELAY_MS = 1000
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { password } = await readValidBody(event, loginSchema)
|
||||
|
||||
if (!(await passwordMatches(event, password))) {
|
||||
await new Promise(resolve => setTimeout(resolve, FAILED_LOGIN_DELAY_MS))
|
||||
throw createError({ statusCode: 401, statusMessage: 'Wrong password' })
|
||||
}
|
||||
|
||||
const session = await diarySession(event)
|
||||
await session.update({ unlocked: true })
|
||||
return { unlocked: true }
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const rows = await listRows<NoteRow>(event, diaryTable(event, 'notes'))
|
||||
return rows.map(toNote).filter(note => note.date)
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const input = await readValidBody(event, newNoteSchema)
|
||||
const row = await createRow<NoteRow>(event, diaryTable(event, 'notes'), {
|
||||
Datum: input.date,
|
||||
Notes: input.text
|
||||
})
|
||||
return toNote(row)
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const rows = await listRows<ReviewRow>(event, diaryTable(event, 'reviews'))
|
||||
return rows.map(toReview).filter(review => review.date)
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const input = await readValidBody(event, newReviewSchema)
|
||||
|
||||
// The review's date is a lookup of the book's read_date, so the date is saved on the book
|
||||
await updateRow(event, await booksTableId(event), input.bookId, { read_date: input.date })
|
||||
|
||||
const row = await createRow<ReviewRow>(event, diaryTable(event, 'reviews'), {
|
||||
books: [input.bookId],
|
||||
review: input.text,
|
||||
medium: await mediumOptionId(event, 'book')
|
||||
})
|
||||
return toReview(row)
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = await diarySession(event)
|
||||
return { unlocked: !!session.data.unlocked }
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
const PUBLIC_ROUTES = ['/api/diary/session', '/api/diary/login']
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const path = event.path.split('?')[0]!
|
||||
if (!path.startsWith('/api/diary/') || PUBLIC_ROUTES.includes(path)) return
|
||||
|
||||
const session = await diarySession(event)
|
||||
if (!session.data.unlocked) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'The diary is locked' })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { H3Event } from 'h3'
|
||||
|
||||
interface DiarySession {
|
||||
unlocked?: boolean
|
||||
}
|
||||
|
||||
const SESSION_MAX_AGE_S = 60 * 60 * 24 * 30
|
||||
|
||||
export function diarySession(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)' })
|
||||
}
|
||||
return useSession<DiarySession>(event, {
|
||||
name: 'diary',
|
||||
password: sessionSecret,
|
||||
maxAge: SESSION_MAX_AGE_S,
|
||||
cookie: { httpOnly: true, sameSite: 'lax', secure: !import.meta.dev }
|
||||
})
|
||||
}
|
||||
|
||||
async function sha256(text: string): Promise<Uint8Array> {
|
||||
return new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)))
|
||||
}
|
||||
|
||||
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' })
|
||||
}
|
||||
|
||||
const [a, b] = await Promise.all([sha256(guess), sha256(diaryPassword)])
|
||||
let difference = 0
|
||||
for (let i = 0; i < a.length; i++) difference |= a[i]! ^ b[i]!
|
||||
return difference === 0
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
interface LinkValue {
|
||||
value: string | null
|
||||
}
|
||||
|
||||
export interface DiaryBookRow {
|
||||
id: number
|
||||
title: string | null
|
||||
title_german: string | null
|
||||
authors: LinkValue[]
|
||||
read_date: string | null
|
||||
}
|
||||
|
||||
export function toDiaryBook(row: DiaryBookRow): DiaryBook {
|
||||
const original = (row.title ?? '').trim()
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title_german?.trim() || original,
|
||||
originalTitle: original,
|
||||
authors: lookupValues(row.authors),
|
||||
readDate: row.read_date || null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { H3Event } from 'h3'
|
||||
|
||||
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` })
|
||||
}
|
||||
return id
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export interface NoteRow {
|
||||
id: number
|
||||
Datum: string | null
|
||||
Notes: string | null
|
||||
}
|
||||
|
||||
export function toNote(row: NoteRow): Note {
|
||||
return {
|
||||
id: row.id,
|
||||
date: row.Datum ?? '',
|
||||
text: (row.Notes ?? '').trim()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { H3Event } from 'h3'
|
||||
|
||||
interface LookupValue {
|
||||
value: string | null
|
||||
}
|
||||
|
||||
// title, date and author_or_director are lookups through the `books` link
|
||||
export interface ReviewRow {
|
||||
id: number
|
||||
books: LookupValue[]
|
||||
title: LookupValue[]
|
||||
date: LookupValue[]
|
||||
author_or_director: LookupValue[]
|
||||
review: string | null
|
||||
medium: SelectOption | null
|
||||
}
|
||||
|
||||
interface SelectOption {
|
||||
id: number
|
||||
value: string
|
||||
}
|
||||
|
||||
interface Field {
|
||||
name: string
|
||||
select_options?: SelectOption[]
|
||||
link_row_table_id?: number
|
||||
}
|
||||
|
||||
const FIELD_CACHE_MS = 10 * 60_000
|
||||
|
||||
let reviewFields: { fields: Field[], fetchedAt: number } | null = null
|
||||
|
||||
async function fields(event: H3Event): Promise<Field[]> {
|
||||
if (!reviewFields || Date.now() - reviewFields.fetchedAt > FIELD_CACHE_MS) {
|
||||
reviewFields = { fields: await listFields<Field>(event, diaryTable(event, 'reviews')), fetchedAt: Date.now() }
|
||||
}
|
||||
return reviewFields.fields
|
||||
}
|
||||
|
||||
function toMedium(value: string | undefined): Medium | null {
|
||||
return MEDIUM_KEYS.find(medium => MEDIUMS[medium].label === value) ?? null
|
||||
}
|
||||
|
||||
export function lookupValues(values: LookupValue[] | null | undefined): string[] {
|
||||
return (values ?? []).map(({ value }) => value?.trim()).filter((value): value is string => !!value)
|
||||
}
|
||||
|
||||
export function toReview(row: ReviewRow): Review {
|
||||
return {
|
||||
id: row.id,
|
||||
date: lookupValues(row.date)[0] ?? '',
|
||||
title: lookupValues(row.title).join(' / ') || lookupValues(row.books).join(' / '),
|
||||
author: lookupValues(row.author_or_director).join(', ') || null,
|
||||
text: (row.review ?? '').replace(/ /g, ' ').trim(),
|
||||
medium: toMedium(row.medium?.value)
|
||||
}
|
||||
}
|
||||
|
||||
export async function mediumOptionId(event: H3Event, medium: Medium): Promise<number> {
|
||||
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` })
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
/** The books table, taken from the reviews table's `books` link field. */
|
||||
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' })
|
||||
}
|
||||
return String(id)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { H3Event } from 'h3'
|
||||
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' })
|
||||
}
|
||||
return result.data
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/** A row of the books table, as offered by the review form */
|
||||
export interface DiaryBook {
|
||||
id: number
|
||||
/** German title, or the original title if there is none */
|
||||
title: string
|
||||
originalTitle: string
|
||||
/** As stored in Baserow: "Last, First" */
|
||||
authors: string[]
|
||||
readDate: string | null
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface Note {
|
||||
id: number
|
||||
date: string
|
||||
text: string
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export type Medium = 'book' | 'movie' | 'album'
|
||||
|
||||
export interface Review {
|
||||
id: number
|
||||
date: string
|
||||
title: string
|
||||
author: string | null
|
||||
text: string
|
||||
medium: Medium | null
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Medium } from '../types/review'
|
||||
|
||||
export const MEDIUMS: Record<Medium, { label: string, creator: string }> = {
|
||||
book: { label: 'Book', creator: 'Author' },
|
||||
movie: { label: 'Movie', creator: 'Director' },
|
||||
album: { label: 'Album', creator: 'Artist' }
|
||||
}
|
||||
|
||||
export const MEDIUM_KEYS = Object.keys(MEDIUMS) as [Medium, ...Medium[]]
|
||||
@@ -0,0 +1,22 @@
|
||||
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')
|
||||
|
||||
export const newNoteSchema = z.object({
|
||||
date: isoDate,
|
||||
text: requiredText
|
||||
})
|
||||
|
||||
export const newReviewSchema = z.object({
|
||||
bookId: z.number({ error: 'Pick a book' }).int().positive('Pick a book'),
|
||||
date: isoDate,
|
||||
text: requiredText
|
||||
})
|
||||
|
||||
export const loginSchema = z.object({
|
||||
password: z.string().min(1)
|
||||
})
|
||||
|
||||
export type NewNoteInput = z.output<typeof newNoteSchema>
|
||||
export type NewReviewInput = z.output<typeof newReviewSchema>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{ book: WishlistBook }>()
|
||||
|
||||
const { url: cover, loading } = useWishlistCover(() => props.book)
|
||||
const imageFailed = ref(false)
|
||||
const authors = computed(() => props.book.authors.map(wishlistAuthorName).join(', '))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="book.thalia ? 'a' : 'div'"
|
||||
:href="book.thalia ?? undefined"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="group block focus-visible:outline-none"
|
||||
>
|
||||
<div
|
||||
class="relative aspect-[2/3] overflow-hidden rounded-md bg-elevated shadow-sm ring-1 ring-default transition duration-200 group-hover:-translate-y-1 group-hover:shadow-lg group-focus-visible:ring-2 group-focus-visible:ring-primary"
|
||||
>
|
||||
<img
|
||||
v-if="cover && !imageFailed"
|
||||
:src="cover"
|
||||
:alt="`Cover of ${book.title}`"
|
||||
loading="lazy"
|
||||
class="size-full object-cover"
|
||||
@error="imageFailed = true"
|
||||
>
|
||||
<div v-else-if="loading" class="size-full animate-pulse bg-accented" />
|
||||
<div v-else class="flex size-full flex-col justify-between bg-gradient-to-br from-primary-100 to-primary-200 p-3 text-primary-950 dark:from-primary-900 dark:to-primary-950 dark:text-primary-50">
|
||||
<p class="font-serif text-base leading-snug font-semibold line-clamp-5">{{ book.title }}</p>
|
||||
<p class="text-xs opacity-75 line-clamp-2">{{ authors }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mt-2 text-sm leading-snug font-medium text-highlighted line-clamp-2">{{ book.title }}</p>
|
||||
<p class="text-xs text-muted line-clamp-1">{{ authors }}</p>
|
||||
</component>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
export type WishlistSort = 'title' | 'author'
|
||||
|
||||
/** "Last, First" -> "First Last" */
|
||||
export function wishlistAuthorName(author: string): string {
|
||||
const [last, first] = author.split(/,\s*/, 2)
|
||||
return first ? `${first} ${last}` : author
|
||||
}
|
||||
|
||||
function searchableText(book: WishlistBook): string {
|
||||
const authors = book.authors.flatMap(author => [author, wishlistAuthorName(author)])
|
||||
return [book.title, ...authors, book.isbn, book.isbn.replace(/-/g, ''), book.thalia ?? ''].join(' ').toLowerCase()
|
||||
}
|
||||
|
||||
const collator = new Intl.Collator('de', { sensitivity: 'base', numeric: true })
|
||||
|
||||
function compare(sort: WishlistSort) {
|
||||
const byTitle = (a: WishlistBook, b: WishlistBook) => collator.compare(a.title, b.title)
|
||||
const byAuthor = (a: WishlistBook, b: WishlistBook) => collator.compare(a.authors[0] ?? '', b.authors[0] ?? '')
|
||||
return sort === 'title'
|
||||
? (a: WishlistBook, b: WishlistBook) => byTitle(a, b) || byAuthor(a, b)
|
||||
: (a: WishlistBook, b: WishlistBook) => byAuthor(a, b) || byTitle(a, b)
|
||||
}
|
||||
|
||||
export function useWishlist(search: Ref<string>, sort: Ref<WishlistSort>) {
|
||||
const { data, error, status } = useFetch<WishlistBook[]>('/api/wishlist/books', { key: 'wishlist-books', default: () => [] })
|
||||
|
||||
const books = computed(() => {
|
||||
const terms = search.value.trim().toLowerCase().split(/\s+/).filter(Boolean)
|
||||
return data.value
|
||||
.filter((book) => {
|
||||
const text = searchableText(book)
|
||||
return terms.every(term => text.includes(term))
|
||||
})
|
||||
.toSorted(compare(sort.value))
|
||||
})
|
||||
|
||||
return { books, total: computed(() => data.value.length), error, status }
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
const lookups = new Map<string, Promise<string | null>>()
|
||||
|
||||
async function lookUpCover(isbn: string): Promise<string | null> {
|
||||
try {
|
||||
const edition = await $fetch<{ covers?: number[] }>(`https://openlibrary.org/isbn/${isbn}.json`)
|
||||
const id = edition.covers?.find(id => id > 0)
|
||||
return id ? `https://covers.openlibrary.org/b/id/${id}-M.jpg` : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The book's cover: the one saved in the table, or else looked up on Open Library by ISBN
|
||||
* and saved to the table so the next visit doesn't have to look it up again.
|
||||
*/
|
||||
export function useWishlistCover(book: MaybeRefOrGetter<WishlistBook>) {
|
||||
const url = ref<string | null>(toValue(book).cover)
|
||||
const loading = ref(!url.value)
|
||||
|
||||
onMounted(async () => {
|
||||
if (url.value) return
|
||||
const { id, isbn } = toValue(book)
|
||||
const digits = isbn.replace(/[^\dX]/gi, '')
|
||||
if (!digits) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (!lookups.has(digits)) lookups.set(digits, lookUpCover(digits))
|
||||
url.value = await lookups.get(digits)!
|
||||
loading.value = false
|
||||
|
||||
if (url.value) {
|
||||
$fetch(`/api/wishlist/books/${id}/cover`, { method: 'POST', body: { cover: url.value } }).catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
return { url, loading }
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Wunschzettel' })
|
||||
|
||||
const search = ref('')
|
||||
const sort = ref<WishlistSort>('title')
|
||||
const sortItems = [
|
||||
{ label: 'Author', value: 'author' },
|
||||
{ label: 'Titel', value: 'title' }
|
||||
]
|
||||
|
||||
const { books, total, error, status } = useWishlist(search, sort)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UMain>
|
||||
<UContainer class="py-8">
|
||||
<div class="mb-8 flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 class="text-2xl font-semibold text-highlighted">
|
||||
Huaqo's Wunschzettel
|
||||
</h1>
|
||||
<div class="flex w-full items-center gap-2 sm:w-auto">
|
||||
<UInput
|
||||
v-model="search"
|
||||
icon="i-lucide-search"
|
||||
placeholder="Title, author, ISBN or link"
|
||||
class="flex-1 sm:w-72 sm:flex-none"
|
||||
/>
|
||||
<USelect v-model="sort" :items="sortItems" icon="i-lucide-arrow-down-a-z" class="w-32" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
v-if="error"
|
||||
color="error"
|
||||
variant="subtle"
|
||||
icon="i-lucide-triangle-alert"
|
||||
title="Couldn't load the wishlist"
|
||||
:description="(error.data as { statusMessage?: string } | undefined)?.statusMessage || error.statusMessage || error.message"
|
||||
class="mb-6"
|
||||
/>
|
||||
|
||||
<div class="grid grid-cols-2 gap-x-4 gap-y-8 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
<WishlistCard v-for="book in books" :key="book.id" :book="book" />
|
||||
</div>
|
||||
|
||||
<p v-if="!books.length && !error && status !== 'pending'" class="py-16 text-center text-muted">
|
||||
{{ search ? 'No book matches your search.' : 'The wishlist is empty.' }}
|
||||
</p>
|
||||
</UContainer>
|
||||
</UMain>
|
||||
</template>
|
||||
@@ -0,0 +1,5 @@
|
||||
export default defineNuxtConfig({
|
||||
runtimeConfig: {
|
||||
baserowWishlistTableId: ''
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const coverSchema = z.object({
|
||||
cover: z.string().regex(OPEN_LIBRARY_COVER, 'Not an Open Library cover')
|
||||
})
|
||||
|
||||
// Public: anyone can fill in a missing cover, but only with an Open Library cover URL.
|
||||
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' })
|
||||
}
|
||||
|
||||
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.cover) return toWishlistBook(row)
|
||||
|
||||
return toWishlistBook(await updateRow<WishlistRow>(event, table, id, { cover: body.data.cover }))
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const rows = await listRows<WishlistRow>(event, wishlistTable(event))
|
||||
return rows.map(toWishlistBook).filter(book => book.title)
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { H3Event } from 'h3'
|
||||
|
||||
interface LinkValue {
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface WishlistRow {
|
||||
id: number
|
||||
isbn: string | null
|
||||
thalia: string | null
|
||||
cover?: string | null
|
||||
books: LinkValue[]
|
||||
authors: LinkValue[]
|
||||
title: LinkValue[]
|
||||
}
|
||||
|
||||
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' })
|
||||
}
|
||||
return baserowWishlistTableId
|
||||
}
|
||||
|
||||
export const OPEN_LIBRARY_COVER = /^https:\/\/covers\.openlibrary\.org\/b\/id\/\d+-M\.jpg$/
|
||||
|
||||
function values(links: LinkValue[] | null | undefined): string[] {
|
||||
return (links ?? []).map(link => link.value?.trim()).filter((value): value is string => !!value)
|
||||
}
|
||||
|
||||
export function toWishlistBook(row: WishlistRow): WishlistBook {
|
||||
const germanTitle = values(row.title).join(' / ')
|
||||
return {
|
||||
id: row.id,
|
||||
isbn: (row.isbn ?? '').trim(),
|
||||
thalia: row.thalia?.trim() || null,
|
||||
cover: row.cover?.trim() || null,
|
||||
title: germanTitle || values(row.books).join(' / '),
|
||||
authors: [...new Set(values(row.authors))]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export interface WishlistBook {
|
||||
id: number
|
||||
isbn: string
|
||||
thalia: string | null
|
||||
/** Open Library cover URL, saved to the table once it has been looked up */
|
||||
cover: string | null
|
||||
title: string
|
||||
/** As stored in Baserow: "Last, First" */
|
||||
authors: string[]
|
||||
}
|
||||
Reference in New Issue
Block a user