feat: add wishlist

This commit is contained in:
2026-09-30 19:15:57 +02:00
parent ab3bbb6338
commit 1f6061b998
57 changed files with 638 additions and 232 deletions
@@ -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 }
}
+51
View File
@@ -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>
+5
View File
@@ -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)
})
+41
View File
@@ -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))]
}
}
+10
View File
@@ -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[]
}