Files
website/layers/notes/app/composables/useNotes.ts
T
2026-10-01 19:39:20 +02:00

138 lines
4.8 KiB
TypeScript

// 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 }
}