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