feat: add currently_reading and read and repsonsive menu
This commit is contained in:
@@ -29,9 +29,14 @@ pnpm dev
|
|||||||
# Wishlist
|
# Wishlist
|
||||||
|
|
||||||
A public shelf of the books in the Baserow wishlist table (`NUXT_BASEROW_WISHLIST_TABLE_ID`): cover, German title
|
A public shelf of the books in the Baserow wishlist table (`NUXT_BASEROW_WISHLIST_TABLE_ID`): cover, German title
|
||||||
and author per card. A card opens its Thalia link. Search matches title, author, ISBN and Thalia link;
|
and author per card. A card opens its Thalia link. Search matches title and author;
|
||||||
sort by author (last name) or title.
|
sort by author (last name) or title.
|
||||||
|
|
||||||
|
Below the shelf, **Bereits gelesen** lists title and author of every book in the books table with `read` set
|
||||||
|
(`read_date` filled in), so nobody gives a book twice. Above it, **Gerade am Lesen** lists the books with
|
||||||
|
`currently_reading` checked. Search and sort apply to it too. The books table is found through
|
||||||
|
the wishlist's `books` link.
|
||||||
|
|
||||||
**Table:** `isbn` (text), `thalia` (URL), `cover` (URL), `books` (link to the books table) and the lookups
|
**Table:** `isbn` (text), `thalia` (URL), `cover` (URL), `books` (link to the books table) and the lookups
|
||||||
`title` (German title) and `authors`.
|
`title` (German title) and `authors`.
|
||||||
|
|
||||||
@@ -52,7 +57,9 @@ app/
|
|||||||
useWishlistCover.ts cover from the table or Open Library, saves it back
|
useWishlistCover.ts cover from the table or Open Library, saves it back
|
||||||
server/
|
server/
|
||||||
api/wishlist/books/ GET list, POST [id]/cover
|
api/wishlist/books/ GET list, POST [id]/cover
|
||||||
|
api/wishlist/reading.get.ts, read.get.ts books being read / already read
|
||||||
utils/wishlist.ts Baserow row -> WishlistBook
|
utils/wishlist.ts Baserow row -> WishlistBook
|
||||||
|
utils/shelf.ts books table lookup, listShelfBooks(filter)
|
||||||
shared/types/wishlist.ts
|
shared/types/wishlist.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const authors = computed(() => props.book.authors.map(wishlistAuthorName).join('
|
|||||||
<img
|
<img
|
||||||
v-if="cover && !imageFailed"
|
v-if="cover && !imageFailed"
|
||||||
:src="cover"
|
:src="cover"
|
||||||
:alt="`Cover of ${book.title}`"
|
:alt="`Cover von ${book.title}`"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
class="size-full object-cover"
|
class="size-full object-cover"
|
||||||
@error="imageFailed = true"
|
@error="imageFailed = true"
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ floating?: boolean }>()
|
||||||
|
|
||||||
|
const search = defineModel<string>('search', { required: true })
|
||||||
|
const sort = defineModel<WishlistSort>('sort', { required: true })
|
||||||
|
|
||||||
|
const sortItems = [
|
||||||
|
{ label: 'Autor', value: 'author' },
|
||||||
|
{ label: 'Titel', value: 'title' }
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<UInput
|
||||||
|
v-model="search"
|
||||||
|
icon="i-lucide-search"
|
||||||
|
placeholder="Titel oder Autor"
|
||||||
|
:variant="floating ? 'none' : 'outline'"
|
||||||
|
class="min-w-0 flex-1"
|
||||||
|
:ui="{ trailing: 'pe-1' }"
|
||||||
|
>
|
||||||
|
<template v-if="search" #trailing>
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-x"
|
||||||
|
color="neutral"
|
||||||
|
variant="link"
|
||||||
|
size="sm"
|
||||||
|
aria-label="Suche leeren"
|
||||||
|
@click="search = ''"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</UInput>
|
||||||
|
<USelect
|
||||||
|
v-model="sort"
|
||||||
|
:items="sortItems"
|
||||||
|
icon="i-lucide-arrow-down-a-z"
|
||||||
|
:variant="floating ? 'soft' : 'outline'"
|
||||||
|
class="w-28 shrink-0"
|
||||||
|
:class="{ 'rounded-full': floating }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ title: string, books: WishlistShelfBook[], total: number }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="mt-16 border-t border-default pt-8">
|
||||||
|
<h2 class="mb-6 text-xl font-semibold text-highlighted">
|
||||||
|
{{ title }}
|
||||||
|
<span class="text-base font-normal text-muted tabular-nums">{{ books.length === total ? total : `${books.length} / ${total}` }}</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<ul class="columns-1 gap-x-8 sm:columns-2 lg:columns-3">
|
||||||
|
<li v-for="book in books" :key="book.id" class="mb-3 break-inside-avoid text-sm leading-snug sm:mb-2">
|
||||||
|
<span class="block text-highlighted sm:inline">{{ book.title }}</span>
|
||||||
|
<span v-if="book.authors.length" class="block text-muted sm:ms-1.5 sm:inline">{{ book.authors.map(wishlistAuthorName).join(', ') }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -1,38 +1,51 @@
|
|||||||
export type WishlistSort = 'title' | 'author'
|
export type WishlistSort = 'title' | 'author'
|
||||||
|
|
||||||
|
type ListedBook = Pick<WishlistBook, 'title' | 'authors'>
|
||||||
|
|
||||||
/** "Last, First" -> "First Last" */
|
/** "Last, First" -> "First Last" */
|
||||||
export function wishlistAuthorName(author: string): string {
|
export function wishlistAuthorName(author: string): string {
|
||||||
const [last, first] = author.split(/,\s*/, 2)
|
const [last, first] = author.split(/,\s*/, 2)
|
||||||
return first ? `${first} ${last}` : author
|
return first ? `${first} ${last}` : author
|
||||||
}
|
}
|
||||||
|
|
||||||
function searchableText(book: WishlistBook): string {
|
function searchableText(book: ListedBook): string {
|
||||||
const authors = book.authors.flatMap(author => [author, wishlistAuthorName(author)])
|
const authors = book.authors.flatMap(author => [author, wishlistAuthorName(author)])
|
||||||
return [book.title, ...authors, book.isbn, book.isbn.replace(/-/g, ''), book.thalia ?? ''].join(' ').toLowerCase()
|
return [book.title, ...authors].join(' ').toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
const collator = new Intl.Collator('de', { sensitivity: 'base', numeric: true })
|
const collator = new Intl.Collator('de', { sensitivity: 'base', numeric: true })
|
||||||
|
|
||||||
function compare(sort: WishlistSort) {
|
function compare(sort: WishlistSort) {
|
||||||
const byTitle = (a: WishlistBook, b: WishlistBook) => collator.compare(a.title, b.title)
|
const byTitle = (a: ListedBook, b: ListedBook) => collator.compare(a.title, b.title)
|
||||||
const byAuthor = (a: WishlistBook, b: WishlistBook) => collator.compare(a.authors[0] ?? '', b.authors[0] ?? '')
|
const byAuthor = (a: ListedBook, b: ListedBook) => collator.compare(a.authors[0] ?? '', b.authors[0] ?? '')
|
||||||
return sort === 'title'
|
return sort === 'title'
|
||||||
? (a: WishlistBook, b: WishlistBook) => byTitle(a, b) || byAuthor(a, b)
|
? (a: ListedBook, b: ListedBook) => byTitle(a, b) || byAuthor(a, b)
|
||||||
: (a: WishlistBook, b: WishlistBook) => byAuthor(a, b) || byTitle(a, b)
|
: (a: ListedBook, b: ListedBook) => byAuthor(a, b) || byTitle(a, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchAndSort<Book extends ListedBook>(books: Book[], search: string, sort: WishlistSort): Book[] {
|
||||||
|
const terms = search.trim().toLowerCase().split(/\s+/).filter(Boolean)
|
||||||
|
return books
|
||||||
|
.filter((book) => {
|
||||||
|
const text = searchableText(book)
|
||||||
|
return terms.every(term => text.includes(term))
|
||||||
|
})
|
||||||
|
.toSorted(compare(sort))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useWishlist(search: Ref<string>, sort: Ref<WishlistSort>) {
|
export function useWishlist(search: Ref<string>, sort: Ref<WishlistSort>) {
|
||||||
const { data, error, status } = useFetch<WishlistBook[]>('/api/wishlist/books', { key: 'wishlist-books', default: () => [] })
|
const { data, error, status } = useFetch<WishlistBook[]>('/api/wishlist/books', { key: 'wishlist-books', default: () => [] })
|
||||||
|
const { data: reading } = useFetch<WishlistShelfBook[]>('/api/wishlist/reading', { key: 'wishlist-reading', default: () => [] })
|
||||||
|
const { data: read } = useFetch<WishlistShelfBook[]>('/api/wishlist/read', { key: 'wishlist-read', default: () => [] })
|
||||||
|
|
||||||
const books = computed(() => {
|
return {
|
||||||
const terms = search.value.trim().toLowerCase().split(/\s+/).filter(Boolean)
|
books: computed(() => searchAndSort(data.value, search.value, sort.value)),
|
||||||
return data.value
|
total: computed(() => data.value.length),
|
||||||
.filter((book) => {
|
readingBooks: computed(() => searchAndSort(reading.value, search.value, sort.value)),
|
||||||
const text = searchableText(book)
|
readingTotal: computed(() => reading.value.length),
|
||||||
return terms.every(term => text.includes(term))
|
readBooks: computed(() => searchAndSort(read.value, search.value, sort.value)),
|
||||||
})
|
readTotal: computed(() => read.value.length),
|
||||||
.toSorted(compare(sort.value))
|
error,
|
||||||
})
|
status
|
||||||
|
}
|
||||||
return { books, total: computed(() => data.value.length), error, status }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,42 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
useHead({ title: 'Wunschzettel' })
|
useHead({ title: 'Wunschzettel', htmlAttrs: { lang: 'de' } })
|
||||||
|
|
||||||
const search = ref('')
|
const search = ref('')
|
||||||
const sort = ref<WishlistSort>('title')
|
const sort = ref<WishlistSort>('title')
|
||||||
const sortItems = [
|
|
||||||
{ label: 'Author', value: 'author' },
|
|
||||||
{ label: 'Titel', value: 'title' }
|
|
||||||
]
|
|
||||||
|
|
||||||
const { books, total, error, status } = useWishlist(search, sort)
|
const { books, total, readingBooks, readingTotal, readBooks, readTotal, error, status } = useWishlist(search, sort)
|
||||||
|
|
||||||
|
// On desktop the controls sit in the header; the floating bar only drops in at the top once they have scrolled out of view.
|
||||||
|
// On mobile the header controls are hidden, so the bar is always there.
|
||||||
|
const headerControls = useTemplateRef<HTMLElement>('headerControls')
|
||||||
|
const headerControlsVisible = ref(true)
|
||||||
|
|
||||||
|
let observer: IntersectionObserver | undefined
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
observer = new IntersectionObserver(([entry]) => {
|
||||||
|
headerControlsVisible.value = !!entry?.isIntersecting
|
||||||
|
})
|
||||||
|
if (headerControls.value) observer.observe(headerControls.value)
|
||||||
|
})
|
||||||
|
onBeforeUnmount(() => observer?.disconnect())
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<UMain>
|
<UMain>
|
||||||
<UContainer class="py-8">
|
<UContainer class="pt-8 pb-32 sm:pb-8">
|
||||||
<div class="mb-8 flex flex-wrap items-center justify-between gap-3">
|
<div class="mb-8 flex items-center justify-between gap-3">
|
||||||
<h1 class="text-2xl font-semibold text-highlighted">
|
<div>
|
||||||
Huaqo's Wunschzettel
|
<h1 class="text-2xl font-semibold text-highlighted">
|
||||||
</h1>
|
Huaqo's Wunschzettel
|
||||||
<div class="flex w-full items-center gap-2 sm:w-auto">
|
<span v-if="total" class="text-base font-normal text-muted tabular-nums">{{ books.length === total ? total : `${books.length} / ${total}` }}</span>
|
||||||
<UInput
|
</h1>
|
||||||
v-model="search"
|
<p class="mt-1 text-sm text-muted">
|
||||||
icon="i-lucide-search"
|
Überrasch mich gern auch mit etwas, das hier nicht zu finden ist <3
|
||||||
placeholder="Title, author, ISBN or link"
|
</p>
|
||||||
class="flex-1 sm:w-72 sm:flex-none"
|
</div>
|
||||||
/>
|
<div ref="headerControls" class="hidden w-96 sm:block">
|
||||||
<USelect v-model="sort" :items="sortItems" icon="i-lucide-arrow-down-a-z" class="w-32" />
|
<WishlistControls v-model:search="search" v-model:sort="sort" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -34,7 +45,7 @@ const { books, total, error, status } = useWishlist(search, sort)
|
|||||||
color="error"
|
color="error"
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
icon="i-lucide-triangle-alert"
|
icon="i-lucide-triangle-alert"
|
||||||
title="Couldn't load the wishlist"
|
title="Der Wunschzettel konnte nicht geladen werden"
|
||||||
:description="(error.data as { statusMessage?: string } | undefined)?.statusMessage || error.statusMessage || error.message"
|
:description="(error.data as { statusMessage?: string } | undefined)?.statusMessage || error.statusMessage || error.message"
|
||||||
class="mb-6"
|
class="mb-6"
|
||||||
/>
|
/>
|
||||||
@@ -43,9 +54,24 @@ const { books, total, error, status } = useWishlist(search, sort)
|
|||||||
<WishlistCard v-for="book in books" :key="book.id" :book="book" />
|
<WishlistCard v-for="book in books" :key="book.id" :book="book" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="!books.length && !error && status !== 'pending'" class="py-16 text-center text-muted">
|
<p v-if="!books.length && !readingBooks.length && !readBooks.length && !error && status !== 'pending'" class="py-16 text-center text-muted">
|
||||||
{{ search ? 'No book matches your search.' : 'The wishlist is empty.' }}
|
{{ search ? 'Kein Buch passt zu deiner Suche.' : 'Der Wunschzettel ist leer.' }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<WishlistShelf v-if="readingBooks.length" title="Gerade am Lesen" :books="readingBooks" :total="readingTotal" />
|
||||||
|
<WishlistShelf v-if="readBooks.length" title="Bereits gelesen" :books="readBooks" :total="readTotal" />
|
||||||
</UContainer>
|
</UContainer>
|
||||||
|
|
||||||
|
<!-- Keeps search and sort reachable while scrolling: always at the bottom on mobile, at the top on desktop -->
|
||||||
|
<div class="pointer-events-none fixed inset-x-0 bottom-0 z-20 flex justify-center px-4 pb-[max(1rem,env(safe-area-inset-bottom))] sm:top-0 sm:bottom-auto sm:pt-4 sm:pb-0">
|
||||||
|
<WishlistControls
|
||||||
|
v-model:search="search"
|
||||||
|
v-model:sort="sort"
|
||||||
|
floating
|
||||||
|
:inert="headerControlsVisible || undefined"
|
||||||
|
class="pointer-events-auto w-full max-w-md rounded-full bg-default/80 p-2 shadow-lg ring-1 ring-default backdrop-blur transition duration-200"
|
||||||
|
:class="{ 'sm:pointer-events-none sm:-translate-y-[150%] sm:opacity-0': headerControlsVisible }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</UMain>
|
</UMain>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export default defineEventHandler(event => listShelfBooks(event, { filter__read__boolean: true }))
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export default defineEventHandler(event => listShelfBooks(event, { filter__currently_reading__boolean: true }))
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { H3Event } from 'h3'
|
||||||
|
|
||||||
|
interface LinkValue {
|
||||||
|
value: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShelfBookRow {
|
||||||
|
id: number
|
||||||
|
title: string | null
|
||||||
|
title_german: string | null
|
||||||
|
authors: LinkValue[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Field {
|
||||||
|
name: string
|
||||||
|
link_row_table_id?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
let booksTable: string | null = null
|
||||||
|
|
||||||
|
/** The books table, taken from the wishlist table's `books` link field. */
|
||||||
|
export async function wishlistBooksTable(event: H3Event): Promise<string> {
|
||||||
|
if (!booksTable) {
|
||||||
|
const fields = await listFields<Field>(event, wishlistTable(event))
|
||||||
|
const id = fields.find(field => field.name === 'books')?.link_row_table_id
|
||||||
|
if (!id) {
|
||||||
|
throw createError({ statusCode: 500, statusMessage: 'The wishlist table has no "books" link field' })
|
||||||
|
}
|
||||||
|
booksTable = String(id)
|
||||||
|
}
|
||||||
|
return booksTable
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toShelfBook(row: ShelfBookRow): WishlistShelfBook {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
title: row.title_german?.trim() || (row.title ?? '').trim(),
|
||||||
|
authors: (row.authors ?? []).map(({ value }) => value?.trim()).filter((value): value is string => !!value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Books from the books table matching a Baserow filter, e.g. `{ filter__read__boolean: true }` */
|
||||||
|
export async function listShelfBooks(event: H3Event, filters: Record<string, unknown>): Promise<WishlistShelfBook[]> {
|
||||||
|
const rows = await listRows<ShelfBookRow>(event, await wishlistBooksTable(event), filters)
|
||||||
|
return rows.map(toShelfBook).filter(book => book.title)
|
||||||
|
}
|
||||||
@@ -8,3 +8,10 @@ export interface WishlistBook {
|
|||||||
/** As stored in Baserow: "Last, First" */
|
/** As stored in Baserow: "Last, First" */
|
||||||
authors: string[]
|
authors: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A book from the books table, as listed under "Gerade am Lesen" and "Bereits gelesen" */
|
||||||
|
export interface WishlistShelfBook {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
authors: string[]
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,12 +38,13 @@ async function request<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listRows<Row>(event: H3Event, tableId: string): Promise<Row[]> {
|
/** All rows of a table. `filters` are Baserow row filters, e.g. `{ filter__read__boolean: true }`. */
|
||||||
|
export async function listRows<Row>(event: H3Event, tableId: string, filters: Fields = {}): Promise<Row[]> {
|
||||||
const path = `rows/table/${tableId}/`
|
const path = `rows/table/${tableId}/`
|
||||||
const rows: Row[] = []
|
const rows: Row[] = []
|
||||||
for (let page = 1; ; page++) {
|
for (let page = 1; ; page++) {
|
||||||
const { count, results } = await request<RowPage<Row>>(event, path, {
|
const { count, results } = await request<RowPage<Row>>(event, path, {
|
||||||
query: { user_field_names: true, size: PAGE_SIZE, page }
|
query: { ...filters, user_field_names: true, size: PAGE_SIZE, page }
|
||||||
})
|
})
|
||||||
rows.push(...results)
|
rows.push(...results)
|
||||||
if (rows.length >= count || results.length === 0) return rows
|
if (rows.length >= count || results.length === 0) return rows
|
||||||
|
|||||||
Reference in New Issue
Block a user