feat: add notes app
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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' })
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
// "New", bottom right.
|
||||
// The list opens with NotesDrawer (mobile) and NotesSidebarHandle (desktop).
|
||||
const { creating, createAndOpen } = useNewNote()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pointer-events-none fixed inset-x-0 bottom-0 z-40 flex justify-end px-4 pb-[max(1rem,env(safe-area-inset-bottom))] md:px-6 md:pb-6">
|
||||
<UTooltip text="New note" :kbds="['meta', 'alt', 'n']">
|
||||
<UButton
|
||||
icon="i-lucide-square-pen"
|
||||
label="New"
|
||||
size="lg"
|
||||
class="pointer-events-auto rounded-full px-5 shadow-lg"
|
||||
:loading="creating"
|
||||
@click="createAndOpen"
|
||||
/>
|
||||
</UTooltip>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
// Mobile only: the note list, pulled down from the top. Tap or pull the bar at the top edge to open it;
|
||||
// push it back up, tap outside or pick a note to close it.
|
||||
const { drawerOpen } = useNotesSidebar()
|
||||
|
||||
const PULL_DISTANCE_PX = 24
|
||||
let startY: number | null = null
|
||||
|
||||
function onTouchStart(event: TouchEvent) {
|
||||
startY = event.touches[0]?.clientY ?? null
|
||||
}
|
||||
|
||||
function onTouchMove(event: TouchEvent) {
|
||||
const y = event.touches[0]?.clientY
|
||||
if (startY === null || y === undefined) return
|
||||
if (y - startY > PULL_DISTANCE_PX) {
|
||||
drawerOpen.value = true
|
||||
startY = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="md:hidden">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Show notes"
|
||||
class="fixed inset-x-0 top-0 z-30 flex h-7 touch-none items-start justify-center pt-2"
|
||||
@click="drawerOpen = true"
|
||||
@touchstart.passive="onTouchStart"
|
||||
@touchmove.passive="onTouchMove"
|
||||
>
|
||||
<span class="h-1.5 w-12 rounded-full bg-accented" />
|
||||
</button>
|
||||
|
||||
<UDrawer v-model:open="drawerOpen" direction="top" title="Notes" :ui="{ content: 'max-h-[85dvh]', body: 'overflow-y-auto p-0 sm:p-0' }">
|
||||
<template #body>
|
||||
<NotesList />
|
||||
</template>
|
||||
</UDrawer>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
// The note list, in the desktop sidebar and in the mobile drawer
|
||||
const { docs: allDocs, error } = useNotes()
|
||||
const { query, matches, searching } = useNotesSearch()
|
||||
const docs = computed(() => matches.value ? allDocs.value.filter(doc => matches.value!.includes(doc.id)) : allDocs.value)
|
||||
const { closeOnMobile } = useNotesSidebar()
|
||||
const deleteDoc = useDeleteNote()
|
||||
const route = useRoute()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="px-2 pb-2">
|
||||
<UInput
|
||||
v-model="query"
|
||||
icon="i-lucide-search"
|
||||
placeholder="Search"
|
||||
:loading="searching"
|
||||
class="w-full"
|
||||
@keydown.esc="query = ''"
|
||||
>
|
||||
<template v-if="query" #trailing>
|
||||
<UButton icon="i-lucide-x" color="neutral" variant="link" size="sm" aria-label="Clear search" @click="query = ''" />
|
||||
</template>
|
||||
</UInput>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="px-4 text-sm text-error">Couldn't load the notes: {{ errorMessage(error) }}</p>
|
||||
|
||||
<nav class="px-2 pb-4">
|
||||
<!-- The delete button shows on hover on desktop (instead of the date), always on mobile -->
|
||||
<div v-for="doc in docs" :key="doc.id" class="group relative">
|
||||
<NuxtLink
|
||||
:to="`/notes/${doc.id}`"
|
||||
class="flex items-baseline gap-2 rounded-md py-2.5 ps-2 pe-11 text-base md:py-1.5 md:pe-2 md:text-sm"
|
||||
:class="String(doc.id) === route.params.id ? 'bg-elevated text-highlighted' : 'text-default hover:bg-elevated/50'"
|
||||
@click="closeOnMobile"
|
||||
>
|
||||
<span class="flex-1 truncate" :class="{ 'text-muted': !doc.title }">{{ notesDocName(doc) }}</span>
|
||||
<span class="hidden shrink-0 text-xs text-dimmed tabular-nums md:inline md:group-hover:invisible">{{ notesDocDay(doc.created) }}</span>
|
||||
</NuxtLink>
|
||||
<UButton
|
||||
icon="i-lucide-trash-2"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:aria-label="`Delete ${notesDocName(doc)}`"
|
||||
title="Delete"
|
||||
class="absolute inset-y-0 end-1 my-auto h-fit text-dimmed hover:text-error md:opacity-0 md:group-hover:opacity-100 md:focus-visible:opacity-100"
|
||||
@click="deleteDoc(doc)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="!docs.length && !error && !searching" class="px-2 py-4 text-sm text-muted">
|
||||
{{ query.trim() ? 'No notes match.' : 'No notes yet.' }}
|
||||
</p>
|
||||
</nav>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
// Desktop only; on mobile the list is pulled down from the top (NotesDrawer)
|
||||
const { open } = useNotesSidebar()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside v-show="open" class="hidden w-72 shrink-0 flex-col border-e border-default md:flex">
|
||||
<h1 class="px-4 py-4 text-lg font-semibold text-highlighted">Notes</h1>
|
||||
<NotesList class="flex-1 overflow-y-auto" />
|
||||
</aside>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
// Desktop only: a chevron on the sidebar's right border (or the window's left edge when the
|
||||
// sidebar is hidden) that appears on hover and shows/hides the note list
|
||||
const { open, toggle } = useNotesSidebar()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- The whole hover area is the button. It only reaches to the right of the line (never over the note list's
|
||||
delete buttons), as wide as the note's side padding (md:px-10), so it never covers the text either -->
|
||||
<button
|
||||
type="button"
|
||||
:title="`${open ? 'Hide' : 'Show'} notes (⌘E)`"
|
||||
:aria-label="open ? 'Hide notes' : 'Show notes'"
|
||||
class="group fixed inset-y-0 z-20 hidden w-10 cursor-pointer items-center focus:outline-none md:flex"
|
||||
:class="open ? 'left-72' : 'left-0'"
|
||||
@click="toggle"
|
||||
>
|
||||
<span
|
||||
class="flex size-6 items-center justify-center rounded-full bg-default text-muted opacity-0 shadow-sm ring-1 ring-accented transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100 group-focus-visible:ring-2 group-focus-visible:ring-primary"
|
||||
:class="open ? '-translate-x-1/2' : 'ms-2'"
|
||||
>
|
||||
<UIcon :name="open ? 'i-lucide-chevron-left' : 'i-lucide-chevron-right'" class="size-4" />
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,137 @@
|
||||
// European numeric dates: 01.10.2026, 14:32
|
||||
const nameFormat = new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
const dayFormat = new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit' })
|
||||
|
||||
/** A note is named by its title, or by when it was created if it has none */
|
||||
export function notesDocName(doc: Pick<NotesDocSummary, 'title' | 'created'>): string {
|
||||
return doc.title || (doc.created ? nameFormat.format(new Date(doc.created)) : 'Untitled')
|
||||
}
|
||||
|
||||
export function notesDocDay(iso: string | null): string {
|
||||
return iso ? dayFormat.format(new Date(iso)) : ''
|
||||
}
|
||||
|
||||
export function useNotes() {
|
||||
const { data, error, status, refresh } = useFetch<NotesDocSummary[]>('/api/notes/docs', { key: 'notes-docs', default: () => [] })
|
||||
|
||||
// Newest first by creation, so editing a note never moves it
|
||||
const docs = computed(() => data.value.toSorted((a, b) => (b.created ?? '').localeCompare(a.created ?? '') || b.id - a.id))
|
||||
|
||||
/** Reflects a change in the browser without reloading the list */
|
||||
function patchSummary(id: number, changes: Partial<NotesDocSummary>) {
|
||||
data.value = data.value.map(doc => doc.id === id ? { ...doc, ...changes } : doc)
|
||||
}
|
||||
|
||||
async function create() {
|
||||
const doc = await $fetch<NotesDoc>('/api/notes/docs', { method: 'POST' })
|
||||
data.value = [...data.value, doc]
|
||||
return doc
|
||||
}
|
||||
|
||||
async function remove(id: number) {
|
||||
await $fetch(`/api/notes/docs/${id}`, { method: 'DELETE' })
|
||||
data.value = data.value.filter(doc => doc.id !== id)
|
||||
}
|
||||
|
||||
return { docs, error, status, refresh, patchSummary, create, remove }
|
||||
}
|
||||
|
||||
/**
|
||||
* `open`: the desktop sidebar (remembered in a cookie).
|
||||
* `drawerOpen`: the list that is pulled down from the top on mobile; it closes once a note is opened.
|
||||
*/
|
||||
export function useNotesSidebar() {
|
||||
const open = useCookie<boolean>('notes-sidebar', { default: () => true, maxAge: 60 * 60 * 24 * 365 })
|
||||
const drawerOpen = useState('notes-drawer', () => false)
|
||||
return {
|
||||
open,
|
||||
drawerOpen,
|
||||
toggle: () => { open.value = !open.value },
|
||||
/** The desktop sidebar, or the drawer below md */
|
||||
toggleList: () => {
|
||||
if (window.matchMedia('(max-width: 767px)').matches) drawerOpen.value = !drawerOpen.value
|
||||
else open.value = !open.value
|
||||
},
|
||||
closeOnMobile: () => { drawerOpen.value = false }
|
||||
}
|
||||
}
|
||||
|
||||
/** Creates a note and opens it; used by the action bar */
|
||||
export function useNewNote() {
|
||||
const { create } = useNotes()
|
||||
const { closeOnMobile } = useNotesSidebar()
|
||||
const toast = useToast()
|
||||
const creating = useState('notes-creating', () => false)
|
||||
|
||||
async function createAndOpen() {
|
||||
creating.value = true
|
||||
try {
|
||||
const doc = await create()
|
||||
closeOnMobile()
|
||||
await navigateTo(`/notes/${doc.id}`)
|
||||
} catch (e) {
|
||||
toast.add({ title: 'Couldn\'t create a note', description: errorMessage(e), color: 'error', icon: 'i-lucide-triangle-alert' })
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { creating, createAndOpen }
|
||||
}
|
||||
|
||||
/** Deletes a note right away; used by the list's trash buttons */
|
||||
export function useDeleteNote() {
|
||||
const { remove } = useNotes()
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
|
||||
return async function deleteNote(doc: NotesDocSummary) {
|
||||
try {
|
||||
// Leave the note first, so its last unsaved edits aren't saved into a deleted row
|
||||
if (String(doc.id) === route.params.id) await navigateTo('/notes')
|
||||
await remove(doc.id)
|
||||
} catch (e) {
|
||||
toast.add({ title: 'Couldn\'t delete the note', description: errorMessage(e), color: 'error', icon: 'i-lucide-triangle-alert' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const SEARCH_DELAY_MS = 250
|
||||
|
||||
/**
|
||||
* The search over title and text. `query` is what's typed; `matches` the ids of matching notes, or null when
|
||||
* not searching. The list is mounted twice (sidebar and drawer), so the searching itself runs once, in notes.vue.
|
||||
*/
|
||||
export function useNotesSearch() {
|
||||
const query = useState('notes-search', () => '')
|
||||
const matches = useState<number[] | null>('notes-search-matches', () => null)
|
||||
const searching = useState('notes-searching', () => false)
|
||||
|
||||
function runSearch() {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let latest = 0
|
||||
|
||||
watch(query, (value) => {
|
||||
clearTimeout(timer)
|
||||
const q = value.trim()
|
||||
if (!q) {
|
||||
matches.value = null
|
||||
searching.value = false
|
||||
return
|
||||
}
|
||||
searching.value = true
|
||||
timer = setTimeout(async () => {
|
||||
const request = ++latest
|
||||
try {
|
||||
const docs = await $fetch<NotesDocSummary[]>('/api/notes/docs', { query: { q } })
|
||||
if (request === latest) matches.value = docs.map(doc => doc.id)
|
||||
} finally {
|
||||
if (request === latest) searching.value = false
|
||||
}
|
||||
}, SEARCH_DELAY_MS)
|
||||
})
|
||||
}
|
||||
|
||||
return { query, matches, searching, runSearch }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
const SAVE_DELAY_MS = 800
|
||||
|
||||
export type NotesSaveState = 'saved' | 'unsaved' | 'saving' | 'error'
|
||||
|
||||
/** Collects edits to a note and saves them shortly after typing stops, and when leaving the note or the page */
|
||||
export function useNotesAutosave(id: number, onSaved: (summary: NotesDocSummary) => void) {
|
||||
const url = `/api/notes/docs/${id}`
|
||||
const state = ref<NotesSaveState>('saved')
|
||||
let pending: NotesDocUpdate = {}
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const hasPending = () => Object.keys(pending).length > 0
|
||||
|
||||
async function flush() {
|
||||
clearTimeout(timer)
|
||||
if (!hasPending()) return
|
||||
const body = pending
|
||||
pending = {}
|
||||
state.value = 'saving'
|
||||
try {
|
||||
onSaved(await $fetch<NotesDocSummary>(url, { method: 'PATCH', body }))
|
||||
state.value = hasPending() ? 'unsaved' : 'saved'
|
||||
} catch {
|
||||
pending = { ...body, ...pending }
|
||||
state.value = 'error'
|
||||
}
|
||||
}
|
||||
|
||||
function queue(changes: NotesDocUpdate) {
|
||||
Object.assign(pending, changes)
|
||||
state.value = 'unsaved'
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(flush, SAVE_DELAY_MS)
|
||||
}
|
||||
|
||||
// A normal request may not finish when the tab closes; keepalive lets it
|
||||
function saveOnUnload() {
|
||||
if (!hasPending()) return
|
||||
fetch(url, { method: 'PATCH', keepalive: true, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(pending) })
|
||||
pending = {}
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener('pagehide', saveOnUnload))
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('pagehide', saveOnUnload)
|
||||
flush()
|
||||
})
|
||||
onBeforeRouteLeave(() => flush())
|
||||
|
||||
return { state, queue, flush }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Notes' })
|
||||
|
||||
const { toggleList } = useNotesSidebar()
|
||||
const { createAndOpen } = useNewNote()
|
||||
useNotesSearch().runSearch()
|
||||
|
||||
// ⌘ on Mac, Ctrl elsewhere. ⌘N itself is reserved by the browser. ⌘E is kept from the editor in notes/[id].vue.
|
||||
defineShortcuts({
|
||||
meta_alt_n: { usingInput: true, handler: createAndOpen },
|
||||
meta_e: { usingInput: true, handler: toggleList }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PasswordGate title="Notes">
|
||||
<div class="flex h-dvh">
|
||||
<NotesSidebar />
|
||||
|
||||
<main class="relative min-w-0 flex-1 overflow-y-auto">
|
||||
<NuxtPage :page-key="route => route.fullPath" />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<NotesSidebarHandle />
|
||||
<NotesDrawer />
|
||||
<NotesActionBar />
|
||||
</PasswordGate>
|
||||
</template>
|
||||
@@ -0,0 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
import type { EditorToolbarItem } from '@nuxt/ui'
|
||||
|
||||
const route = useRoute()
|
||||
const id = Number(route.params.id)
|
||||
|
||||
const { data: doc, error } = await useFetch<NotesDoc>(`/api/notes/docs/${id}`, { key: `notes-doc-${id}` })
|
||||
const { patchSummary } = useNotes()
|
||||
|
||||
const title = ref(doc.value?.title ?? '')
|
||||
const content = ref(doc.value?.content ?? '')
|
||||
let savedContent = content.value
|
||||
|
||||
// ⌘E opens/closes the note list (notes.vue), so the editor must not also toggle inline code with it
|
||||
const editorProps = {
|
||||
handleKeyDown: (_view: unknown, event: KeyboardEvent) =>
|
||||
(event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'e'
|
||||
}
|
||||
|
||||
const { queue } = useNotesAutosave(id, summary => patchSummary(id, summary))
|
||||
|
||||
useHead({ title: () => doc.value ? notesDocName({ title: title.value, created: doc.value.created }) : 'Notes' })
|
||||
|
||||
watch(title, (value) => {
|
||||
patchSummary(id, { title: value.trim() })
|
||||
queue({ title: value })
|
||||
})
|
||||
|
||||
// The editor also reports content on load; only real edits are saved
|
||||
watch(content, (value) => {
|
||||
if (value === savedContent) return
|
||||
savedContent = value
|
||||
queue({ content: value })
|
||||
})
|
||||
|
||||
// Phones get a toolbar that stays at the top instead of the bubble on selection, which competes with the
|
||||
// system's own selection menu there
|
||||
const isMobile = ref(false)
|
||||
let mobileQuery: MediaQueryList | undefined
|
||||
const updateMobile = () => { isMobile.value = !!mobileQuery?.matches }
|
||||
onMounted(() => {
|
||||
mobileQuery = window.matchMedia('(max-width: 767px)')
|
||||
updateMobile()
|
||||
mobileQuery.addEventListener('change', updateMobile)
|
||||
})
|
||||
onBeforeUnmount(() => mobileQuery?.removeEventListener('change', updateMobile))
|
||||
|
||||
const toolbarItems: EditorToolbarItem[][] = [
|
||||
[
|
||||
{ kind: 'heading', level: 1, icon: 'i-lucide-heading-1', 'aria-label': 'Heading 1' },
|
||||
{ kind: 'heading', level: 2, icon: 'i-lucide-heading-2', 'aria-label': 'Heading 2' }
|
||||
],
|
||||
[
|
||||
{ kind: 'mark', mark: 'bold', icon: 'i-lucide-bold', 'aria-label': 'Bold' },
|
||||
{ kind: 'mark', mark: 'italic', icon: 'i-lucide-italic', 'aria-label': 'Italic' },
|
||||
{ kind: 'mark', mark: 'strike', icon: 'i-lucide-strikethrough', 'aria-label': 'Strikethrough' },
|
||||
{ kind: 'mark', mark: 'code', icon: 'i-lucide-code', 'aria-label': 'Code' },
|
||||
{ kind: 'link', icon: 'i-lucide-link', 'aria-label': 'Link' }
|
||||
],
|
||||
[
|
||||
{ kind: 'bulletList', icon: 'i-lucide-list', 'aria-label': 'Bullet list' },
|
||||
{ kind: 'orderedList', icon: 'i-lucide-list-ordered', 'aria-label': 'Numbered list' },
|
||||
{ kind: 'blockquote', icon: 'i-lucide-text-quote', 'aria-label': 'Quote' }
|
||||
]
|
||||
]
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-3xl px-6 pt-8 pb-32 md:px-10 md:pt-14 md:pb-24">
|
||||
<UAlert
|
||||
v-if="error"
|
||||
color="error"
|
||||
variant="subtle"
|
||||
icon="i-lucide-triangle-alert"
|
||||
title="Couldn't load this note"
|
||||
:description="errorMessage(error)"
|
||||
/>
|
||||
|
||||
<template v-else-if="doc">
|
||||
<div class="mb-6 flex items-center gap-3">
|
||||
<input
|
||||
v-model="title"
|
||||
:placeholder="notesDocName({ title: '', created: doc.created })"
|
||||
aria-label="Title"
|
||||
class="min-w-0 flex-1 bg-transparent text-3xl font-semibold text-highlighted outline-none placeholder:text-dimmed"
|
||||
>
|
||||
</div>
|
||||
|
||||
<UEditor
|
||||
v-slot="{ editor }"
|
||||
v-model="content"
|
||||
content-type="markdown"
|
||||
placeholder="Write something…"
|
||||
:mention="false"
|
||||
:starter-kit="{ link: { openOnClick: true } }"
|
||||
:editor-props="editorProps"
|
||||
:ui="{ base: 'sm:px-0 [&_a]:cursor-pointer' }"
|
||||
class="min-h-[60vh]"
|
||||
>
|
||||
<UEditorToolbar
|
||||
v-if="isMobile"
|
||||
:editor="editor"
|
||||
:items="toolbarItems"
|
||||
layout="fixed"
|
||||
class="sticky top-7 z-10 -mx-6 mb-4 overflow-x-auto border-b border-default bg-default/90 px-4 py-1 backdrop-blur"
|
||||
/>
|
||||
<UEditorToolbar v-else :editor="editor" :items="toolbarItems" layout="bubble" />
|
||||
</UEditor>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
// On mobile there is nothing to see without a note, so the list starts open
|
||||
const { drawerOpen } = useNotesSidebar()
|
||||
onMounted(() => {
|
||||
if (window.matchMedia('(max-width: 767px)').matches) drawerOpen.value = true
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full items-center justify-center p-8 text-center text-sm text-muted">
|
||||
<span class="md:hidden">Pull down from the top for your notes, or start a new one.</span>
|
||||
<span class="hidden md:inline">Pick a note, or start a new one.</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,5 @@
|
||||
export default defineNuxtConfig({
|
||||
runtimeConfig: {
|
||||
baserowNotesAppTableId: ''
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
await deleteRow(event, notesTable(event), notesDocId(event))
|
||||
return { deleted: true }
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
return toNotesDoc(await getRow<NotesDocRow>(event, notesTable(event), notesDocId(event)))
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const changes = await readValidBody(event, notesDocUpdateSchema)
|
||||
const row = await updateRow<NotesDocRow>(event, notesTable(event), notesDocId(event), { ...changes, updated: notesNow() })
|
||||
return toNotesDocSummary(row)
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
// ?q= searches title and text with Baserow's search. Baserow only searches (and filters) the fields a request
|
||||
// includes, so a search has to include `content` even though the list only needs the summary.
|
||||
export default defineEventHandler(async (event) => {
|
||||
const q = String(getQuery(event).q ?? '').trim()
|
||||
const query = q
|
||||
? { include: `${NOTES_SUMMARY_FIELDS},content`, search: q }
|
||||
: { include: NOTES_SUMMARY_FIELDS }
|
||||
|
||||
const rows = await listRows<NotesDocRow>(event, notesTable(event), query)
|
||||
return rows.map(toNotesDocSummary)
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const row = await createRow<NotesDocRow>(event, notesTable(event), { title: '', content: '', created: notesNow(), updated: notesNow() })
|
||||
return toNotesDoc(row)
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { H3Event } from 'h3'
|
||||
|
||||
export interface NotesDocRow {
|
||||
id: number
|
||||
title: string | null
|
||||
content?: string | null
|
||||
created: string | null
|
||||
updated: string | null
|
||||
}
|
||||
|
||||
export const NOTES_SUMMARY_FIELDS = 'title,created,updated'
|
||||
|
||||
export function notesTable(event: H3Event): string {
|
||||
const { baserowNotesAppTableId } = useRuntimeConfig(event)
|
||||
if (!baserowNotesAppTableId) {
|
||||
throw createError({ statusCode: 500, statusMessage: 'NUXT_BASEROW_NOTES_APP_TABLE_ID is not set' })
|
||||
}
|
||||
return baserowNotesAppTableId
|
||||
}
|
||||
|
||||
export function notesDocId(event: H3Event): number {
|
||||
const id = Number(getRouterParam(event, 'id'))
|
||||
if (!Number.isInteger(id) || id < 1) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Invalid note' })
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
/** `created` and `updated` are plain date fields, so the server keeps them up to date */
|
||||
export function notesNow(): string {
|
||||
return new Date().toISOString()
|
||||
}
|
||||
|
||||
export function toNotesDocSummary(row: NotesDocRow): NotesDocSummary {
|
||||
return { id: row.id, title: (row.title ?? '').trim(), created: row.created, updated: row.updated }
|
||||
}
|
||||
|
||||
export function toNotesDoc(row: NotesDocRow): NotesDoc {
|
||||
return { ...toNotesDocSummary(row), content: row.content ?? '' }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export interface NotesDocSummary {
|
||||
id: number
|
||||
title: string
|
||||
/** ISO timestamps, set by the server; null for rows added in Baserow directly */
|
||||
created: string | null
|
||||
updated: string | null
|
||||
}
|
||||
|
||||
export interface NotesDoc extends NotesDocSummary {
|
||||
/** Markdown */
|
||||
content: string
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const notesDocUpdateSchema = z.object({
|
||||
title: z.string().max(255).optional(),
|
||||
content: z.string().optional()
|
||||
})
|
||||
|
||||
export type NotesDocUpdate = z.output<typeof notesDocUpdateSchema>
|
||||
Reference in New Issue
Block a user