feat: add notes app
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user