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
+29
View File
@@ -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>
+112
View File
@@ -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>
+14
View File
@@ -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>