138 lines
4.8 KiB
TypeScript
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 }
|
|
}
|
|
|