initial commit
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
NUXT_BASEROW_URL=http://baserow.example
|
||||||
|
NUXT_BASEROW_TOKEN=
|
||||||
|
NUXT_BASEROW_REVIEWS_TABLE_ID=866
|
||||||
|
NUXT_BASEROW_NOTES_TABLE_ID=864
|
||||||
|
NUXT_DIARY_PASSWORD=
|
||||||
|
NUXT_SESSION_SECRET=
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
.nuxt
|
||||||
|
.output
|
||||||
|
.data
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
cp .env.example .env
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `NUXT_BASEROW_URL` | Baserow instance, no trailing slash |
|
||||||
|
| `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_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.
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
- `/` is the only page: 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.
|
||||||
|
Changing `NUXT_SESSION_SECRET` logs out every browser.
|
||||||
|
|
||||||
|
## Code layout
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
app.vue password gate around the page
|
||||||
|
pages/index.vue the diary page
|
||||||
|
components/
|
||||||
|
DiaryEntry.vue one timeline entry (date, title, text, read more)
|
||||||
|
LogModal.vue Note / Review switch around the two forms
|
||||||
|
NoteForm.vue, ReviewForm.vue
|
||||||
|
PasswordModal.vue
|
||||||
|
CoverTile.vue generated cover, used when showCovers is on
|
||||||
|
composables/
|
||||||
|
useDiary.ts loads reviews + notes, searches, sorts and groups them by year
|
||||||
|
useSaveEntry.ts shared submit logic of the forms
|
||||||
|
useLog.ts open/close state of the Log modal
|
||||||
|
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)
|
||||||
|
utils/
|
||||||
|
baserow.ts Baserow client: listRows, createRow, listFields
|
||||||
|
review.ts, note.ts Baserow row -> app type
|
||||||
|
auth.ts session cookie and password check
|
||||||
|
validation.ts readValidBody(event, zodSchema)
|
||||||
|
shared/
|
||||||
|
types/ Review, Note
|
||||||
|
utils/medium.ts the review media: label and creator label
|
||||||
|
utils/schemas.ts zod schemas used by both forms and API
|
||||||
|
```
|
||||||
|
|
||||||
|
## Baserow tables
|
||||||
|
|
||||||
|
**Reviews:** `title`, `date` (ISO date), `author_or_director`, `review` (long text) and `medium`
|
||||||
|
(single select: `Book` / `Movie` / `Album`).
|
||||||
|
|
||||||
|
**Notes:** `Datum` (date) and `Notes` (long text).
|
||||||
|
|
||||||
|
Rows without a date are skipped. Text is plain text: line breaks are kept and it is never rendered as HTML.
|
||||||
|
|
||||||
|
### Adding a medium
|
||||||
|
|
||||||
|
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`.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export default defineAppConfig({
|
||||||
|
showCovers: false,
|
||||||
|
ui: {
|
||||||
|
colors: {
|
||||||
|
primary: 'amber',
|
||||||
|
neutral: 'stone'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
<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()" />
|
||||||
|
</UApp>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "@nuxt/ui";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-serif: ui-serif, 'Iowan Old Style', 'Palatino Linotype', Georgia, serif;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
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 }
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export function useLog() {
|
||||||
|
const open = useState('log-open', () => false)
|
||||||
|
|
||||||
|
return {
|
||||||
|
open,
|
||||||
|
show: () => { open.value = true },
|
||||||
|
close: () => { open.value = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
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 }
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export type Entry =
|
||||||
|
| { kind: 'review', key: string, date: string, text: string, review: Review }
|
||||||
|
| { kind: 'note', key: string, date: string, text: string, note: Note }
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
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}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
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'
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export default defineNuxtConfig({
|
||||||
|
modules: ['@nuxt/ui'],
|
||||||
|
css: ['~/assets/css/main.css'],
|
||||||
|
compatibilityDate: '2026-09-01',
|
||||||
|
devtools: { enabled: false },
|
||||||
|
runtimeConfig: {
|
||||||
|
baserowUrl: '',
|
||||||
|
baserowToken: '',
|
||||||
|
baserowReviewsTableId: '',
|
||||||
|
baserowNotesTableId: '',
|
||||||
|
diaryPassword: '',
|
||||||
|
sessionSecret: ''
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
head: {
|
||||||
|
title: 'Diary',
|
||||||
|
htmlAttrs: { lang: 'en' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "bookshelf",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "nuxt dev",
|
||||||
|
"build": "nuxt build",
|
||||||
|
"preview": "nuxt preview",
|
||||||
|
"typecheck": "nuxt typecheck"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@iconify-json/lucide": "^1.2.134",
|
||||||
|
"@nuxt/ui": "^4.11.1",
|
||||||
|
"nuxt": "^4.5.2",
|
||||||
|
"tailwindcss": "^4.3.3",
|
||||||
|
"vue": "^3.5.43",
|
||||||
|
"vue-router": "^5.3.1",
|
||||||
|
"zod": "^4.6.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vue-tsc": "^3.3.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+8703
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
|||||||
|
allowBuilds:
|
||||||
|
esbuild: set this to true or false
|
||||||
|
vue-demi: set this to true or false
|
||||||
@@ -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, '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, '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, 'reviews')
|
||||||
|
return rows.map(toReview).filter(review => review.date)
|
||||||
|
})
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
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)
|
||||||
|
})
|
||||||
@@ -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/session', '/api/login']
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const path = event.path.split('?')[0]!
|
||||||
|
if (!path.startsWith('/api/') || PUBLIC_ROUTES.includes(path)) return
|
||||||
|
|
||||||
|
const session = await diarySession(event)
|
||||||
|
if (!session.data.unlocked) {
|
||||||
|
throw createError({ statusCode: 401, statusMessage: 'The diary is locked' })
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import type { H3Event } from 'h3'
|
||||||
|
|
||||||
|
type Table = 'reviews' | 'notes'
|
||||||
|
type Fields = Record<string, unknown>
|
||||||
|
|
||||||
|
interface RowPage<Row> {
|
||||||
|
count: number
|
||||||
|
results: Row[]
|
||||||
|
}
|
||||||
|
|
||||||
|
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<T>(
|
||||||
|
event: H3Event,
|
||||||
|
path: string,
|
||||||
|
options: { method?: 'GET' | 'POST', query?: Fields, body?: Fields } = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const { baserowUrl, baserowToken } = useRuntimeConfig(event)
|
||||||
|
if (!baserowUrl || !baserowToken) {
|
||||||
|
throw createError({ statusCode: 500, statusMessage: 'NUXT_BASEROW_URL and NUXT_BASEROW_TOKEN must be set' })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await $fetch(`/api/database/${path}`, {
|
||||||
|
baseURL: baserowUrl,
|
||||||
|
method: options.method ?? 'GET',
|
||||||
|
headers: { Authorization: `Token ${baserowToken}` },
|
||||||
|
query: options.query,
|
||||||
|
body: options.body,
|
||||||
|
timeout: TIMEOUT_MS
|
||||||
|
}) as T
|
||||||
|
} catch (error: any) {
|
||||||
|
const detail = error?.data?.detail
|
||||||
|
throw createError({
|
||||||
|
statusCode: error?.statusCode ?? 502,
|
||||||
|
statusMessage: typeof detail === 'string' ? detail : 'Baserow request failed'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listRows<Row>(event: H3Event, table: Table): Promise<Row[]> {
|
||||||
|
const path = `rows/table/${tableId(event, table)}/`
|
||||||
|
const rows: Row[] = []
|
||||||
|
for (let page = 1; ; page++) {
|
||||||
|
const { count, results } = await request<RowPage<Row>>(event, path, {
|
||||||
|
query: { user_field_names: true, size: PAGE_SIZE, page }
|
||||||
|
})
|
||||||
|
rows.push(...results)
|
||||||
|
if (rows.length >= count || results.length === 0) return rows
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRow<Row>(event: H3Event, table: Table, fields: Fields): Promise<Row> {
|
||||||
|
return request<Row>(event, `rows/table/${tableId(event, table)}/`, {
|
||||||
|
method: 'POST',
|
||||||
|
query: { user_field_names: true },
|
||||||
|
body: fields
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listFields<Field>(event: H3Event, table: Table): Promise<Field[]> {
|
||||||
|
return request<Field[]>(event, `fields/table/${tableId(event, table)}/`)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export interface Note {
|
||||||
|
id: number
|
||||||
|
date: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export type Medium = 'book' | 'movie' | 'album'
|
||||||
|
|
||||||
|
export interface Review {
|
||||||
|
id: number
|
||||||
|
date: string
|
||||||
|
title: string
|
||||||
|
author: string | null
|
||||||
|
text: string
|
||||||
|
medium: Medium | null
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { Medium } from '../types/review'
|
||||||
|
|
||||||
|
export const MEDIUMS: Record<Medium, { label: string, creator: string }> = {
|
||||||
|
book: { label: 'Book', creator: 'Author' },
|
||||||
|
movie: { label: 'Movie', creator: 'Director' },
|
||||||
|
album: { label: 'Album', creator: 'Artist' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MEDIUM_KEYS = Object.keys(MEDIUMS) as [Medium, ...Medium[]]
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
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')
|
||||||
|
|
||||||
|
export const newNoteSchema = z.object({
|
||||||
|
date: isoDate,
|
||||||
|
text: requiredText
|
||||||
|
})
|
||||||
|
|
||||||
|
export const newReviewSchema = z.object({
|
||||||
|
medium: z.enum(MEDIUM_KEYS, { error: 'Pick a medium' }),
|
||||||
|
date: isoDate,
|
||||||
|
title: z.string().trim().min(1, 'Add a title'),
|
||||||
|
author: z.string().trim().optional(),
|
||||||
|
text: requiredText
|
||||||
|
})
|
||||||
|
|
||||||
|
export const loginSchema = z.object({
|
||||||
|
password: z.string().min(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
export type NewNoteInput = z.output<typeof newNoteSchema>
|
||||||
|
export type NewReviewInput = z.output<typeof newReviewSchema>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./.nuxt/tsconfig.app.json" },
|
||||||
|
{ "path": "./.nuxt/tsconfig.server.json" },
|
||||||
|
{ "path": "./.nuxt/tsconfig.shared.json" },
|
||||||
|
{ "path": "./.nuxt/tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user