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>