feat: add wishlist
This commit is contained in:
@@ -2,5 +2,6 @@ NUXT_BASEROW_URL=http://baserow.example
|
|||||||
NUXT_BASEROW_TOKEN=
|
NUXT_BASEROW_TOKEN=
|
||||||
NUXT_BASEROW_REVIEWS_TABLE_ID=866
|
NUXT_BASEROW_REVIEWS_TABLE_ID=866
|
||||||
NUXT_BASEROW_NOTES_TABLE_ID=864
|
NUXT_BASEROW_NOTES_TABLE_ID=864
|
||||||
|
NUXT_BASEROW_WISHLIST_TABLE_ID=870
|
||||||
NUXT_DIARY_PASSWORD=
|
NUXT_DIARY_PASSWORD=
|
||||||
NUXT_SESSION_SECRET=
|
NUXT_SESSION_SECRET=
|
||||||
|
|||||||
@@ -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
|
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)
|
||||||
self-hosted Baserow tables.
|
in `layers/<name>/` 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
|
```bash
|
||||||
pnpm install
|
pnpm install
|
||||||
@@ -9,6 +17,50 @@ cp .env.example .env
|
|||||||
pnpm dev
|
pnpm dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Adding an app
|
||||||
|
|
||||||
|
1. Create `layers/<name>/` with `app/pages/<name>.vue` (or `app/pages/<name>/index.vue`), and whatever else it needs:
|
||||||
|
`app/components`, `app/composables`, `server/api/<name>/…`, `shared/`, and a `nuxt.config.ts` for its runtime config.
|
||||||
|
2. Keep it contained: serve its API under `/api/<name>/`, 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/<isbn>.json`) and posts the cover URL to `/api/wishlist/books/<id>/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/<n>-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
|
## Configuration
|
||||||
|
|
||||||
| Variable | Purpose |
|
| Variable | Purpose |
|
||||||
@@ -17,29 +69,34 @@ pnpm dev
|
|||||||
| `NUXT_BASEROW_TOKEN` | Database token with read and create access to both tables |
|
| `NUXT_BASEROW_TOKEN` | Database token with read and create access to both tables |
|
||||||
| `NUXT_BASEROW_REVIEWS_TABLE_ID` | Reviews table |
|
| `NUXT_BASEROW_REVIEWS_TABLE_ID` | Reviews table |
|
||||||
| `NUXT_BASEROW_NOTES_TABLE_ID` | Notes 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_DIARY_PASSWORD` | Password that unlocks the diary |
|
||||||
| `NUXT_SESSION_SECRET` | Signs the login cookie, at least 32 characters (`openssl rand -hex 32`) |
|
| `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
|
## 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".
|
- 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`),
|
- 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.
|
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.
|
- "Log" opens a form with a Note / Review switch. A review is written for a book picked from the books table;
|
||||||
- Until the password is entered, only a password prompt is shown and every API route except
|
its "Read on" date is saved as that book's `read_date`.
|
||||||
`/api/session` and `/api/login` returns 401. A correct password sets a signed, http-only cookie valid for 30 days.
|
- 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.
|
Changing `NUXT_SESSION_SECRET` logs out every browser.
|
||||||
|
|
||||||
## Code layout
|
## Code layout
|
||||||
|
|
||||||
```
|
```
|
||||||
|
layers/diary/
|
||||||
|
nuxt.config.ts runtime config (env vars above)
|
||||||
app/
|
app/
|
||||||
app.vue password gate around the page
|
app.config.ts diary.showCovers
|
||||||
pages/index.vue the diary page
|
pages/diary.vue password gate around the timeline
|
||||||
components/
|
components/
|
||||||
|
DiaryTimeline.vue the diary: search, Log button, entries grouped by year
|
||||||
DiaryEntry.vue one timeline entry (date, title, text, read more)
|
DiaryEntry.vue one timeline entry (date, title, text, read more)
|
||||||
LogModal.vue Note / Review switch around the two forms
|
LogModal.vue Note / Review switch around the two forms
|
||||||
NoteForm.vue, ReviewForm.vue
|
NoteForm.vue, ReviewForm.vue
|
||||||
@@ -52,11 +109,11 @@ app/
|
|||||||
utils/ dates, icons, search highlighting, error messages
|
utils/ dates, icons, search highlighting, error messages
|
||||||
types/entry.ts a timeline entry (review or note)
|
types/entry.ts a timeline entry (review or note)
|
||||||
server/
|
server/
|
||||||
middleware/auth.ts locks /api/* until unlocked
|
middleware/diary-auth.ts locks /api/diary/* until unlocked
|
||||||
api/ session, login, reviews and notes (GET list, POST create)
|
api/diary/ session, login, reviews and notes (GET list, POST create), books (for the review form)
|
||||||
utils/
|
utils/
|
||||||
baserow.ts Baserow client: listRows, createRow, listFields
|
diaryTable.ts the diary's table IDs (the Baserow client itself is in the root server/utils)
|
||||||
review.ts, note.ts Baserow row -> app type
|
review.ts, note.ts, book.ts Baserow row -> app type
|
||||||
auth.ts session cookie and password check
|
auth.ts session cookie and password check
|
||||||
validation.ts readValidBody(event, zodSchema)
|
validation.ts readValidBody(event, zodSchema)
|
||||||
shared/
|
shared/
|
||||||
@@ -67,8 +124,11 @@ shared/
|
|||||||
|
|
||||||
## Baserow tables
|
## Baserow tables
|
||||||
|
|
||||||
**Reviews:** `title`, `date` (ISO date), `author_or_director`, `review` (long text) and `medium`
|
**Reviews:** `books` (link to the books table), `review` (long text), `medium` (single select: `Book` / `Movie` /
|
||||||
(single select: `Book` / `Movie` / `Album`).
|
`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).
|
**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
|
### 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.
|
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
|
2. Add it to `Medium` in `layers/diary/shared/types/review.ts`, to `MEDIUMS` in `layers/diary/shared/utils/medium.ts`
|
||||||
the Baserow option) and to `MEDIUM_ICONS` in `app/utils/icons.ts`.
|
(its `label` must match the Baserow option) and to `MEDIUM_ICONS` in `layers/diary/app/utils/icons.ts`.
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
export default defineAppConfig({
|
export default defineAppConfig({
|
||||||
showCovers: false,
|
|
||||||
ui: {
|
ui: {
|
||||||
colors: {
|
colors: {
|
||||||
primary: 'amber',
|
primary: 'amber',
|
||||||
|
|||||||
+1
-16
@@ -1,20 +1,5 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
const { data: session, refresh } = await useFetch<{ unlocked: boolean }>('/api/session', { key: 'session' })
|
|
||||||
const unlocked = computed(() => !!session.value?.unlocked)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<UApp>
|
<UApp>
|
||||||
<template v-if="unlocked">
|
<NuxtPage />
|
||||||
<UMain>
|
|
||||||
<UContainer class="py-8">
|
|
||||||
<NuxtPage />
|
|
||||||
</UContainer>
|
|
||||||
</UMain>
|
|
||||||
|
|
||||||
<LogModal />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<PasswordModal v-else @unlocked="refresh()" />
|
|
||||||
</UApp>
|
</UApp>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { FormSubmitEvent } from '@nuxt/ui'
|
|
||||||
|
|
||||||
const emit = defineEmits<{ saved: [] }>()
|
|
||||||
|
|
||||||
const mediumOptions = MEDIUM_KEYS.map(medium => ({
|
|
||||||
label: MEDIUMS[medium].label,
|
|
||||||
value: medium,
|
|
||||||
icon: MEDIUM_ICONS[medium]
|
|
||||||
}))
|
|
||||||
|
|
||||||
const state = reactive({ medium: 'book' as Medium, date: todayIso(), title: '', author: '', text: '' })
|
|
||||||
const { saving, save } = useSaveEntry({ url: '/api/reviews', dataKey: 'reviews', noun: 'review', onSaved: () => emit('saved') })
|
|
||||||
|
|
||||||
function onSubmit(event: FormSubmitEvent<NewReviewInput>) {
|
|
||||||
return save(event.data)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<UForm :schema="newReviewSchema" :state="state" class="space-y-5" @submit="onSubmit">
|
|
||||||
<UFormField label="Medium" name="medium" required>
|
|
||||||
<URadioGroup v-model="state.medium" :items="mediumOptions" orientation="horizontal" />
|
|
||||||
</UFormField>
|
|
||||||
|
|
||||||
<UFormField label="Date" name="date" required>
|
|
||||||
<UInput v-model="state.date" type="date" />
|
|
||||||
</UFormField>
|
|
||||||
|
|
||||||
<UFormField label="Title" name="title" required>
|
|
||||||
<UInput v-model="state.title" class="w-full" autofocus />
|
|
||||||
</UFormField>
|
|
||||||
|
|
||||||
<UFormField :label="MEDIUMS[state.medium].creator" name="author">
|
|
||||||
<UInput v-model="state.author" class="w-full" />
|
|
||||||
</UFormField>
|
|
||||||
|
|
||||||
<UFormField label="Review" name="text" required>
|
|
||||||
<UTextarea v-model="state.text" :rows="8" autoresize :maxrows="18" class="w-full" />
|
|
||||||
</UFormField>
|
|
||||||
|
|
||||||
<UButton type="submit" label="Save review" :icon="REVIEW_ICON" :loading="saving" />
|
|
||||||
</UForm>
|
|
||||||
</template>
|
|
||||||
+1
-42
@@ -1,44 +1,3 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
const search = ref('')
|
|
||||||
const { years, errors } = useDiary(search)
|
|
||||||
const log = useLog()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="mx-auto max-w-3xl">
|
<div />
|
||||||
<div class="mb-8 flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<h1 class="text-2xl font-semibold text-highlighted">Diary</h1>
|
|
||||||
<div class="flex w-full items-center gap-2 sm:w-auto">
|
|
||||||
<UInput v-model="search" icon="i-lucide-search" placeholder="Search the diary" class="flex-1 sm:w-64 sm:flex-none" />
|
|
||||||
<UButton icon="i-lucide-plus" label="Log" @click="log.show()" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<UAlert
|
|
||||||
v-for="{ what, error } in errors"
|
|
||||||
:key="what"
|
|
||||||
color="error"
|
|
||||||
variant="subtle"
|
|
||||||
icon="i-lucide-triangle-alert"
|
|
||||||
:title="`Couldn't load your ${what}`"
|
|
||||||
:description="errorMessage(error)"
|
|
||||||
class="mb-6"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section v-for="group in years" :key="group.year" class="mb-10">
|
|
||||||
<header class="sticky top-0 z-10 mb-4 bg-default/90 py-2 backdrop-blur">
|
|
||||||
<h2 class="text-xl font-semibold text-highlighted tabular-nums">{{ group.year }}</h2>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<UTimeline :items="group.items" color="primary" size="md" :ui="{ wrapper: 'pb-6' }">
|
|
||||||
<template #wrapper="{ item }">
|
|
||||||
<DiaryEntry :key="item.value" :entry="item.entry" :search="search" />
|
|
||||||
</template>
|
|
||||||
</UTimeline>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<p v-if="!years.length && !errors.length" class="py-16 text-center text-muted">
|
|
||||||
{{ search ? 'Nothing in the diary matches your search.' : 'Nothing logged yet.' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export default defineAppConfig({
|
||||||
|
diary: {
|
||||||
|
showCovers: false
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Entry } from '~/types/entry'
|
import type { Entry } from '../types/entry'
|
||||||
|
|
||||||
const props = defineProps<{ entry: Entry, search: string }>()
|
const props = defineProps<{ entry: Entry, search: string }>()
|
||||||
|
|
||||||
const COLLAPSE_AFTER_CHARS = 320
|
const COLLAPSE_AFTER_CHARS = 320
|
||||||
const COLLAPSE_AFTER_LINES = 5
|
const COLLAPSE_AFTER_LINES = 5
|
||||||
|
|
||||||
const { showCovers } = useAppConfig()
|
const { showCovers } = useAppConfig().diary
|
||||||
const expanded = ref(false)
|
const expanded = ref(false)
|
||||||
|
|
||||||
const searching = computed(() => searchTerms(props.search).length > 0)
|
const searching = computed(() => searchTerms(props.search).length > 0)
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const search = ref('')
|
||||||
|
const { years, errors } = useDiary(search)
|
||||||
|
const log = useLog()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mx-auto max-w-3xl">
|
||||||
|
<div class="mb-8 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h1 class="text-2xl font-semibold text-highlighted">Diary</h1>
|
||||||
|
<div class="flex w-full items-center gap-2 sm:w-auto">
|
||||||
|
<UInput v-model="search" icon="i-lucide-search" placeholder="Search the diary" class="flex-1 sm:w-64 sm:flex-none" />
|
||||||
|
<UButton icon="i-lucide-plus" label="Log" @click="log.show()" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UAlert
|
||||||
|
v-for="{ what, error } in errors"
|
||||||
|
:key="what"
|
||||||
|
color="error"
|
||||||
|
variant="subtle"
|
||||||
|
icon="i-lucide-triangle-alert"
|
||||||
|
:title="`Couldn't load your ${what}`"
|
||||||
|
:description="errorMessage(error)"
|
||||||
|
class="mb-6"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section v-for="group in years" :key="group.year" class="mb-10">
|
||||||
|
<header class="sticky top-0 z-10 mb-4 bg-default/90 py-2 backdrop-blur">
|
||||||
|
<h2 class="text-xl font-semibold text-highlighted tabular-nums">{{ group.year }}</h2>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<UTimeline :items="group.items" color="primary" size="md" :ui="{ wrapper: 'pb-6' }">
|
||||||
|
<template #wrapper="{ item }">
|
||||||
|
<DiaryEntry :key="item.value" :entry="item.entry" :search="search" />
|
||||||
|
</template>
|
||||||
|
</UTimeline>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p v-if="!years.length && !errors.length" class="py-16 text-center text-muted">
|
||||||
|
{{ search ? 'Nothing in the diary matches your search.' : 'Nothing logged yet.' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -4,7 +4,7 @@ import type { FormSubmitEvent } from '@nuxt/ui'
|
|||||||
const emit = defineEmits<{ saved: [] }>()
|
const emit = defineEmits<{ saved: [] }>()
|
||||||
|
|
||||||
const state = reactive({ date: todayIso(), text: '' })
|
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<NewNoteInput>) {
|
function onSubmit(event: FormSubmitEvent<NewNoteInput>) {
|
||||||
return save(event.data)
|
return save(event.data)
|
||||||
@@ -10,7 +10,7 @@ async function unlock() {
|
|||||||
unlocking.value = true
|
unlocking.value = true
|
||||||
error.value = undefined
|
error.value = undefined
|
||||||
try {
|
try {
|
||||||
await $fetch('/api/login', { method: 'POST', body: { password: password.value } })
|
await $fetch('/api/diary/login', { method: 'POST', body: { password: password.value } })
|
||||||
emit('unlocked')
|
emit('unlocked')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = errorMessage(e) ?? 'Couldn\'t unlock the diary'
|
error.value = errorMessage(e) ?? 'Couldn\'t unlock the diary'
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { FormSubmitEvent } from '@nuxt/ui'
|
||||||
|
|
||||||
|
const emit = defineEmits<{ saved: [] }>()
|
||||||
|
|
||||||
|
const { data: books, status } = useFetch<DiaryBook[]>('/api/diary/books', { key: 'diary-books', default: () => [], lazy: true })
|
||||||
|
|
||||||
|
// Recently read books first, then the rest by title
|
||||||
|
const bookItems = computed(() => books.value
|
||||||
|
.toSorted((a, b) => (b.readDate ?? '').localeCompare(a.readDate ?? '') || a.title.localeCompare(b.title, 'de'))
|
||||||
|
.map(book => ({
|
||||||
|
label: book.title,
|
||||||
|
description: book.authors.join('; '),
|
||||||
|
originalTitle: book.originalTitle,
|
||||||
|
value: book.id
|
||||||
|
})))
|
||||||
|
|
||||||
|
const state = reactive({ bookId: undefined as number | undefined, date: todayIso(), text: '' })
|
||||||
|
const { saving, save } = useSaveEntry({
|
||||||
|
url: '/api/diary/reviews',
|
||||||
|
dataKey: ['diary-reviews', 'diary-books'],
|
||||||
|
noun: 'review',
|
||||||
|
onSaved: () => emit('saved')
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => state.bookId, (id) => {
|
||||||
|
state.date = books.value.find(book => book.id === id)?.readDate ?? todayIso()
|
||||||
|
})
|
||||||
|
|
||||||
|
function onSubmit(event: FormSubmitEvent<NewReviewInput>) {
|
||||||
|
return save(event.data)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UForm :schema="newReviewSchema" :state="state" class="space-y-5" @submit="onSubmit">
|
||||||
|
<UFormField label="Book" name="bookId" required>
|
||||||
|
<USelectMenu
|
||||||
|
v-model="state.bookId"
|
||||||
|
:items="bookItems"
|
||||||
|
value-key="value"
|
||||||
|
:filter-fields="['label', 'description', 'originalTitle']"
|
||||||
|
:loading="status === 'pending'"
|
||||||
|
:search-input="{ placeholder: 'Title or author' }"
|
||||||
|
placeholder="Pick a book"
|
||||||
|
icon="i-lucide-book-open"
|
||||||
|
virtualize
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<UFormField label="Read on" name="date" help="Saved as the book's read date" required>
|
||||||
|
<UInput v-model="state.date" type="date" />
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<UFormField label="Review" name="text" required>
|
||||||
|
<UTextarea v-model="state.text" :rows="8" autoresize :maxrows="18" class="w-full" />
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<UButton type="submit" label="Save review" :icon="REVIEW_ICON" :loading="saving" />
|
||||||
|
</UForm>
|
||||||
|
</template>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Entry } from '~/types/entry'
|
import type { Entry } from '../types/entry'
|
||||||
|
|
||||||
const KIND_ORDER: Record<Entry['kind'], number> = { review: 0, note: 1 }
|
const KIND_ORDER: Record<Entry['kind'], number> = { review: 0, note: 1 }
|
||||||
|
|
||||||
@@ -39,8 +39,8 @@ function groupByYear(entries: Entry[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useDiary(search: Ref<string>) {
|
export function useDiary(search: Ref<string>) {
|
||||||
const { data: reviews, error: reviewsError } = useFetch<Review[]>('/api/reviews', { key: 'reviews', default: () => [] })
|
const { data: reviews, error: reviewsError } = useFetch<Review[]>('/api/diary/reviews', { key: 'diary-reviews', default: () => [] })
|
||||||
const { data: notes, error: notesError } = useFetch<Note[]>('/api/notes', { key: 'notes', default: () => [] })
|
const { data: notes, error: notesError } = useFetch<Note[]>('/api/diary/notes', { key: 'diary-notes', default: () => [] })
|
||||||
|
|
||||||
const years = computed(() => {
|
const years = computed(() => {
|
||||||
const terms = searchTerms(search.value)
|
const terms = searchTerms(search.value)
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
export function useLog() {
|
export function useLog() {
|
||||||
const open = useState('log-open', () => false)
|
const open = useState('diary-log-open', () => false)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
open,
|
open,
|
||||||
@@ -2,7 +2,7 @@ function capitalize(word: string): string {
|
|||||||
return word.charAt(0).toUpperCase() + word.slice(1)
|
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 toast = useToast()
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
useHead({ title: 'Diary' })
|
||||||
|
|
||||||
|
const { data: session, refresh } = await useFetch<{ unlocked: boolean }>('/api/diary/session', { key: 'diary-session' })
|
||||||
|
const unlocked = computed(() => !!session.value?.unlocked)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<template v-if="unlocked">
|
||||||
|
<UMain>
|
||||||
|
<UContainer class="py-8">
|
||||||
|
<DiaryTimeline />
|
||||||
|
</UContainer>
|
||||||
|
</UMain>
|
||||||
|
|
||||||
|
<LogModal />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<PasswordModal v-else @unlocked="refresh()" />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export default defineNuxtConfig({
|
||||||
|
runtimeConfig: {
|
||||||
|
baserowReviewsTableId: '',
|
||||||
|
baserowNotesTableId: '',
|
||||||
|
diaryPassword: '',
|
||||||
|
sessionSecret: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const rows = await listRows<DiaryBookRow>(event, await booksTableId(event))
|
||||||
|
return rows.map(toDiaryBook).filter(book => book.title)
|
||||||
|
})
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const rows = await listRows<NoteRow>(event, 'notes')
|
const rows = await listRows<NoteRow>(event, diaryTable(event, 'notes'))
|
||||||
return rows.map(toNote).filter(note => note.date)
|
return rows.map(toNote).filter(note => note.date)
|
||||||
})
|
})
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const input = await readValidBody(event, newNoteSchema)
|
const input = await readValidBody(event, newNoteSchema)
|
||||||
const row = await createRow<NoteRow>(event, 'notes', {
|
const row = await createRow<NoteRow>(event, diaryTable(event, 'notes'), {
|
||||||
Datum: input.date,
|
Datum: input.date,
|
||||||
Notes: input.text
|
Notes: input.text
|
||||||
})
|
})
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const rows = await listRows<ReviewRow>(event, 'reviews')
|
const rows = await listRows<ReviewRow>(event, diaryTable(event, 'reviews'))
|
||||||
return rows.map(toReview).filter(review => review.date)
|
return rows.map(toReview).filter(review => review.date)
|
||||||
})
|
})
|
||||||
@@ -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<ReviewRow>(event, diaryTable(event, 'reviews'), {
|
||||||
|
books: [input.bookId],
|
||||||
|
review: input.text,
|
||||||
|
medium: await mediumOptionId(event, 'book')
|
||||||
|
})
|
||||||
|
return toReview(row)
|
||||||
|
})
|
||||||
@@ -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) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const path = event.path.split('?')[0]!
|
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)
|
const session = await diarySession(event)
|
||||||
if (!session.data.unlocked) {
|
if (!session.data.unlocked) {
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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<Field[]> {
|
||||||
|
if (!reviewFields || Date.now() - reviewFields.fetchedAt > FIELD_CACHE_MS) {
|
||||||
|
reviewFields = { fields: await listFields<Field>(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<number> {
|
||||||
|
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<string> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { MEDIUM_KEYS } from './medium'
|
|
||||||
|
|
||||||
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Pick a date')
|
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Pick a date')
|
||||||
const requiredText = z.string().trim().min(1, 'Write something first')
|
const requiredText = z.string().trim().min(1, 'Write something first')
|
||||||
@@ -10,10 +9,8 @@ export const newNoteSchema = z.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const newReviewSchema = 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,
|
date: isoDate,
|
||||||
title: z.string().trim().min(1, 'Add a title'),
|
|
||||||
author: z.string().trim().optional(),
|
|
||||||
text: requiredText
|
text: requiredText
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -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[]
|
||||||
|
}
|
||||||
+2
-6
@@ -1,3 +1,4 @@
|
|||||||
|
// Every app lives in its own layer under layers/<name>/ and is picked up automatically.
|
||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
modules: ['@nuxt/ui'],
|
modules: ['@nuxt/ui'],
|
||||||
css: ['~/assets/css/main.css'],
|
css: ['~/assets/css/main.css'],
|
||||||
@@ -5,15 +6,10 @@ export default defineNuxtConfig({
|
|||||||
devtools: { enabled: false },
|
devtools: { enabled: false },
|
||||||
runtimeConfig: {
|
runtimeConfig: {
|
||||||
baserowUrl: '',
|
baserowUrl: '',
|
||||||
baserowToken: '',
|
baserowToken: ''
|
||||||
baserowReviewsTableId: '',
|
|
||||||
baserowNotesTableId: '',
|
|
||||||
diaryPassword: '',
|
|
||||||
sessionSecret: ''
|
|
||||||
},
|
},
|
||||||
app: {
|
app: {
|
||||||
head: {
|
head: {
|
||||||
title: 'Diary',
|
|
||||||
htmlAttrs: { lang: 'en' }
|
htmlAttrs: { lang: 'en' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "bookshelf",
|
"name": "website",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
export default defineEventHandler(async (event) => {
|
|
||||||
const input = await readValidBody(event, newReviewSchema)
|
|
||||||
const row = await createRow<ReviewRow>(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)
|
|
||||||
})
|
|
||||||
+15
-17
@@ -1,6 +1,5 @@
|
|||||||
import type { H3Event } from 'h3'
|
import type { H3Event } from 'h3'
|
||||||
|
|
||||||
type Table = 'reviews' | 'notes'
|
|
||||||
type Fields = Record<string, unknown>
|
type Fields = Record<string, unknown>
|
||||||
|
|
||||||
interface RowPage<Row> {
|
interface RowPage<Row> {
|
||||||
@@ -11,19 +10,10 @@ interface RowPage<Row> {
|
|||||||
const PAGE_SIZE = 200
|
const PAGE_SIZE = 200
|
||||||
const TIMEOUT_MS = 15_000
|
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<T>(
|
async function request<T>(
|
||||||
event: H3Event,
|
event: H3Event,
|
||||||
path: string,
|
path: string,
|
||||||
options: { method?: 'GET' | 'POST', query?: Fields, body?: Fields } = {}
|
options: { method?: 'GET' | 'POST' | 'PATCH', query?: Fields, body?: Fields } = {}
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const { baserowUrl, baserowToken } = useRuntimeConfig(event)
|
const { baserowUrl, baserowToken } = useRuntimeConfig(event)
|
||||||
if (!baserowUrl || !baserowToken) {
|
if (!baserowUrl || !baserowToken) {
|
||||||
@@ -48,8 +38,8 @@ async function request<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listRows<Row>(event: H3Event, table: Table): Promise<Row[]> {
|
export async function listRows<Row>(event: H3Event, tableId: string): Promise<Row[]> {
|
||||||
const path = `rows/table/${tableId(event, table)}/`
|
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, {
|
||||||
@@ -60,14 +50,22 @@ export async function listRows<Row>(event: H3Event, table: Table): Promise<Row[]
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createRow<Row>(event: H3Event, table: Table, fields: Fields): Promise<Row> {
|
export function createRow<Row>(event: H3Event, tableId: string, fields: Fields): Promise<Row> {
|
||||||
return request<Row>(event, `rows/table/${tableId(event, table)}/`, {
|
return request<Row>(event, `rows/table/${tableId}/`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
query: { user_field_names: true },
|
query: { user_field_names: true },
|
||||||
body: fields
|
body: fields
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listFields<Field>(event: H3Event, table: Table): Promise<Field[]> {
|
export function updateRow<Row>(event: H3Event, tableId: string, rowId: number, fields: Fields): Promise<Row> {
|
||||||
return request<Field[]>(event, `fields/table/${tableId(event, table)}/`)
|
return request<Row>(event, `rows/table/${tableId}/${rowId}/`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
query: { user_field_names: true },
|
||||||
|
body: fields
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listFields<Field>(event: H3Event, tableId: string): Promise<Field[]> {
|
||||||
|
return request<Field[]>(event, `fields/table/${tableId}/`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Record<Medium, number>>, 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<number> {
|
|
||||||
if (!mediumOptions || Date.now() - mediumOptions.fetchedAt > OPTION_CACHE_MS) {
|
|
||||||
const fields = await listFields<Field>(event, 'reviews')
|
|
||||||
const options = fields.find(field => field.name === 'medium')?.select_options ?? []
|
|
||||||
const ids: Partial<Record<Medium, number>> = {}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user