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
-1
View File
@@ -1,5 +1,4 @@
export default defineAppConfig({
showCovers: false,
ui: {
colors: {
primary: 'amber',
+1 -16
View File
@@ -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>
<UApp>
<template v-if="unlocked">
<UMain>
<UContainer class="py-8">
<NuxtPage />
</UContainer>
</UMain>
<LogModal />
</template>
<PasswordModal v-else @unlocked="refresh()" />
<NuxtPage />
</UApp>
</template>
-31
View File
@@ -1,31 +0,0 @@
<script setup lang="ts">
const props = defineProps<{
title: string
subtitle?: string | null
}>()
const background = computed(() => {
let hue = 0
for (const char of props.title) hue = (hue * 31 + char.codePointAt(0)!) % 360
return `linear-gradient(155deg, hsl(${hue} 38% 40%), hsl(${(hue + 24) % 360} 42% 22%))`
})
</script>
<template>
<div
class="relative size-full overflow-hidden text-white [container-type:inline-size]"
:style="{ background }"
role="img"
:aria-label="`Cover of ${title}`"
>
<span class="absolute inset-y-0 left-[7cqw] w-px bg-white/25" aria-hidden="true" />
<div class="flex size-full flex-col justify-between gap-1 p-[8cqw] pl-[12cqw]">
<span class="font-serif text-[clamp(0.5rem,11cqw,1.05rem)] leading-tight font-semibold text-balance wrap-anywhere line-clamp-6">
{{ title }}
</span>
<span v-if="subtitle" class="text-[clamp(0.4rem,7cqw,0.7rem)] leading-tight tracking-wide text-white/75 uppercase wrap-anywhere line-clamp-2">
{{ subtitle }}
</span>
</div>
</div>
</template>
-55
View File
@@ -1,55 +0,0 @@
<script setup lang="ts">
import type { Entry } from '~/types/entry'
const props = defineProps<{ entry: Entry, search: string }>()
const COLLAPSE_AFTER_CHARS = 320
const COLLAPSE_AFTER_LINES = 5
const { showCovers } = useAppConfig()
const expanded = ref(false)
const searching = computed(() => searchTerms(props.search).length > 0)
const isLong = computed(() =>
props.entry.text.length > COLLAPSE_AFTER_CHARS || props.entry.text.split('\n').length > COLLAPSE_AFTER_LINES
)
const collapsed = computed(() => isLong.value && !expanded.value && !searching.value)
const wordCount = computed(() => props.entry.text.split(/\s+/).filter(Boolean).length)
const parts = computed(() => highlight(props.entry.text, props.search))
</script>
<template>
<div class="flex gap-4">
<div
v-if="showCovers && entry.kind === 'review'"
class="aspect-2/3 w-16 shrink-0 self-start overflow-hidden rounded shadow-sm ring-1 ring-default"
>
<CoverTile :title="entry.review.title" :subtitle="entry.review.author" />
</div>
<article class="min-w-0 flex-1">
<div class="flex min-w-0 items-baseline gap-3">
<span class="shrink-0 text-xs text-muted tabular-nums">{{ formatDate(entry.date) }}</span>
<p v-if="entry.kind === 'review'" class="min-w-0 truncate">
<span class="font-medium text-highlighted">{{ entry.review.title }}</span>
<span v-if="entry.review.author" class="ml-3 text-sm text-muted">{{ entry.review.author }}</span>
</p>
</div>
<p
class="mt-1.5 text-sm leading-relaxed whitespace-pre-line text-default"
:class="{ 'line-clamp-5': collapsed }"
><template v-for="(part, index) in parts" :key="index"><mark v-if="part.hit" class="rounded-sm bg-primary/30 px-0.5 text-highlighted">{{ part.text }}</mark><template v-else>{{ part.text }}</template></template></p>
<UButton
v-if="isLong && !searching"
size="xs"
color="neutral"
variant="link"
class="mt-1 px-0"
:label="expanded ? 'Show less' : `Read more (${wordCount} words)`"
@click="expanded = !expanded"
/>
</article>
</div>
</template>
-23
View File
@@ -1,23 +0,0 @@
<script setup lang="ts">
const { open, close } = useLog()
const tabs = [
{ label: 'Note', value: 'note', icon: NOTE_ICON, slot: 'note' as const },
{ label: 'Review', value: 'review', icon: REVIEW_ICON, slot: 'review' as const }
]
</script>
<template>
<UModal v-model:open="open" title="Log">
<template #body>
<UTabs :items="tabs" default-value="note" class="w-full" :ui="{ content: 'pt-4' }">
<template #note>
<NoteForm @saved="close()" />
</template>
<template #review>
<ReviewForm @saved="close()" />
</template>
</UTabs>
</template>
</UModal>
</template>
-26
View File
@@ -1,26 +0,0 @@
<script setup lang="ts">
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') })
function onSubmit(event: FormSubmitEvent<NewNoteInput>) {
return save(event.data)
}
</script>
<template>
<UForm :schema="newNoteSchema" :state="state" class="space-y-5" @submit="onSubmit">
<UFormField label="Date" name="date" required>
<UInput v-model="state.date" type="date" />
</UFormField>
<UFormField label="Note" name="text" required>
<UTextarea v-model="state.text" :rows="8" autoresize :maxrows="18" class="w-full" autofocus />
</UFormField>
<UButton type="submit" label="Save note" :icon="NOTE_ICON" :loading="saving" />
</UForm>
</template>
-35
View File
@@ -1,35 +0,0 @@
<script setup lang="ts">
const emit = defineEmits<{ unlocked: [] }>()
const password = ref('')
const error = ref<string>()
const unlocking = ref(false)
async function unlock() {
if (!password.value) return
unlocking.value = true
error.value = undefined
try {
await $fetch('/api/login', { method: 'POST', body: { password: password.value } })
emit('unlocked')
} catch (e) {
error.value = errorMessage(e) ?? 'Couldn\'t unlock the diary'
password.value = ''
} finally {
unlocking.value = false
}
}
</script>
<template>
<UModal :open="true" :dismissible="false" :close="false" title="Diary" description="Enter your password to open the diary.">
<template #body>
<form class="space-y-4" @submit.prevent="unlock">
<UFormField label="Password" :error="error">
<UInput v-model="password" type="password" autocomplete="current-password" class="w-full" autofocus />
</UFormField>
<UButton type="submit" label="Unlock" icon="i-lucide-lock-open" :loading="unlocking" :disabled="!password" />
</form>
</template>
</UModal>
</template>
-44
View File
@@ -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>
-63
View File
@@ -1,63 +0,0 @@
import type { Entry } from '~/types/entry'
const KIND_ORDER: Record<Entry['kind'], number> = { review: 0, note: 1 }
function toEntries(reviews: Review[], notes: Note[]): Entry[] {
return [
...reviews.map(review => ({ kind: 'review' as const, key: `review-${review.id}`, date: review.date, text: review.text, review })),
...notes.map(note => ({ kind: 'note' as const, key: `note-${note.id}`, date: note.date, text: note.text, note }))
]
}
function searchableText(entry: Entry): string {
const date = `${formatDate(entry.date)} ${entry.date}`
const text = entry.kind === 'review'
? `review ${entry.review.medium ?? ''} ${date} ${entry.review.title} ${entry.review.author ?? ''} ${entry.text}`
: `note ${date} ${entry.text}`
return text.toLowerCase()
}
function newestFirst(a: Entry, b: Entry): number {
return b.date.localeCompare(a.date) || KIND_ORDER[a.kind] - KIND_ORDER[b.kind] || b.key.localeCompare(a.key)
}
function entryIcon(entry: Entry): string {
if (entry.kind === 'note') return NOTE_ICON
return entry.review.medium ? MEDIUM_ICONS[entry.review.medium] : REVIEW_ICON
}
function groupByYear(entries: Entry[]) {
const years = new Map<string, Entry[]>()
for (const entry of entries) {
const year = entry.date.slice(0, 4)
years.set(year, [...(years.get(year) ?? []), entry])
}
return [...years].map(([year, entries]) => ({
year,
items: entries.map(entry => ({ value: entry.key, icon: entryIcon(entry), entry }))
}))
}
export function useDiary(search: Ref<string>) {
const { data: reviews, error: reviewsError } = useFetch<Review[]>('/api/reviews', { key: 'reviews', default: () => [] })
const { data: notes, error: notesError } = useFetch<Note[]>('/api/notes', { key: 'notes', default: () => [] })
const years = computed(() => {
const terms = searchTerms(search.value)
const matches = toEntries(reviews.value, notes.value)
.filter(entry => entry.text)
.filter((entry) => {
const text = searchableText(entry)
return terms.every(term => text.includes(term))
})
.toSorted(newestFirst)
return groupByYear(matches)
})
const errors = computed(() => [
{ what: 'reviews', error: reviewsError.value },
{ what: 'notes', error: notesError.value }
].filter(({ error }) => error))
return { years, errors }
}
-9
View File
@@ -1,9 +0,0 @@
export function useLog() {
const open = useState('log-open', () => false)
return {
open,
show: () => { open.value = true },
close: () => { open.value = false }
}
}
-29
View File
@@ -1,29 +0,0 @@
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 }) {
const toast = useToast()
const saving = ref(false)
async function save(body: Record<string, unknown>) {
saving.value = true
try {
await $fetch(options.url, { method: 'POST', body })
await refreshNuxtData(options.dataKey)
toast.add({ title: `${capitalize(options.noun)} saved`, color: 'success', icon: 'i-lucide-check' })
options.onSaved()
} catch (error) {
toast.add({
title: `Couldn't save the ${options.noun}`,
description: errorMessage(error),
color: 'error',
icon: 'i-lucide-triangle-alert'
})
} finally {
saving.value = false
}
}
return { saving, save }
}
+1 -42
View File
@@ -1,44 +1,3 @@
<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>
<div />
</template>
-3
View File
@@ -1,3 +0,0 @@
export type Entry =
| { kind: 'review', key: string, date: string, text: string, review: Review }
| { kind: 'note', key: string, date: string, text: string, note: Note }
-10
View File
@@ -1,10 +0,0 @@
export function formatDate(iso: string): string {
return new Date(`${iso}T00:00:00`).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })
}
export function todayIso(): string {
const today = new Date()
const month = String(today.getMonth() + 1).padStart(2, '0')
const day = String(today.getDate()).padStart(2, '0')
return `${today.getFullYear()}-${month}-${day}`
}
-10
View File
@@ -1,10 +0,0 @@
interface FetchErrorLike {
data?: { statusMessage?: string }
statusMessage?: string
message?: string
}
export function errorMessage(error: unknown): string | undefined {
const e = error as FetchErrorLike | null
return e?.data?.statusMessage || e?.statusMessage || e?.message
}
-19
View File
@@ -1,19 +0,0 @@
interface TextPart {
text: string
hit: boolean
}
export function searchTerms(query: string): string[] {
return query.trim().toLowerCase().split(/\s+/).filter(Boolean)
}
export function highlight(text: string, query: string): TextPart[] {
const terms = searchTerms(query)
if (!terms.length) return [{ text, hit: false }]
const escaped = terms.map(term => term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
return text
.split(new RegExp(`(${escaped.join('|')})`, 'gi'))
.map((part, index) => ({ text: part, hit: index % 2 === 1 }))
.filter(part => part.text)
}
-8
View File
@@ -1,8 +0,0 @@
export const NOTE_ICON = 'i-lucide-notebook-pen'
export const REVIEW_ICON = 'i-lucide-star'
export const MEDIUM_ICONS: Record<Medium, string> = {
book: 'i-lucide-book-open',
movie: 'i-lucide-clapperboard',
album: 'i-lucide-disc-3'
}