From ccb8dd3d154dbba92d8ce6508d7775cb3bdf80ef Mon Sep 17 00:00:00 2001 From: huaqo Date: Wed, 30 Sep 2026 21:55:40 +0200 Subject: [PATCH] feat: add currently_reading and read and repsonsive menu --- README.md | 9 ++- .../wishlist/app/components/WishlistCard.vue | 2 +- .../app/components/WishlistControls.vue | 43 ++++++++++++ .../wishlist/app/components/WishlistShelf.vue | 19 +++++ .../wishlist/app/composables/useWishlist.ts | 47 ++++++++----- layers/wishlist/app/pages/wishlist.vue | 70 +++++++++++++------ .../wishlist/server/api/wishlist/read.get.ts | 1 + .../server/api/wishlist/reading.get.ts | 1 + layers/wishlist/server/utils/shelf.ts | 46 ++++++++++++ layers/wishlist/shared/types/wishlist.ts | 7 ++ server/utils/baserow.ts | 5 +- 11 files changed, 207 insertions(+), 43 deletions(-) create mode 100644 layers/wishlist/app/components/WishlistControls.vue create mode 100644 layers/wishlist/app/components/WishlistShelf.vue create mode 100644 layers/wishlist/server/api/wishlist/read.get.ts create mode 100644 layers/wishlist/server/api/wishlist/reading.get.ts create mode 100644 layers/wishlist/server/utils/shelf.ts diff --git a/README.md b/README.md index 18b0e36..9503d2c 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,14 @@ pnpm dev # Wishlist 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. +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 `title` (German title) and `authors`. @@ -52,7 +57,9 @@ app/ useWishlistCover.ts cover from the table or Open Library, saves it back server/ 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/shelf.ts books table lookup, listShelfBooks(filter) shared/types/wishlist.ts ``` diff --git a/layers/wishlist/app/components/WishlistCard.vue b/layers/wishlist/app/components/WishlistCard.vue index cd79f85..d06e14a 100644 --- a/layers/wishlist/app/components/WishlistCard.vue +++ b/layers/wishlist/app/components/WishlistCard.vue @@ -20,7 +20,7 @@ const authors = computed(() => props.book.authors.map(wishlistAuthorName).join(' +defineProps<{ floating?: boolean }>() + +const search = defineModel('search', { required: true }) +const sort = defineModel('sort', { required: true }) + +const sortItems = [ + { label: 'Autor', value: 'author' }, + { label: 'Titel', value: 'title' } +] + + + diff --git a/layers/wishlist/app/components/WishlistShelf.vue b/layers/wishlist/app/components/WishlistShelf.vue new file mode 100644 index 0000000..d728c74 --- /dev/null +++ b/layers/wishlist/app/components/WishlistShelf.vue @@ -0,0 +1,19 @@ + + + diff --git a/layers/wishlist/app/composables/useWishlist.ts b/layers/wishlist/app/composables/useWishlist.ts index 5938fa1..7e4e1ca 100644 --- a/layers/wishlist/app/composables/useWishlist.ts +++ b/layers/wishlist/app/composables/useWishlist.ts @@ -1,38 +1,51 @@ export type WishlistSort = 'title' | 'author' +type ListedBook = Pick + /** "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 { +function searchableText(book: ListedBook): string { 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 }) 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] ?? '') + const byTitle = (a: ListedBook, b: ListedBook) => collator.compare(a.title, b.title) + const byAuthor = (a: ListedBook, b: ListedBook) => 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) + ? (a: ListedBook, b: ListedBook) => byTitle(a, b) || byAuthor(a, b) + : (a: ListedBook, b: ListedBook) => byAuthor(a, b) || byTitle(a, b) +} + +function searchAndSort(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, sort: Ref) { const { data, error, status } = useFetch('/api/wishlist/books', { key: 'wishlist-books', default: () => [] }) + const { data: reading } = useFetch('/api/wishlist/reading', { key: 'wishlist-reading', default: () => [] }) + const { data: read } = useFetch('/api/wishlist/read', { key: 'wishlist-read', 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 } + return { + books: computed(() => searchAndSort(data.value, search.value, sort.value)), + total: computed(() => data.value.length), + readingBooks: computed(() => searchAndSort(reading.value, search.value, sort.value)), + readingTotal: computed(() => reading.value.length), + readBooks: computed(() => searchAndSort(read.value, search.value, sort.value)), + readTotal: computed(() => read.value.length), + error, + status + } } diff --git a/layers/wishlist/app/pages/wishlist.vue b/layers/wishlist/app/pages/wishlist.vue index 9d9d57d..b04d3c2 100644 --- a/layers/wishlist/app/pages/wishlist.vue +++ b/layers/wishlist/app/pages/wishlist.vue @@ -1,31 +1,42 @@ diff --git a/layers/wishlist/server/api/wishlist/read.get.ts b/layers/wishlist/server/api/wishlist/read.get.ts new file mode 100644 index 0000000..1b2df58 --- /dev/null +++ b/layers/wishlist/server/api/wishlist/read.get.ts @@ -0,0 +1 @@ +export default defineEventHandler(event => listShelfBooks(event, { filter__read__boolean: true })) diff --git a/layers/wishlist/server/api/wishlist/reading.get.ts b/layers/wishlist/server/api/wishlist/reading.get.ts new file mode 100644 index 0000000..cdcfc38 --- /dev/null +++ b/layers/wishlist/server/api/wishlist/reading.get.ts @@ -0,0 +1 @@ +export default defineEventHandler(event => listShelfBooks(event, { filter__currently_reading__boolean: true })) diff --git a/layers/wishlist/server/utils/shelf.ts b/layers/wishlist/server/utils/shelf.ts new file mode 100644 index 0000000..0ab03bc --- /dev/null +++ b/layers/wishlist/server/utils/shelf.ts @@ -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 { + if (!booksTable) { + const fields = await listFields(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): Promise { + const rows = await listRows(event, await wishlistBooksTable(event), filters) + return rows.map(toShelfBook).filter(book => book.title) +} diff --git a/layers/wishlist/shared/types/wishlist.ts b/layers/wishlist/shared/types/wishlist.ts index ab457a9..c88a3d8 100644 --- a/layers/wishlist/shared/types/wishlist.ts +++ b/layers/wishlist/shared/types/wishlist.ts @@ -8,3 +8,10 @@ export interface WishlistBook { /** As stored in Baserow: "Last, First" */ 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[] +} diff --git a/server/utils/baserow.ts b/server/utils/baserow.ts index 3a377f7..4662154 100644 --- a/server/utils/baserow.ts +++ b/server/utils/baserow.ts @@ -38,12 +38,13 @@ async function request( } } -export async function listRows(event: H3Event, tableId: string): Promise { +/** All rows of a table. `filters` are Baserow row filters, e.g. `{ filter__read__boolean: true }`. */ +export async function listRows(event: H3Event, tableId: string, filters: Fields = {}): Promise { const path = `rows/table/${tableId}/` const rows: Row[] = [] for (let page = 1; ; page++) { const { count, results } = await request>(event, path, { - query: { user_field_names: true, size: PAGE_SIZE, page } + query: { ...filters, user_field_names: true, size: PAGE_SIZE, page } }) rows.push(...results) if (rows.length >= count || results.length === 0) return rows