feat: add wishlist

This commit is contained in:
2026-09-30 19:15:57 +02:00
parent ab3bbb6338
commit 1f6061b998
57 changed files with 638 additions and 232 deletions
@@ -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)
})
@@ -0,0 +1,14 @@
const FAILED_LOGIN_DELAY_MS = 1000
export default defineEventHandler(async (event) => {
const { password } = await readValidBody(event, loginSchema)
if (!(await passwordMatches(event, password))) {
await new Promise(resolve => setTimeout(resolve, FAILED_LOGIN_DELAY_MS))
throw createError({ statusCode: 401, statusMessage: 'Wrong password' })
}
const session = await diarySession(event)
await session.update({ unlocked: true })
return { unlocked: true }
})
@@ -0,0 +1,4 @@
export default defineEventHandler(async (event) => {
const rows = await listRows<NoteRow>(event, diaryTable(event, 'notes'))
return rows.map(toNote).filter(note => note.date)
})
@@ -0,0 +1,8 @@
export default defineEventHandler(async (event) => {
const input = await readValidBody(event, newNoteSchema)
const row = await createRow<NoteRow>(event, diaryTable(event, 'notes'), {
Datum: input.date,
Notes: input.text
})
return toNote(row)
})
@@ -0,0 +1,4 @@
export default defineEventHandler(async (event) => {
const rows = await listRows<ReviewRow>(event, diaryTable(event, 'reviews'))
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)
})
@@ -0,0 +1,4 @@
export default defineEventHandler(async (event) => {
const session = await diarySession(event)
return { unlocked: !!session.data.unlocked }
})
@@ -0,0 +1,11 @@
const PUBLIC_ROUTES = ['/api/diary/session', '/api/diary/login']
export default defineEventHandler(async (event) => {
const path = event.path.split('?')[0]!
if (!path.startsWith('/api/diary/') || PUBLIC_ROUTES.includes(path)) return
const session = await diarySession(event)
if (!session.data.unlocked) {
throw createError({ statusCode: 401, statusMessage: 'The diary is locked' })
}
})
+36
View File
@@ -0,0 +1,36 @@
import type { H3Event } from 'h3'
interface DiarySession {
unlocked?: boolean
}
const SESSION_MAX_AGE_S = 60 * 60 * 24 * 30
export function diarySession(event: H3Event) {
const { sessionSecret } = useRuntimeConfig(event)
if (!sessionSecret || sessionSecret.length < 32) {
throw createError({ statusCode: 500, statusMessage: 'NUXT_SESSION_SECRET must be set (at least 32 characters)' })
}
return useSession<DiarySession>(event, {
name: 'diary',
password: sessionSecret,
maxAge: SESSION_MAX_AGE_S,
cookie: { httpOnly: true, sameSite: 'lax', secure: !import.meta.dev }
})
}
async function sha256(text: string): Promise<Uint8Array> {
return new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)))
}
export async function passwordMatches(event: H3Event, guess: string): Promise<boolean> {
const { diaryPassword } = useRuntimeConfig(event)
if (!diaryPassword) {
throw createError({ statusCode: 500, statusMessage: 'NUXT_DIARY_PASSWORD is not set' })
}
const [a, b] = await Promise.all([sha256(guess), sha256(diaryPassword)])
let difference = 0
for (let i = 0; i < a.length; i++) difference |= a[i]! ^ b[i]!
return difference === 0
}
+22
View File
@@ -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
}
}
+10
View File
@@ -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
}
+13
View File
@@ -0,0 +1,13 @@
export interface NoteRow {
id: number
Datum: string | null
Notes: string | null
}
export function toNote(row: NoteRow): Note {
return {
id: row.id,
date: row.Datum ?? '',
text: (row.Notes ?? '').trim()
}
}
+75
View File
@@ -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(/&nbsp;/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)
}
+10
View File
@@ -0,0 +1,10 @@
import type { H3Event } from 'h3'
import type { z } from 'zod'
export async function readValidBody<Schema extends z.ZodType>(event: H3Event, schema: Schema): Promise<z.output<Schema>> {
const result = schema.safeParse(await readBody(event))
if (!result.success) {
throw createError({ statusCode: 400, statusMessage: result.error.issues[0]?.message ?? 'Invalid request' })
}
return result.data
}