feat: add wishlist
This commit is contained in:
@@ -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