feat: add notes app

This commit is contained in:
2026-10-01 19:39:20 +02:00
parent ccb8dd3d15
commit ddbb6f37b5
35 changed files with 706 additions and 52 deletions
@@ -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/diary/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>
+2 -7
View File
@@ -1,12 +1,9 @@
<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">
<PasswordGate title="Diary">
<UMain>
<UContainer class="py-8">
<DiaryTimeline />
@@ -14,7 +11,5 @@ const unlocked = computed(() => !!session.value?.unlocked)
</UMain>
<LogModal />
</template>
<PasswordModal v-else @unlocked="refresh()" />
</PasswordGate>
</template>
-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
}
+1 -3
View File
@@ -1,8 +1,6 @@
export default defineNuxtConfig({
runtimeConfig: {
baserowReviewsTableId: '',
baserowNotesTableId: '',
diaryPassword: '',
sessionSecret: ''
baserowNotesTableId: ''
}
})
@@ -1,14 +0,0 @@
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 }
})
@@ -1,4 +0,0 @@
export default defineEventHandler(async (event) => {
const session = await diarySession(event)
return { unlocked: !!session.data.unlocked }
})
@@ -1,11 +0,0 @@
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
@@ -1,36 +0,0 @@
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
}
-10
View File
@@ -1,10 +0,0 @@
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
}
-4
View File
@@ -14,9 +14,5 @@ export const newReviewSchema = z.object({
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>