From 1f6061b998068f4b745dbd23cf3bac69f9f72cf3 Mon Sep 17 00:00:00 2001 From: huaqo Date: Wed, 30 Sep 2026 19:15:57 +0200 Subject: [PATCH] feat: add wishlist --- .env.example | 1 + README.md | 98 +++++++++++++++---- app/app.config.ts | 1 - app/app.vue | 17 +--- app/components/ReviewForm.vue | 44 --------- app/pages/index.vue | 43 +------- layers/diary/app/app.config.ts | 5 + .../diary/app}/components/CoverTile.vue | 0 .../diary/app}/components/DiaryEntry.vue | 4 +- layers/diary/app/components/DiaryTimeline.vue | 44 +++++++++ .../diary/app}/components/LogModal.vue | 0 .../diary/app}/components/NoteForm.vue | 2 +- .../diary/app}/components/PasswordModal.vue | 2 +- layers/diary/app/components/ReviewForm.vue | 62 ++++++++++++ .../diary/app}/composables/useDiary.ts | 6 +- .../diary/app}/composables/useLog.ts | 2 +- .../diary/app}/composables/useSaveEntry.ts | 2 +- layers/diary/app/pages/diary.vue | 20 ++++ {app => layers/diary/app}/types/entry.ts | 0 {app => layers/diary/app}/utils/date.ts | 0 {app => layers/diary/app}/utils/errors.ts | 0 {app => layers/diary/app}/utils/highlight.ts | 0 {app => layers/diary/app}/utils/icons.ts | 0 layers/diary/nuxt.config.ts | 8 ++ layers/diary/server/api/diary/books.get.ts | 4 + .../diary/server/api/diary}/login.post.ts | 0 .../diary/server/api/diary}/notes.get.ts | 2 +- .../diary/server/api/diary}/notes.post.ts | 2 +- .../diary/server/api/diary}/reviews.get.ts | 2 +- layers/diary/server/api/diary/reviews.post.ts | 13 +++ .../diary/server/api/diary}/session.get.ts | 0 .../diary/server/middleware/diary-auth.ts | 4 +- {server => layers/diary/server}/utils/auth.ts | 0 layers/diary/server/utils/book.ts | 22 +++++ layers/diary/server/utils/diaryTable.ts | 10 ++ {server => layers/diary/server}/utils/note.ts | 0 layers/diary/server/utils/review.ts | 75 ++++++++++++++ .../diary/server}/utils/validation.ts | 0 layers/diary/shared/types/book.ts | 10 ++ {shared => layers/diary/shared}/types/note.ts | 0 .../diary/shared}/types/review.ts | 0 .../diary/shared}/utils/medium.ts | 0 .../diary/shared}/utils/schemas.ts | 5 +- .../wishlist/app/components/WishlistCard.vue | 38 +++++++ .../wishlist/app/composables/useWishlist.ts | 38 +++++++ .../app/composables/useWishlistCover.ts | 40 ++++++++ layers/wishlist/app/pages/wishlist.vue | 51 ++++++++++ layers/wishlist/nuxt.config.ts | 5 + .../api/wishlist/books/[id]/cover.post.ts | 22 +++++ .../server/api/wishlist/books/index.get.ts | 4 + layers/wishlist/server/utils/wishlist.ts | 41 ++++++++ layers/wishlist/shared/types/wishlist.ts | 10 ++ nuxt.config.ts | 8 +- package.json | 2 +- server/api/reviews.post.ts | 11 --- server/utils/baserow.ts | 32 +++--- server/utils/review.ts | 58 ----------- 57 files changed, 638 insertions(+), 232 deletions(-) delete mode 100644 app/components/ReviewForm.vue create mode 100644 layers/diary/app/app.config.ts rename {app => layers/diary/app}/components/CoverTile.vue (100%) rename {app => layers/diary/app}/components/DiaryEntry.vue (95%) create mode 100644 layers/diary/app/components/DiaryTimeline.vue rename {app => layers/diary/app}/components/LogModal.vue (100%) rename {app => layers/diary/app}/components/NoteForm.vue (85%) rename {app => layers/diary/app}/components/PasswordModal.vue (91%) create mode 100644 layers/diary/app/components/ReviewForm.vue rename {app => layers/diary/app}/composables/useDiary.ts (92%) rename {app => layers/diary/app}/composables/useLog.ts (70%) rename {app => layers/diary/app}/composables/useSaveEntry.ts (93%) create mode 100644 layers/diary/app/pages/diary.vue rename {app => layers/diary/app}/types/entry.ts (100%) rename {app => layers/diary/app}/utils/date.ts (100%) rename {app => layers/diary/app}/utils/errors.ts (100%) rename {app => layers/diary/app}/utils/highlight.ts (100%) rename {app => layers/diary/app}/utils/icons.ts (100%) create mode 100644 layers/diary/nuxt.config.ts create mode 100644 layers/diary/server/api/diary/books.get.ts rename {server/api => layers/diary/server/api/diary}/login.post.ts (100%) rename {server/api => layers/diary/server/api/diary}/notes.get.ts (59%) rename {server/api => layers/diary/server/api/diary}/notes.post.ts (70%) rename {server/api => layers/diary/server/api/diary}/reviews.get.ts (59%) create mode 100644 layers/diary/server/api/diary/reviews.post.ts rename {server/api => layers/diary/server/api/diary}/session.get.ts (100%) rename server/middleware/auth.ts => layers/diary/server/middleware/diary-auth.ts (64%) rename {server => layers/diary/server}/utils/auth.ts (100%) create mode 100644 layers/diary/server/utils/book.ts create mode 100644 layers/diary/server/utils/diaryTable.ts rename {server => layers/diary/server}/utils/note.ts (100%) create mode 100644 layers/diary/server/utils/review.ts rename {server => layers/diary/server}/utils/validation.ts (100%) create mode 100644 layers/diary/shared/types/book.ts rename {shared => layers/diary/shared}/types/note.ts (100%) rename {shared => layers/diary/shared}/types/review.ts (100%) rename {shared => layers/diary/shared}/utils/medium.ts (100%) rename {shared => layers/diary/shared}/utils/schemas.ts (73%) create mode 100644 layers/wishlist/app/components/WishlistCard.vue create mode 100644 layers/wishlist/app/composables/useWishlist.ts create mode 100644 layers/wishlist/app/composables/useWishlistCover.ts create mode 100644 layers/wishlist/app/pages/wishlist.vue create mode 100644 layers/wishlist/nuxt.config.ts create mode 100644 layers/wishlist/server/api/wishlist/books/[id]/cover.post.ts create mode 100644 layers/wishlist/server/api/wishlist/books/index.get.ts create mode 100644 layers/wishlist/server/utils/wishlist.ts create mode 100644 layers/wishlist/shared/types/wishlist.ts delete mode 100644 server/api/reviews.post.ts delete mode 100644 server/utils/review.ts diff --git a/.env.example b/.env.example index 176fa46..331c23e 100644 --- a/.env.example +++ b/.env.example @@ -2,5 +2,6 @@ NUXT_BASEROW_URL=http://baserow.example NUXT_BASEROW_TOKEN= NUXT_BASEROW_REVIEWS_TABLE_ID=866 NUXT_BASEROW_NOTES_TABLE_ID=864 +NUXT_BASEROW_WISHLIST_TABLE_ID=870 NUXT_DIARY_PASSWORD= NUXT_SESSION_SECRET= diff --git a/README.md b/README.md index b70fcdb..18b0e36 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,15 @@ -# Diary +# Website -A password-protected diary of reviews (books, movies, albums) and notes, built with Nuxt 4 + Nuxt UI on top of two -self-hosted Baserow tables. +One Nuxt 4 + Nuxt UI deployment made of self-contained apps. Each app is a [Nuxt layer](https://nuxt.com/docs/guide/going-further/layers) +in `layers//` with its own pages, components, composables, server routes and config; layers in that folder are +picked up automatically. The root only holds what apps share: the shell (`app/app.vue`, global CSS, theme), an empty `/`, and the Baserow +client `server/utils/baserow.ts` (`listRows`, `createRow`, `updateRow`, `listFields`, each taking a table ID). +`NUXT_BASEROW_URL` and `NUXT_BASEROW_TOKEN` are shared; each app reads its own table IDs. + +| Path | App | +|---|---| +| `/diary` | [Diary](#diary) — `layers/diary/` (password protected) | +| `/wishlist` | [Wishlist](#wishlist) — `layers/wishlist/` | ```bash pnpm install @@ -9,6 +17,50 @@ cp .env.example .env pnpm dev ``` +## Adding an app + +1. Create `layers//` with `app/pages/.vue` (or `app/pages//index.vue`), and whatever else it needs: + `app/components`, `app/composables`, `server/api//…`, `shared/`, and a `nuxt.config.ts` for its runtime config. +2. Keep it contained: serve its API under `/api//`, and prefix `useState` / `useFetch` keys and app config + with the app name. All layers share one auto-import namespace, so give components and exported utils + names that won't clash with another app's (e.g. `DiaryEntry`, not `Entry`). +3. Don't use `~/` inside a layer — it points to the root `app/`. Use relative imports. + +# 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; +sort by author (last name) or title. + +**Table:** `isbn` (text), `thalia` (URL), `cover` (URL), `books` (link to the books table) and the lookups +`title` (German title) and `authors`. + +**Covers:** `cover` is used if set. Otherwise the browser looks the ISBN up on Open Library +(`openlibrary.org/isbn/.json`) and posts the cover URL to `/api/wishlist/books//cover`, which saves it to the +row, so each book is looked up only until it has a cover. That route is public but only accepts +`https://covers.openlibrary.org/b/id/-M.jpg` and never overwrites a cover. Books Open Library has no cover for +get a generated tile; paste any image URL into `cover` to give them one. + +``` +layers/wishlist/ +nuxt.config.ts baserowWishlistTableId +app/ + pages/wishlist.vue search, sort, grid + components/WishlistCard.vue cover + title + author, links to Thalia + composables/ + useWishlist.ts loads, searches and sorts the books + useWishlistCover.ts cover from the table or Open Library, saves it back +server/ + api/wishlist/books/ GET list, POST [id]/cover + utils/wishlist.ts Baserow row -> WishlistBook +shared/types/wishlist.ts +``` + +# Diary + +A password-protected diary of reviews (books, movies, albums) and notes, built with Nuxt 4 + Nuxt UI on top of two +self-hosted Baserow tables. + ## Configuration | Variable | Purpose | @@ -17,29 +69,34 @@ pnpm dev | `NUXT_BASEROW_TOKEN` | Database token with read and create access to both tables | | `NUXT_BASEROW_REVIEWS_TABLE_ID` | Reviews table | | `NUXT_BASEROW_NOTES_TABLE_ID` | Notes table | +| `NUXT_BASEROW_WISHLIST_TABLE_ID` | Wishlist table (see [Wishlist](#wishlist)) | | `NUXT_DIARY_PASSWORD` | Password that unlocks the diary | | `NUXT_SESSION_SECRET` | Signs the login cookie, at least 32 characters (`openssl rand -hex 32`) | -In `app/app.config.ts`, `showCovers: true` shows a generated title tile next to each review. +In `layers/diary/app/app.config.ts`, `diary.showCovers: true` shows a generated title tile next to each review. ## What it does -- `/` is the only page: reviews and notes on one timeline, newest first, grouped by year. +- `/diary`: reviews and notes on one timeline, newest first, grouped by year. - Only entries with text are shown. Long text is collapsed behind "Read more". - Search matches every word against the kind (`review`, `note`), the medium (`book`, `movie`, `album`), the date (`aug 2025`, `2025-08`), the title, the author and the text. Matches in the text are highlighted. -- "Log" opens a form with a Note / Review switch. -- Until the password is entered, only a password prompt is shown and every API route except - `/api/session` and `/api/login` returns 401. A correct password sets a signed, http-only cookie valid for 30 days. +- "Log" opens a form with a Note / Review switch. A review is written for a book picked from the books table; + its "Read on" date is saved as that book's `read_date`. +- Until the password is entered, only a password prompt is shown and every `/api/diary/*` route except + `/api/diary/session` and `/api/diary/login` returns 401. A correct password sets a signed, http-only cookie valid for 30 days. Changing `NUXT_SESSION_SECRET` logs out every browser. ## Code layout ``` +layers/diary/ +nuxt.config.ts runtime config (env vars above) app/ - app.vue password gate around the page - pages/index.vue the diary page + app.config.ts diary.showCovers + pages/diary.vue password gate around the timeline components/ + DiaryTimeline.vue the diary: search, Log button, entries grouped by year DiaryEntry.vue one timeline entry (date, title, text, read more) LogModal.vue Note / Review switch around the two forms NoteForm.vue, ReviewForm.vue @@ -52,11 +109,11 @@ app/ utils/ dates, icons, search highlighting, error messages types/entry.ts a timeline entry (review or note) server/ - middleware/auth.ts locks /api/* until unlocked - api/ session, login, reviews and notes (GET list, POST create) + middleware/diary-auth.ts locks /api/diary/* until unlocked + api/diary/ session, login, reviews and notes (GET list, POST create), books (for the review form) utils/ - baserow.ts Baserow client: listRows, createRow, listFields - review.ts, note.ts Baserow row -> app type + diaryTable.ts the diary's table IDs (the Baserow client itself is in the root server/utils) + review.ts, note.ts, book.ts Baserow row -> app type auth.ts session cookie and password check validation.ts readValidBody(event, zodSchema) shared/ @@ -67,8 +124,11 @@ shared/ ## Baserow tables -**Reviews:** `title`, `date` (ISO date), `author_or_director`, `review` (long text) and `medium` -(single select: `Book` / `Movie` / `Album`). +**Reviews:** `books` (link to the books table), `review` (long text), `medium` (single select: `Book` / `Movie` / +`Album`) and the lookups through `books`: `title` (`title_german`), `author_or_director` (`authors`) and +`date` (`read_date`). The books table is found through the `books` link, so it needs no table ID. + +**Books:** `title`, `title_german`, `authors` (link) and `read_date` (date). **Notes:** `Datum` (date) and `Notes` (long text). @@ -76,6 +136,8 @@ Rows without a date are skipped. Text is plain text: line breaks are kept and it ### Adding a medium +Reviews currently link only to books, so the form always saves `Book`. Other media need their own link field. + 1. Add the option to the `medium` single select in Baserow. -2. Add it to `Medium` in `shared/types/review.ts`, to `MEDIUMS` in `shared/utils/medium.ts` (its `label` must match - the Baserow option) and to `MEDIUM_ICONS` in `app/utils/icons.ts`. +2. Add it to `Medium` in `layers/diary/shared/types/review.ts`, to `MEDIUMS` in `layers/diary/shared/utils/medium.ts` + (its `label` must match the Baserow option) and to `MEDIUM_ICONS` in `layers/diary/app/utils/icons.ts`. diff --git a/app/app.config.ts b/app/app.config.ts index c626193..4a20171 100644 --- a/app/app.config.ts +++ b/app/app.config.ts @@ -1,5 +1,4 @@ export default defineAppConfig({ - showCovers: false, ui: { colors: { primary: 'amber', diff --git a/app/app.vue b/app/app.vue index 1fb9487..8dd09d3 100644 --- a/app/app.vue +++ b/app/app.vue @@ -1,20 +1,5 @@ - - diff --git a/app/components/ReviewForm.vue b/app/components/ReviewForm.vue deleted file mode 100644 index 79ff599..0000000 --- a/app/components/ReviewForm.vue +++ /dev/null @@ -1,44 +0,0 @@ - - - diff --git a/app/pages/index.vue b/app/pages/index.vue index c702073..04d85e1 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -1,44 +1,3 @@ - - diff --git a/layers/diary/app/app.config.ts b/layers/diary/app/app.config.ts new file mode 100644 index 0000000..d55ebdc --- /dev/null +++ b/layers/diary/app/app.config.ts @@ -0,0 +1,5 @@ +export default defineAppConfig({ + diary: { + showCovers: false + } +}) diff --git a/app/components/CoverTile.vue b/layers/diary/app/components/CoverTile.vue similarity index 100% rename from app/components/CoverTile.vue rename to layers/diary/app/components/CoverTile.vue diff --git a/app/components/DiaryEntry.vue b/layers/diary/app/components/DiaryEntry.vue similarity index 95% rename from app/components/DiaryEntry.vue rename to layers/diary/app/components/DiaryEntry.vue index 0fcf522..40e8a14 100644 --- a/app/components/DiaryEntry.vue +++ b/layers/diary/app/components/DiaryEntry.vue @@ -1,12 +1,12 @@ + + diff --git a/app/components/LogModal.vue b/layers/diary/app/components/LogModal.vue similarity index 100% rename from app/components/LogModal.vue rename to layers/diary/app/components/LogModal.vue diff --git a/app/components/NoteForm.vue b/layers/diary/app/components/NoteForm.vue similarity index 85% rename from app/components/NoteForm.vue rename to layers/diary/app/components/NoteForm.vue index f9420e1..433068c 100644 --- a/app/components/NoteForm.vue +++ b/layers/diary/app/components/NoteForm.vue @@ -4,7 +4,7 @@ import type { FormSubmitEvent } from '@nuxt/ui' const emit = defineEmits<{ saved: [] }>() const state = reactive({ date: todayIso(), text: '' }) -const { saving, save } = useSaveEntry({ url: '/api/notes', dataKey: 'notes', noun: 'note', onSaved: () => emit('saved') }) +const { saving, save } = useSaveEntry({ url: '/api/diary/notes', dataKey: 'diary-notes', noun: 'note', onSaved: () => emit('saved') }) function onSubmit(event: FormSubmitEvent) { return save(event.data) diff --git a/app/components/PasswordModal.vue b/layers/diary/app/components/PasswordModal.vue similarity index 91% rename from app/components/PasswordModal.vue rename to layers/diary/app/components/PasswordModal.vue index d49392a..51c75b6 100644 --- a/app/components/PasswordModal.vue +++ b/layers/diary/app/components/PasswordModal.vue @@ -10,7 +10,7 @@ async function unlock() { unlocking.value = true error.value = undefined try { - await $fetch('/api/login', { method: 'POST', body: { password: password.value } }) + await $fetch('/api/diary/login', { method: 'POST', body: { password: password.value } }) emit('unlocked') } catch (e) { error.value = errorMessage(e) ?? 'Couldn\'t unlock the diary' diff --git a/layers/diary/app/components/ReviewForm.vue b/layers/diary/app/components/ReviewForm.vue new file mode 100644 index 0000000..62f1ff3 --- /dev/null +++ b/layers/diary/app/components/ReviewForm.vue @@ -0,0 +1,62 @@ + + + diff --git a/app/composables/useDiary.ts b/layers/diary/app/composables/useDiary.ts similarity index 92% rename from app/composables/useDiary.ts rename to layers/diary/app/composables/useDiary.ts index bcc8fa4..e400bc7 100644 --- a/app/composables/useDiary.ts +++ b/layers/diary/app/composables/useDiary.ts @@ -1,4 +1,4 @@ -import type { Entry } from '~/types/entry' +import type { Entry } from '../types/entry' const KIND_ORDER: Record = { review: 0, note: 1 } @@ -39,8 +39,8 @@ function groupByYear(entries: Entry[]) { } export function useDiary(search: Ref) { - const { data: reviews, error: reviewsError } = useFetch('/api/reviews', { key: 'reviews', default: () => [] }) - const { data: notes, error: notesError } = useFetch('/api/notes', { key: 'notes', default: () => [] }) + const { data: reviews, error: reviewsError } = useFetch('/api/diary/reviews', { key: 'diary-reviews', default: () => [] }) + const { data: notes, error: notesError } = useFetch('/api/diary/notes', { key: 'diary-notes', default: () => [] }) const years = computed(() => { const terms = searchTerms(search.value) diff --git a/app/composables/useLog.ts b/layers/diary/app/composables/useLog.ts similarity index 70% rename from app/composables/useLog.ts rename to layers/diary/app/composables/useLog.ts index 3ec3e6e..875591b 100644 --- a/app/composables/useLog.ts +++ b/layers/diary/app/composables/useLog.ts @@ -1,5 +1,5 @@ export function useLog() { - const open = useState('log-open', () => false) + const open = useState('diary-log-open', () => false) return { open, diff --git a/app/composables/useSaveEntry.ts b/layers/diary/app/composables/useSaveEntry.ts similarity index 93% rename from app/composables/useSaveEntry.ts rename to layers/diary/app/composables/useSaveEntry.ts index 6358f01..22ea548 100644 --- a/app/composables/useSaveEntry.ts +++ b/layers/diary/app/composables/useSaveEntry.ts @@ -2,7 +2,7 @@ function capitalize(word: string): string { return word.charAt(0).toUpperCase() + word.slice(1) } -export function useSaveEntry(options: { url: string, dataKey: string, noun: string, onSaved: () => void }) { +export function useSaveEntry(options: { url: string, dataKey: string | string[], noun: string, onSaved: () => void }) { const toast = useToast() const saving = ref(false) diff --git a/layers/diary/app/pages/diary.vue b/layers/diary/app/pages/diary.vue new file mode 100644 index 0000000..f1edfe3 --- /dev/null +++ b/layers/diary/app/pages/diary.vue @@ -0,0 +1,20 @@ + + + diff --git a/app/types/entry.ts b/layers/diary/app/types/entry.ts similarity index 100% rename from app/types/entry.ts rename to layers/diary/app/types/entry.ts diff --git a/app/utils/date.ts b/layers/diary/app/utils/date.ts similarity index 100% rename from app/utils/date.ts rename to layers/diary/app/utils/date.ts diff --git a/app/utils/errors.ts b/layers/diary/app/utils/errors.ts similarity index 100% rename from app/utils/errors.ts rename to layers/diary/app/utils/errors.ts diff --git a/app/utils/highlight.ts b/layers/diary/app/utils/highlight.ts similarity index 100% rename from app/utils/highlight.ts rename to layers/diary/app/utils/highlight.ts diff --git a/app/utils/icons.ts b/layers/diary/app/utils/icons.ts similarity index 100% rename from app/utils/icons.ts rename to layers/diary/app/utils/icons.ts diff --git a/layers/diary/nuxt.config.ts b/layers/diary/nuxt.config.ts new file mode 100644 index 0000000..c94e562 --- /dev/null +++ b/layers/diary/nuxt.config.ts @@ -0,0 +1,8 @@ +export default defineNuxtConfig({ + runtimeConfig: { + baserowReviewsTableId: '', + baserowNotesTableId: '', + diaryPassword: '', + sessionSecret: '' + } +}) diff --git a/layers/diary/server/api/diary/books.get.ts b/layers/diary/server/api/diary/books.get.ts new file mode 100644 index 0000000..0a0864b --- /dev/null +++ b/layers/diary/server/api/diary/books.get.ts @@ -0,0 +1,4 @@ +export default defineEventHandler(async (event) => { + const rows = await listRows(event, await booksTableId(event)) + return rows.map(toDiaryBook).filter(book => book.title) +}) diff --git a/server/api/login.post.ts b/layers/diary/server/api/diary/login.post.ts similarity index 100% rename from server/api/login.post.ts rename to layers/diary/server/api/diary/login.post.ts diff --git a/server/api/notes.get.ts b/layers/diary/server/api/diary/notes.get.ts similarity index 59% rename from server/api/notes.get.ts rename to layers/diary/server/api/diary/notes.get.ts index 38e9deb..4acd265 100644 --- a/server/api/notes.get.ts +++ b/layers/diary/server/api/diary/notes.get.ts @@ -1,4 +1,4 @@ export default defineEventHandler(async (event) => { - const rows = await listRows(event, 'notes') + const rows = await listRows(event, diaryTable(event, 'notes')) return rows.map(toNote).filter(note => note.date) }) diff --git a/server/api/notes.post.ts b/layers/diary/server/api/diary/notes.post.ts similarity index 70% rename from server/api/notes.post.ts rename to layers/diary/server/api/diary/notes.post.ts index fb12c40..e16b53f 100644 --- a/server/api/notes.post.ts +++ b/layers/diary/server/api/diary/notes.post.ts @@ -1,6 +1,6 @@ export default defineEventHandler(async (event) => { const input = await readValidBody(event, newNoteSchema) - const row = await createRow(event, 'notes', { + const row = await createRow(event, diaryTable(event, 'notes'), { Datum: input.date, Notes: input.text }) diff --git a/server/api/reviews.get.ts b/layers/diary/server/api/diary/reviews.get.ts similarity index 59% rename from server/api/reviews.get.ts rename to layers/diary/server/api/diary/reviews.get.ts index 03d2bd0..fe3881e 100644 --- a/server/api/reviews.get.ts +++ b/layers/diary/server/api/diary/reviews.get.ts @@ -1,4 +1,4 @@ export default defineEventHandler(async (event) => { - const rows = await listRows(event, 'reviews') + const rows = await listRows(event, diaryTable(event, 'reviews')) return rows.map(toReview).filter(review => review.date) }) diff --git a/layers/diary/server/api/diary/reviews.post.ts b/layers/diary/server/api/diary/reviews.post.ts new file mode 100644 index 0000000..39a224f --- /dev/null +++ b/layers/diary/server/api/diary/reviews.post.ts @@ -0,0 +1,13 @@ +export default defineEventHandler(async (event) => { + const input = await readValidBody(event, newReviewSchema) + + // The review's date is a lookup of the book's read_date, so the date is saved on the book + await updateRow(event, await booksTableId(event), input.bookId, { read_date: input.date }) + + const row = await createRow(event, diaryTable(event, 'reviews'), { + books: [input.bookId], + review: input.text, + medium: await mediumOptionId(event, 'book') + }) + return toReview(row) +}) diff --git a/server/api/session.get.ts b/layers/diary/server/api/diary/session.get.ts similarity index 100% rename from server/api/session.get.ts rename to layers/diary/server/api/diary/session.get.ts diff --git a/server/middleware/auth.ts b/layers/diary/server/middleware/diary-auth.ts similarity index 64% rename from server/middleware/auth.ts rename to layers/diary/server/middleware/diary-auth.ts index 63dcb8c..9d48c19 100644 --- a/server/middleware/auth.ts +++ b/layers/diary/server/middleware/diary-auth.ts @@ -1,8 +1,8 @@ -const PUBLIC_ROUTES = ['/api/session', '/api/login'] +const PUBLIC_ROUTES = ['/api/diary/session', '/api/diary/login'] export default defineEventHandler(async (event) => { const path = event.path.split('?')[0]! - if (!path.startsWith('/api/') || PUBLIC_ROUTES.includes(path)) return + if (!path.startsWith('/api/diary/') || PUBLIC_ROUTES.includes(path)) return const session = await diarySession(event) if (!session.data.unlocked) { diff --git a/server/utils/auth.ts b/layers/diary/server/utils/auth.ts similarity index 100% rename from server/utils/auth.ts rename to layers/diary/server/utils/auth.ts diff --git a/layers/diary/server/utils/book.ts b/layers/diary/server/utils/book.ts new file mode 100644 index 0000000..5943e4f --- /dev/null +++ b/layers/diary/server/utils/book.ts @@ -0,0 +1,22 @@ +interface LinkValue { + value: string | null +} + +export interface DiaryBookRow { + id: number + title: string | null + title_german: string | null + authors: LinkValue[] + read_date: string | null +} + +export function toDiaryBook(row: DiaryBookRow): DiaryBook { + const original = (row.title ?? '').trim() + return { + id: row.id, + title: row.title_german?.trim() || original, + originalTitle: original, + authors: lookupValues(row.authors), + readDate: row.read_date || null + } +} diff --git a/layers/diary/server/utils/diaryTable.ts b/layers/diary/server/utils/diaryTable.ts new file mode 100644 index 0000000..56bd973 --- /dev/null +++ b/layers/diary/server/utils/diaryTable.ts @@ -0,0 +1,10 @@ +import type { H3Event } from 'h3' + +export function diaryTable(event: H3Event, table: 'reviews' | 'notes'): string { + const config = useRuntimeConfig(event) + const id = { reviews: config.baserowReviewsTableId, notes: config.baserowNotesTableId }[table] + if (!id) { + throw createError({ statusCode: 500, statusMessage: `NUXT_BASEROW_${table.toUpperCase()}_TABLE_ID is not set` }) + } + return id +} diff --git a/server/utils/note.ts b/layers/diary/server/utils/note.ts similarity index 100% rename from server/utils/note.ts rename to layers/diary/server/utils/note.ts diff --git a/layers/diary/server/utils/review.ts b/layers/diary/server/utils/review.ts new file mode 100644 index 0000000..581ac16 --- /dev/null +++ b/layers/diary/server/utils/review.ts @@ -0,0 +1,75 @@ +import type { H3Event } from 'h3' + +interface LookupValue { + value: string | null +} + +// title, date and author_or_director are lookups through the `books` link +export interface ReviewRow { + id: number + books: LookupValue[] + title: LookupValue[] + date: LookupValue[] + author_or_director: LookupValue[] + review: string | null + medium: SelectOption | null +} + +interface SelectOption { + id: number + value: string +} + +interface Field { + name: string + select_options?: SelectOption[] + link_row_table_id?: number +} + +const FIELD_CACHE_MS = 10 * 60_000 + +let reviewFields: { fields: Field[], fetchedAt: number } | null = null + +async function fields(event: H3Event): Promise { + if (!reviewFields || Date.now() - reviewFields.fetchedAt > FIELD_CACHE_MS) { + reviewFields = { fields: await listFields(event, diaryTable(event, 'reviews')), fetchedAt: Date.now() } + } + return reviewFields.fields +} + +function toMedium(value: string | undefined): Medium | null { + return MEDIUM_KEYS.find(medium => MEDIUMS[medium].label === value) ?? null +} + +export function lookupValues(values: LookupValue[] | null | undefined): string[] { + return (values ?? []).map(({ value }) => value?.trim()).filter((value): value is string => !!value) +} + +export function toReview(row: ReviewRow): Review { + return { + id: row.id, + date: lookupValues(row.date)[0] ?? '', + title: lookupValues(row.title).join(' / ') || lookupValues(row.books).join(' / '), + author: lookupValues(row.author_or_director).join(', ') || null, + text: (row.review ?? '').replace(/ /g, ' ').trim(), + medium: toMedium(row.medium?.value) + } +} + +export async function mediumOptionId(event: H3Event, medium: Medium): Promise { + const options = (await fields(event)).find(field => field.name === 'medium')?.select_options ?? [] + const id = options.find(option => toMedium(option.value) === medium)?.id + if (!id) { + throw createError({ statusCode: 500, statusMessage: `The medium field has no "${MEDIUMS[medium].label}" option` }) + } + return id +} + +/** The books table, taken from the reviews table's `books` link field. */ +export async function booksTableId(event: H3Event): Promise { + const id = (await fields(event)).find(field => field.name === 'books')?.link_row_table_id + if (!id) { + throw createError({ statusCode: 500, statusMessage: 'The reviews table has no "books" link field' }) + } + return String(id) +} diff --git a/server/utils/validation.ts b/layers/diary/server/utils/validation.ts similarity index 100% rename from server/utils/validation.ts rename to layers/diary/server/utils/validation.ts diff --git a/layers/diary/shared/types/book.ts b/layers/diary/shared/types/book.ts new file mode 100644 index 0000000..325413e --- /dev/null +++ b/layers/diary/shared/types/book.ts @@ -0,0 +1,10 @@ +/** A row of the books table, as offered by the review form */ +export interface DiaryBook { + id: number + /** German title, or the original title if there is none */ + title: string + originalTitle: string + /** As stored in Baserow: "Last, First" */ + authors: string[] + readDate: string | null +} diff --git a/shared/types/note.ts b/layers/diary/shared/types/note.ts similarity index 100% rename from shared/types/note.ts rename to layers/diary/shared/types/note.ts diff --git a/shared/types/review.ts b/layers/diary/shared/types/review.ts similarity index 100% rename from shared/types/review.ts rename to layers/diary/shared/types/review.ts diff --git a/shared/utils/medium.ts b/layers/diary/shared/utils/medium.ts similarity index 100% rename from shared/utils/medium.ts rename to layers/diary/shared/utils/medium.ts diff --git a/shared/utils/schemas.ts b/layers/diary/shared/utils/schemas.ts similarity index 73% rename from shared/utils/schemas.ts rename to layers/diary/shared/utils/schemas.ts index 837ea11..3c4570b 100644 --- a/shared/utils/schemas.ts +++ b/layers/diary/shared/utils/schemas.ts @@ -1,5 +1,4 @@ import { z } from 'zod' -import { MEDIUM_KEYS } from './medium' const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Pick a date') const requiredText = z.string().trim().min(1, 'Write something first') @@ -10,10 +9,8 @@ export const newNoteSchema = z.object({ }) export const newReviewSchema = z.object({ - medium: z.enum(MEDIUM_KEYS, { error: 'Pick a medium' }), + bookId: z.number({ error: 'Pick a book' }).int().positive('Pick a book'), date: isoDate, - title: z.string().trim().min(1, 'Add a title'), - author: z.string().trim().optional(), text: requiredText }) diff --git a/layers/wishlist/app/components/WishlistCard.vue b/layers/wishlist/app/components/WishlistCard.vue new file mode 100644 index 0000000..cd79f85 --- /dev/null +++ b/layers/wishlist/app/components/WishlistCard.vue @@ -0,0 +1,38 @@ + + + diff --git a/layers/wishlist/app/composables/useWishlist.ts b/layers/wishlist/app/composables/useWishlist.ts new file mode 100644 index 0000000..5938fa1 --- /dev/null +++ b/layers/wishlist/app/composables/useWishlist.ts @@ -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, sort: Ref) { + const { data, error, status } = useFetch('/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 } +} diff --git a/layers/wishlist/app/composables/useWishlistCover.ts b/layers/wishlist/app/composables/useWishlistCover.ts new file mode 100644 index 0000000..617e5ad --- /dev/null +++ b/layers/wishlist/app/composables/useWishlistCover.ts @@ -0,0 +1,40 @@ +const lookups = new Map>() + +async function lookUpCover(isbn: string): Promise { + 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) { + const url = ref(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 } +} diff --git a/layers/wishlist/app/pages/wishlist.vue b/layers/wishlist/app/pages/wishlist.vue new file mode 100644 index 0000000..9d9d57d --- /dev/null +++ b/layers/wishlist/app/pages/wishlist.vue @@ -0,0 +1,51 @@ + + + diff --git a/layers/wishlist/nuxt.config.ts b/layers/wishlist/nuxt.config.ts new file mode 100644 index 0000000..c2f3401 --- /dev/null +++ b/layers/wishlist/nuxt.config.ts @@ -0,0 +1,5 @@ +export default defineNuxtConfig({ + runtimeConfig: { + baserowWishlistTableId: '' + } +}) diff --git a/layers/wishlist/server/api/wishlist/books/[id]/cover.post.ts b/layers/wishlist/server/api/wishlist/books/[id]/cover.post.ts new file mode 100644 index 0000000..15cafb6 --- /dev/null +++ b/layers/wishlist/server/api/wishlist/books/[id]/cover.post.ts @@ -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(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(event, table, id, { cover: body.data.cover })) +}) diff --git a/layers/wishlist/server/api/wishlist/books/index.get.ts b/layers/wishlist/server/api/wishlist/books/index.get.ts new file mode 100644 index 0000000..d105774 --- /dev/null +++ b/layers/wishlist/server/api/wishlist/books/index.get.ts @@ -0,0 +1,4 @@ +export default defineEventHandler(async (event) => { + const rows = await listRows(event, wishlistTable(event)) + return rows.map(toWishlistBook).filter(book => book.title) +}) diff --git a/layers/wishlist/server/utils/wishlist.ts b/layers/wishlist/server/utils/wishlist.ts new file mode 100644 index 0000000..423a6c6 --- /dev/null +++ b/layers/wishlist/server/utils/wishlist.ts @@ -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))] + } +} diff --git a/layers/wishlist/shared/types/wishlist.ts b/layers/wishlist/shared/types/wishlist.ts new file mode 100644 index 0000000..ab457a9 --- /dev/null +++ b/layers/wishlist/shared/types/wishlist.ts @@ -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[] +} diff --git a/nuxt.config.ts b/nuxt.config.ts index 528d5ea..fda213a 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -1,3 +1,4 @@ +// Every app lives in its own layer under layers// and is picked up automatically. export default defineNuxtConfig({ modules: ['@nuxt/ui'], css: ['~/assets/css/main.css'], @@ -5,15 +6,10 @@ export default defineNuxtConfig({ devtools: { enabled: false }, runtimeConfig: { baserowUrl: '', - baserowToken: '', - baserowReviewsTableId: '', - baserowNotesTableId: '', - diaryPassword: '', - sessionSecret: '' + baserowToken: '' }, app: { head: { - title: 'Diary', htmlAttrs: { lang: 'en' } } } diff --git a/package.json b/package.json index 71fa1ff..b0a812b 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "bookshelf", + "name": "website", "private": true, "type": "module", "scripts": { diff --git a/server/api/reviews.post.ts b/server/api/reviews.post.ts deleted file mode 100644 index 0908cb0..0000000 --- a/server/api/reviews.post.ts +++ /dev/null @@ -1,11 +0,0 @@ -export default defineEventHandler(async (event) => { - const input = await readValidBody(event, newReviewSchema) - const row = await createRow(event, 'reviews', { - title: input.title, - date: input.date, - author_or_director: input.author ?? '', - review: input.text, - medium: await mediumOptionId(event, input.medium) - }) - return toReview(row) -}) diff --git a/server/utils/baserow.ts b/server/utils/baserow.ts index bbac5fe..3a377f7 100644 --- a/server/utils/baserow.ts +++ b/server/utils/baserow.ts @@ -1,6 +1,5 @@ import type { H3Event } from 'h3' -type Table = 'reviews' | 'notes' type Fields = Record interface RowPage { @@ -11,19 +10,10 @@ interface RowPage { const PAGE_SIZE = 200 const TIMEOUT_MS = 15_000 -function tableId(event: H3Event, table: Table): string { - const config = useRuntimeConfig(event) - const id = { reviews: config.baserowReviewsTableId, notes: config.baserowNotesTableId }[table] - if (!id) { - throw createError({ statusCode: 500, statusMessage: `NUXT_BASEROW_${table.toUpperCase()}_TABLE_ID is not set` }) - } - return id -} - async function request( event: H3Event, path: string, - options: { method?: 'GET' | 'POST', query?: Fields, body?: Fields } = {} + options: { method?: 'GET' | 'POST' | 'PATCH', query?: Fields, body?: Fields } = {} ): Promise { const { baserowUrl, baserowToken } = useRuntimeConfig(event) if (!baserowUrl || !baserowToken) { @@ -48,8 +38,8 @@ async function request( } } -export async function listRows(event: H3Event, table: Table): Promise { - const path = `rows/table/${tableId(event, table)}/` +export async function listRows(event: H3Event, tableId: string): Promise { + const path = `rows/table/${tableId}/` const rows: Row[] = [] for (let page = 1; ; page++) { const { count, results } = await request>(event, path, { @@ -60,14 +50,22 @@ export async function listRows(event: H3Event, table: Table): Promise(event: H3Event, table: Table, fields: Fields): Promise { - return request(event, `rows/table/${tableId(event, table)}/`, { +export function createRow(event: H3Event, tableId: string, fields: Fields): Promise { + return request(event, `rows/table/${tableId}/`, { method: 'POST', query: { user_field_names: true }, body: fields }) } -export function listFields(event: H3Event, table: Table): Promise { - return request(event, `fields/table/${tableId(event, table)}/`) +export function updateRow(event: H3Event, tableId: string, rowId: number, fields: Fields): Promise { + return request(event, `rows/table/${tableId}/${rowId}/`, { + method: 'PATCH', + query: { user_field_names: true }, + body: fields + }) +} + +export function listFields(event: H3Event, tableId: string): Promise { + return request(event, `fields/table/${tableId}/`) } diff --git a/server/utils/review.ts b/server/utils/review.ts deleted file mode 100644 index 3d6d386..0000000 --- a/server/utils/review.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { H3Event } from 'h3' - -export interface ReviewRow { - id: number - title: string | null - date: string | null - author_or_director: string | null - review: string | null - medium: SelectOption | null -} - -interface SelectOption { - id: number - value: string -} - -interface Field { - name: string - select_options?: SelectOption[] -} - -const OPTION_CACHE_MS = 10 * 60_000 - -let mediumOptions: { ids: Partial>, fetchedAt: number } | null = null - -function toMedium(value: string | undefined): Medium | null { - return MEDIUM_KEYS.find(medium => MEDIUMS[medium].label === value) ?? null -} - -export function toReview(row: ReviewRow): Review { - return { - id: row.id, - date: row.date ?? '', - title: (row.title ?? '').trim(), - author: row.author_or_director?.trim() || null, - text: (row.review ?? '').replace(/ /g, ' ').trim(), - medium: toMedium(row.medium?.value) - } -} - -export async function mediumOptionId(event: H3Event, medium: Medium): Promise { - if (!mediumOptions || Date.now() - mediumOptions.fetchedAt > OPTION_CACHE_MS) { - const fields = await listFields(event, 'reviews') - const options = fields.find(field => field.name === 'medium')?.select_options ?? [] - const ids: Partial> = {} - for (const option of options) { - const key = toMedium(option.value) - if (key) ids[key] = option.id - } - mediumOptions = { ids, fetchedAt: Date.now() } - } - - const id = mediumOptions.ids[medium] - if (!id) { - throw createError({ statusCode: 500, statusMessage: `The medium field has no "${MEDIUMS[medium].label}" option` }) - } - return id -}