From 813a4e1050d7180409dad08bb45da9bd3eff9216 Mon Sep 17 00:00:00 2001 From: Irfan Fadilah Date: Thu, 15 Oct 2026 10:59:29 +0700 Subject: [PATCH 1/9] Add Shared UI Components --- app/components/ui/ConfirmDialog.vue | 46 +++++++++++++++ app/components/ui/Icon.vue | 22 ++++++++ app/components/ui/InlineConfirm.vue | 37 ++++++++++++ app/components/ui/Modal.vue | 88 +++++++++++++++++++++++++++++ app/composables/useEscapeKey.ts | 54 ++++++++++++------ app/utils/download.ts | 21 +++++++ app/utils/focus.ts | 9 +++ app/utils/icons.ts | 36 ++++++++++++ app/utils/ui.ts | 6 ++ 9 files changed, 301 insertions(+), 18 deletions(-) create mode 100644 app/components/ui/ConfirmDialog.vue create mode 100644 app/components/ui/Icon.vue create mode 100644 app/components/ui/InlineConfirm.vue create mode 100644 app/components/ui/Modal.vue create mode 100644 app/utils/download.ts create mode 100644 app/utils/focus.ts create mode 100644 app/utils/icons.ts create mode 100644 app/utils/ui.ts diff --git a/app/components/ui/ConfirmDialog.vue b/app/components/ui/ConfirmDialog.vue new file mode 100644 index 0000000..7fca793 --- /dev/null +++ b/app/components/ui/ConfirmDialog.vue @@ -0,0 +1,46 @@ + + + diff --git a/app/components/ui/Icon.vue b/app/components/ui/Icon.vue new file mode 100644 index 0000000..c17d2ad --- /dev/null +++ b/app/components/ui/Icon.vue @@ -0,0 +1,22 @@ + + + diff --git a/app/components/ui/InlineConfirm.vue b/app/components/ui/InlineConfirm.vue new file mode 100644 index 0000000..4a4be72 --- /dev/null +++ b/app/components/ui/InlineConfirm.vue @@ -0,0 +1,37 @@ + + + diff --git a/app/components/ui/Modal.vue b/app/components/ui/Modal.vue new file mode 100644 index 0000000..489b1b5 --- /dev/null +++ b/app/components/ui/Modal.vue @@ -0,0 +1,88 @@ + + + diff --git a/app/composables/useEscapeKey.ts b/app/composables/useEscapeKey.ts index f400e1b..cca42e5 100644 --- a/app/composables/useEscapeKey.ts +++ b/app/composables/useEscapeKey.ts @@ -1,22 +1,40 @@ -import { useLightbox } from './useLightbox' +import type { MaybeRefOrGetter } from 'vue' -/** - * Calls `fn` when the Escape key is pressed. - * Listener is automatically removed when the component unmounts. - * - * If the lightbox is open, do not invoke the callback so the lightbox - * remains the top-most handler for Escape. - */ -export function useEscapeKey(fn: () => void) { - const { lightboxOpen } = useLightbox() +// Escape closes only the top-most open layer (modal, menu, lightbox, confirm…), never +// everything at once. Layers register while active; the most recently activated one wins. +type Handler = () => void - const handler = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - // Let the lightbox handle Escape when it's open. - if (lightboxOpen && lightboxOpen.value) return - fn() - } +const stack: Handler[] = [] +let listening = false + +function onKeydown(e: KeyboardEvent) { + // Inputs that handle Escape themselves (e.g. inline rename) call preventDefault to claim it + if (e.key !== 'Escape' || e.defaultPrevented || e.isComposing) return + const top = stack[stack.length - 1] + if (!top) return + e.preventDefault() + top() +} + +/** Run `fn` on Escape while `active` is true and this is the top-most active layer. */ +export function useEscapeKey(fn: Handler, active: MaybeRefOrGetter = true) { + if (!import.meta.client) return + const handler: Handler = () => fn() + + const remove = () => { + const index = stack.indexOf(handler) + if (index !== -1) stack.splice(index, 1) } - onMounted(() => document.addEventListener('keydown', handler)) - onUnmounted(() => document.removeEventListener('keydown', handler)) + + onMounted(() => { + if (!listening) { + document.addEventListener('keydown', onKeydown) + listening = true + } + watch(() => toValue(active), (isActive) => { + remove() + if (isActive) stack.push(handler) + }, { immediate: true }) + }) + onUnmounted(remove) } diff --git a/app/utils/download.ts b/app/utils/download.ts new file mode 100644 index 0000000..38792e3 --- /dev/null +++ b/app/utils/download.ts @@ -0,0 +1,21 @@ +const isStandalonePwa = () => + window.matchMedia('(display-mode: standalone)').matches || + (window.navigator as Navigator & { standalone?: boolean }).standalone === true + +/** + * Download a file from the API. Installed PWAs have no tab to open, so they get a + * download link instead of a new window. + */ +export function downloadFile(url: string, filename: string) { + if (isStandalonePwa()) { + const link = document.createElement('a') + link.href = url + link.download = filename + link.rel = 'noopener' + document.body.appendChild(link) + link.click() + link.remove() + return + } + window.open(url, '_blank', 'noopener') +} diff --git a/app/utils/focus.ts b/app/utils/focus.ts new file mode 100644 index 0000000..3fc3e6c --- /dev/null +++ b/app/utils/focus.ts @@ -0,0 +1,9 @@ +/** + * Give focus back to `target` after a dialog or menu closes, unless the user has already moved + * it elsewhere (closing can finish late, e.g. after a save) — then leave it where it is. + */ +export function restoreFocus(target: HTMLElement | null, closingContainer?: HTMLElement | null) { + const active = document.activeElement + const focusWasLost = !active || active === document.body || !!closingContainer?.contains(active) + if (focusWasLost && target?.isConnected) target.focus() +} diff --git a/app/utils/icons.ts b/app/utils/icons.ts new file mode 100644 index 0000000..78ee980 --- /dev/null +++ b/app/utils/icons.ts @@ -0,0 +1,36 @@ +// Outline icons (24×24, Heroicons style) used across the app. Rendered by . + +interface IconDefinition { + paths: string[] + /** Filled icons use `fill`; everything else is stroked */ + fill?: boolean + strokeWidth?: number +} + +const TRASH = 'M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0' + +export const ICONS = { + 'archive': { paths: ['M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z'] }, + 'arrow-up': { paths: ['M4.5 10.5 12 3m0 0 7.5 7.5M12 3v18'], strokeWidth: 2 }, + 'backspace': { paths: ['M12 9l3 3-3 3m3-3H9m-6 0l6.75-7.5A1.5 1.5 0 0110.86 4.5h9.64a1.5 1.5 0 011.5 1.5v12a1.5 1.5 0 01-1.5 1.5h-9.64a1.5 1.5 0 01-1.11-.5L3 12z'], strokeWidth: 1.8 }, + 'check': { paths: ['M4.5 12.75l6 6 9-13.5'] }, + 'checklist': { paths: ['M11.35 3.836c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m8.9-4.414c.376.023.75.05 1.124.08 1.131.094 1.976 1.057 1.976 2.192V16.5A2.25 2.25 0 0118 18.75h-2.25m-7.5-10.5H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V18.75m-7.5-10.5h6.375c.621 0 1.125.504 1.125 1.125v9.375m-8.25-3l1.5 1.5 3-3.75'] }, + 'chevron-left': { paths: ['M15.75 19.5 8.25 12l7.5-7.5'] }, + 'chevron-right': { paths: ['M8.25 4.5l7.5 7.5-7.5 7.5'] }, + 'clipboard': { paths: ['M15.666 3.888A2.25 2.25 0 0 0 13.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 0 1-.75.75H9a.75.75 0 0 1-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 0 1-2.25 2.25H6.75A2.25 2.25 0 0 1 4.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 0 1 1.927-.184'] }, + 'document': { paths: ['M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z'] }, + 'download': { paths: ['M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3'] }, + 'drag-handle': { paths: ['M9 4a1 1 0 100 2 1 1 0 000-2zm6 0a1 1 0 100 2 1 1 0 000-2zM9 10a1 1 0 100 2 1 1 0 000-2zm6 0a1 1 0 100 2 1 1 0 000-2zM9 16a1 1 0 100 2 1 1 0 000-2zm6 0a1 1 0 100 2 1 1 0 000-2z'], fill: true }, + 'menu': { paths: ['M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5'] }, + 'paperclip': { paths: ['M18.375 12.739l-7.693 7.693a4.5 4.5 0 01-6.364-6.364l10.94-10.94A3 3 0 1119.5 7.372L8.552 18.32m.009-.01-.01.01m5.699-9.941-7.81 7.81a1.5 1.5 0 002.112 2.13'] }, + 'pencil': { paths: ['M16.862 4.487a2.25 2.25 0 013.182 3.182L7.5 20.213l-4.5 1.125 1.125-4.5L16.862 4.487z'] }, + 'pencil-square': { paths: ['M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10'] }, + 'restore': { paths: ['M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.85l3.181 3.182m0-4.991v4.99'] }, + 'search': { paths: ['M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z'] }, + 'tag': { paths: ['M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3z', 'M6 6h.008v.008H6V6z'] }, + 'trash': { paths: [TRASH] }, + 'warning': { paths: ['M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z'] }, + 'x-mark': { paths: ['M6 18 18 6M6 6l12 12'] }, +} satisfies Record + +export type IconName = keyof typeof ICONS diff --git a/app/utils/ui.ts b/app/utils/ui.ts new file mode 100644 index 0000000..e2ebc72 --- /dev/null +++ b/app/utils/ui.ts @@ -0,0 +1,6 @@ +/** Matches Tailwind's `md` breakpoint: below this the sidebar becomes an overlay. */ +export const MOBILE_BREAKPOINT_PX = 768 + +export const SNACKBAR_DURATION_MS = 3500 + +export const isMobileViewport = () => typeof window !== 'undefined' && window.innerWidth < MOBILE_BREAKPOINT_PX From 8c5df2a4ccb234c96a12743e1b0bf3a342e91b66 Mon Sep 17 00:00:00 2001 From: Irfan Fadilah Date: Thu, 15 Oct 2026 10:59:29 +0700 Subject: [PATCH 2/9] Split Notes Composable --- app/composables/useEventSource.ts | 105 ++++----- app/composables/useLabels.ts | 42 ++-- app/composables/useNoteAttachments.ts | 86 +++++++ app/composables/useNoteMutations.ts | 84 +++++++ app/composables/useNoteUploadState.ts | 11 +- app/composables/useNotes.ts | 312 ------------------------- app/composables/useNotesList.ts | 155 ++++++++++++ app/composables/useNotesPage.ts | 29 --- app/composables/useRealtimeResource.ts | 32 +++ 9 files changed, 434 insertions(+), 422 deletions(-) create mode 100644 app/composables/useNoteAttachments.ts create mode 100644 app/composables/useNoteMutations.ts delete mode 100644 app/composables/useNotes.ts create mode 100644 app/composables/useNotesList.ts delete mode 100644 app/composables/useNotesPage.ts create mode 100644 app/composables/useRealtimeResource.ts diff --git a/app/composables/useEventSource.ts b/app/composables/useEventSource.ts index 2461c1c..c4457b2 100644 --- a/app/composables/useEventSource.ts +++ b/app/composables/useEventSource.ts @@ -1,42 +1,28 @@ -type RealtimeResource = 'notes' | 'labels' | 'all' -type RealtimeEvent = { resource: RealtimeResource; data: unknown; type: string } -type Listener = (event: RealtimeEvent) => void +import type { RealtimeEvent } from '#shared/types/realtime' + +/** Server events plus the client-side signal sent after the stream reconnects. */ +export type ClientRealtimeEvent = RealtimeEvent | { type: 'reconnected'; data: { resource: 'all' } } +type Listener = (event: ClientRealtimeEvent) => void const listeners = new Set() let source: EventSource | null = null let retryTimer: ReturnType | null = null let retryAttempt = 0 let started = false -let stopped = false let connectedBefore = false const MAX_RETRY_DELAY = 30_000 -function resourceFor(type: string, data: unknown): RealtimeResource { - const eventType = String(type).toLowerCase() - if (eventType.startsWith('label.')) return 'labels' - if (eventType.startsWith('note.') || eventType.startsWith('attachment.')) return 'notes' - const record = typeof data === 'object' && data !== null ? data as Record : {} - const value = record.resource ?? record.entity ?? record.scope - if (value === 'labels') return 'labels' - if (value === 'notes') return 'notes' - return 'all' -} - -function dispatch(type: string, raw: string) { - let data: unknown = raw +function dispatch(raw: string) { + let parsed: unknown try { - data = JSON.parse(raw) + parsed = JSON.parse(raw) } catch { - // Some SSE implementations use an empty heartbeat or a plain event name. + return } - const eventType = typeof data === 'object' && data !== null && 'type' in data - ? String(data.type) - : type - const eventData = typeof data === 'object' && data !== null && 'type' in data - ? Object.fromEntries(Object.entries(data).filter(([key]) => key !== 'type')) - : data - const event = { resource: resourceFor(eventType, eventData), data: eventData, type: eventType } + if (typeof parsed !== 'object' || parsed === null || !('type' in parsed)) return + const { type, ...data } = parsed as { type: string } & Record + const event = { type, data } as ClientRealtimeEvent listeners.forEach((listener) => listener(event)) } @@ -45,8 +31,13 @@ function closeSource() { source = null } +function cancelRetry() { + if (retryTimer) clearTimeout(retryTimer) + retryTimer = null +} + function scheduleReconnect() { - if (stopped || retryTimer || !navigator.onLine || document.visibilityState === 'hidden') return + if (!started || retryTimer || !navigator.onLine || document.visibilityState === 'hidden') return const delay = Math.min(MAX_RETRY_DELAY, 500 * 2 ** retryAttempt++) + Math.random() * 250 retryTimer = setTimeout(() => { retryTimer = null @@ -55,52 +46,56 @@ function scheduleReconnect() { } function connect() { - if (stopped || source || !navigator.onLine || document.visibilityState === 'hidden') return - closeSource() + if (!started || source || !navigator.onLine || document.visibilityState === 'hidden') return source = new EventSource('/api/events') source.onopen = () => { retryAttempt = 0 - if (connectedBefore) { - dispatch('reconnected', JSON.stringify({ resource: 'all' })) - } + if (connectedBefore) dispatch(JSON.stringify({ type: 'reconnected', resource: 'all' })) connectedBefore = true } - source.onmessage = (event) => dispatch(event.type, event.data) + source.onmessage = (event) => dispatch(event.data) source.onerror = () => { closeSource() scheduleReconnect() } +} + +function onOffline() { + cancelRetry() + closeSource() +} - // Supporting named events keeps the client compatible with both generic - // invalidations and resource-specific SSE events. - for (const type of ['invalidate', 'notes', 'labels', 'notes.invalidate', 'labels.invalidate']) { - source.addEventListener(type, (event) => dispatch(type, (event as MessageEvent).data)) +function onVisibilityChange() { + if (document.visibilityState === 'visible') { + retryAttempt = 0 + connect() + } else { + cancelRetry() + closeSource() } } function start() { if (!import.meta.client || started) return started = true - stopped = false window.addEventListener('online', connect) - window.addEventListener('offline', () => { - if (retryTimer) clearTimeout(retryTimer) - retryTimer = null - closeSource() - }) - document.addEventListener('visibilitychange', () => { - if (document.visibilityState === 'visible') { - retryAttempt = 0 - connect() - } else { - if (retryTimer) clearTimeout(retryTimer) - retryTimer = null - closeSource() - } - }) + window.addEventListener('offline', onOffline) + document.addEventListener('visibilitychange', onVisibilityChange) connect() } +/** Close the stream and remove listeners, e.g. when leaving the signed-in layout. */ +function stop() { + if (!started) return + started = false + connectedBefore = false + cancelRetry() + closeSource() + window.removeEventListener('online', connect) + window.removeEventListener('offline', onOffline) + document.removeEventListener('visibilitychange', onVisibilityChange) +} + export const useEventSource = () => { const subscribe = (listener: Listener) => { if (!import.meta.client) return () => {} @@ -108,7 +103,5 @@ export const useEventSource = () => { return () => listeners.delete(listener) } - const startSource = () => start() - - return { start: startSource, subscribe } + return { start, stop, subscribe } } diff --git a/app/composables/useLabels.ts b/app/composables/useLabels.ts index da28d6c..c66a573 100644 --- a/app/composables/useLabels.ts +++ b/app/composables/useLabels.ts @@ -1,38 +1,36 @@ import type { Label, Note } from '#shared/types/notes' +import type { ClientRealtimeEvent } from './useEventSource' -// See noteUrl in useNotes for why this is typed as a plain string +// See noteUrl in useNotesList for why this is typed as a plain string const labelUrl = (id: string): string => `/api/labels/${id}` -export const useLabels = () => { +const labelEventKeys = (event: ClientRealtimeEvent): string[] => { + if (event.data.resource !== 'labels') return [] + const labelId = 'label' in event.data ? event.data.label.id : 'labelId' in event.data ? event.data.labelId : undefined + return labelId ? [`${event.type}:${labelId}`, event.type] : [event.type] +} + +/** Pass `realtime: true` once (in the layout) to keep labels in sync with other devices. */ +export const useLabels = (options: { realtime?: boolean } = {}) => { // useState ensures SSR state is serialized in the Nuxt payload and // transferred to the client, preventing hydration mismatches. const labels = useState('labels', () => []) - const apiFetch = useNuxtApp().$api - const { subscribe } = useEventSource() - const realtimeBound = useState('labels-realtime-bound', () => false) - const { consume: consumeLocalLabelEvent, run: runLocalLabelMutation } = - useLocalRealtimeEvents('local-label-events') const fetchLabels = async () => { labels.value = await apiFetch('/api/labels') } - if (import.meta.client && !realtimeBound.value) { - realtimeBound.value = true - subscribe((event) => { - if (event.resource === 'labels' || event.type === 'reconnected') { - const data = event.data as { label?: { id?: string }; labelId?: string } | undefined - const labelId = data?.label?.id ?? data?.labelId - const eventKey = labelId ? `${event.type}:${labelId}` : event.type - if ( - event.type !== 'reconnected' && - (consumeLocalLabelEvent(eventKey) || (labelId ? consumeLocalLabelEvent(event.type) : false)) - ) return - fetchLabels().catch((error) => console.warn('[realtime] Failed to refresh labels:', error)) - } - }) - } + const { runLocal: runLocalLabelMutation } = useRealtimeResource({ + localEventsKey: 'local-label-events', + eventKeys: labelEventKeys, + onEvent: options.realtime + ? (event) => { + if (event.data.resource !== 'labels' && event.type !== 'reconnected') return + fetchLabels().catch((error) => console.warn('[realtime] Failed to refresh labels:', error)) + } + : undefined, + }) /** Resolves to the new label, or undefined on failure (an error snackbar is shown). */ const createLabel = async (name: string) => { diff --git a/app/composables/useNoteAttachments.ts b/app/composables/useNoteAttachments.ts new file mode 100644 index 0000000..b07a5fd --- /dev/null +++ b/app/composables/useNoteAttachments.ts @@ -0,0 +1,86 @@ +import type { Attachment } from '#shared/types/notes' + +const attachmentsUrl = (noteId: string): string => `/api/notes/${noteId}/attachments` +const attachmentUrl = (noteId: string, attId: string): string => `/api/notes/${noteId}/attachments/${attId}` + +/** Give files without a usable extension (pasted images are often just "blob") one from their type. */ +function fileWithExt(f: File): [File, string] { + const raw = f.name || 'file' + const dotIdx = raw.lastIndexOf('.') + const ext = dotIdx > -1 ? raw.slice(dotIdx).toLowerCase() : '' + const mimeExt = MIME_TO_EXT[f.type] + if (mimeExt && (!ext || ext === '.bin' || raw === 'blob')) { + const base = ext ? raw.slice(0, dotIdx) : raw + return [f, `${base || 'file'}${mimeExt}`] + } + return [f, raw] +} + +export const useNoteAttachments = () => { + const apiFetch = useNuxtApp().$api + const { updateLoadedNote } = useNotesState() + const { startUpload, endUpload } = useNoteUploadState() + + /** Upload files one request at a time (keeps each under the server's size limit). Failures show a snackbar. */ + const uploadAttachment = async (noteId: string, files: File[]) => { + const uploaded: Attachment[] = [] + startUpload(noteId) + try { + for (const f of files) { + const form = new FormData() + const [file, name] = fileWithExt(f) + form.append('files', file, name) + const result = await withErrorSnackbar('Upload Failed', () => + apiFetch(attachmentsUrl(noteId), { method: 'POST', body: form }) + ) + if (result) uploaded.push(...result) + } + } finally { + endUpload(noteId) + } + updateLoadedNote(noteId, (note) => ({ ...note, attachments: [...note.attachments, ...uploaded] })) + return uploaded + } + + const deleteAttachment = async (noteId: string, attId: string) => { + const ok = await withErrorSnackbar('Failed to Delete Attachment', async () => { + await apiFetch(attachmentUrl(noteId, attId), { method: 'DELETE' }) + return true + }) + if (!ok) return false + updateLoadedNote(noteId, (note) => ({ ...note, attachments: note.attachments.filter((a) => a.id !== attId) })) + return true + } + + /** Resolves to the renamed attachment, or undefined on failure (an error snackbar is shown). */ + const renameAttachment = async (noteId: string, attId: string, filename: string) => { + const attachment = await withErrorSnackbar('Rename Failed', () => + apiFetch(attachmentUrl(noteId, attId), { method: 'PUT', body: { filename } }) + ) + if (!attachment) return undefined + updateLoadedNote(noteId, (note) => ({ + ...note, + attachments: note.attachments.map((a) => (a.id === attId ? attachment : a)), + })) + return attachment + } + + const reorderAttachments = async (noteId: string, ids: string[]) => { + const ok = await withErrorSnackbar('Reorder Failed', async () => { + await apiFetch(`${attachmentsUrl(noteId)}/reorder`, { method: 'PUT', body: { ids } }) + return true + }) + if (!ok) return false + updateLoadedNote(noteId, (note) => { + const byId = new Map(note.attachments.map((a) => [a.id, a])) + const attachments = ids.flatMap((id, position) => { + const att = byId.get(id) + return att ? [{ ...att, position }] : [] + }) + return { ...note, attachments } + }) + return true + } + + return { uploadAttachment, deleteAttachment, renameAttachment, reorderAttachments } +} diff --git a/app/composables/useNoteMutations.ts b/app/composables/useNoteMutations.ts new file mode 100644 index 0000000..2cfab63 --- /dev/null +++ b/app/composables/useNoteMutations.ts @@ -0,0 +1,84 @@ +import type { ChecklistItem, Note, NoteType } from '#shared/types/notes' + +export interface NotePayload { + type?: NoteType + title?: string + content?: string + labelIds?: string[] + checklistItems?: ChecklistItem[] +} + +export const useNoteMutations = () => { + const apiFetch = useNuxtApp().$api + const { notes, updateLoadedNote, removeLoadedNote } = useNotesState() + const { uploadAttachment } = useNoteAttachments() + const { runLocal } = useRealtimeResource({ localEventsKey: 'local-note-events', eventKeys: noteEventKeys }) + const snackbar = useSnackbar() + + /** Create a note, then upload its files in the background. Throws on failure so the editor can stay open. */ + const createNote = async (payload: NotePayload, files: File[] = []) => { + const note = await apiFetch('/api/notes', { method: 'POST', body: payload }) + notes.value.unshift(note) + if (files.length) void uploadAttachment(note.id, files) + return note + } + + /** Throws on failure so the editor can keep the user's changes. */ + const updateNote = async (id: string, payload: NotePayload) => { + const updated = await runLocal(`note.updated:${id}`, () => + apiFetch(noteUrl(id), { method: 'PUT', body: payload }) + ) + const previous = notes.value.find((n) => n.id === id) + const movedOut = previous && (updated.isArchived || updated.isTrashed) && + (previous.isArchived !== updated.isArchived || previous.isTrashed !== updated.isTrashed) + if (movedOut) removeLoadedNote(id) + else updateLoadedNote(id, () => updated) + return updated + } + + /** Change a note's archive/trash status and drop it from the current list. Resolves false on failure. */ + const setNoteStatus = async ( + id: string, + patch: { isArchived?: boolean; isTrashed?: boolean }, + successMessage: string, + errorMessage: string, + ) => { + const ok = await withErrorSnackbar(errorMessage, async () => { + await runLocal(`note.updated:${id}`, () => apiFetch(noteUrl(id), { method: 'PUT', body: patch })) + return true + }) + if (!ok) return false + removeLoadedNote(id) + snackbar.show(successMessage) + return true + } + + const trashNote = (id: string) => setNoteStatus(id, { isTrashed: true }, 'Note Moved to Trash', 'Failed to Move Note to Trash') + const restoreNote = (id: string) => setNoteStatus(id, { isTrashed: false }, 'Note Restored', 'Failed to Restore Note') + const archiveNote = (id: string) => setNoteStatus(id, { isArchived: true }, 'Note Archived', 'Failed to Archive Note') + const unarchiveNote = (id: string) => setNoteStatus(id, { isArchived: false }, 'Note Unarchived', 'Failed to Unarchive Note') + + const deleteNote = async (id: string) => { + const ok = await withErrorSnackbar('Failed to Delete Note', async () => { + await runLocal(`note.deleted:${id}`, () => apiFetch(noteUrl(id), { method: 'DELETE' })) + return true + }) + if (!ok) return false + removeLoadedNote(id) + snackbar.show('Note Permanently Deleted') + return true + } + + const emptyTrash = async () => { + const ok = await withErrorSnackbar('Failed to Empty Trash', async () => { + await apiFetch('/api/notes/trash', { method: 'DELETE' }) + return true + }) + if (!ok) return false + notes.value = notes.value.filter((n) => !n.isTrashed) + snackbar.show('All Trash Permanently Deleted') + return true + } + + return { createNote, updateNote, trashNote, restoreNote, archiveNote, unarchiveNote, deleteNote, emptyTrash } +} diff --git a/app/composables/useNoteUploadState.ts b/app/composables/useNoteUploadState.ts index da92063..7ae19b4 100644 --- a/app/composables/useNoteUploadState.ts +++ b/app/composables/useNoteUploadState.ts @@ -1,8 +1,13 @@ -const _uploading = reactive>({}) +// Number of uploads in flight per note (a note can have several concurrent uploads) +const _uploading = reactive>({}) export const useNoteUploadState = () => { - const startUpload = (noteId: string) => { _uploading[noteId] = true } - const endUpload = (noteId: string) => { delete _uploading[noteId] } + const startUpload = (noteId: string) => { _uploading[noteId] = (_uploading[noteId] ?? 0) + 1 } + const endUpload = (noteId: string) => { + const remaining = (_uploading[noteId] ?? 1) - 1 + if (remaining > 0) _uploading[noteId] = remaining + else delete _uploading[noteId] + } const isUploading = (noteId: string) => !!_uploading[noteId] return { startUpload, endUpload, isUploading } } diff --git a/app/composables/useNotes.ts b/app/composables/useNotes.ts deleted file mode 100644 index 12ebd1f..0000000 --- a/app/composables/useNotes.ts +++ /dev/null @@ -1,312 +0,0 @@ -import type { Attachment, Note } from '#shared/types/notes' - -function fileWithExt(f: File): [File, string] { - const raw = f.name || 'file' - const dotIdx = raw.lastIndexOf('.') - const ext = dotIdx > -1 ? raw.slice(dotIdx).toLowerCase() : '' - // Use MIME-derived extension when filename has none, is generic (.bin), or is just 'blob' - const mimeExt = MIME_TO_EXT[f.type] - if (mimeExt && (!ext || ext === '.bin' || raw === 'blob')) { - const base = ext ? raw.slice(0, dotIdx) : raw - return [f, `${base || 'file'}${mimeExt}`] - } - return [f, raw] -} - -// Typed as plain strings: Nitro's typed routes match `/api/notes/${id}` against static -// siblings such as `/api/notes/search` (GET only) and would reject PUT/DELETE. -const noteUrl = (id: string): string => `/api/notes/${id}` -const attachmentUrl = (noteId: string, attId: string): string => `/api/notes/${noteId}/attachments/${attId}` - -type NotesParams = { archived?: boolean; trashed?: boolean; labelId?: string } -type NotesPage = { notes: Note[]; hasMore: boolean } - -const notesQuery = (params: NotesParams, page: number): Record => { - const query: Record = { page: String(page) } - if (params.archived) query.archived = 'true' - if (params.trashed) query.trashed = 'true' - if (params.labelId) query.labelId = params.labelId - return query -} - -export const useNotes = (options: { realtime?: boolean } = {}) => { - const notes = useState('notes', () => []) - const loading = useState('notes-loading', () => false) - const hasMore = useState('notes-has-more', () => false) - const notesPage = useState('notes-page', () => 1) - const notesParams = useState('notes-params', () => ({})) - - const apiFetch = useNuxtApp().$api - const { syncNote, refreshSearch } = useSearch() - const refreshVersion = useState('notes-refresh-version', () => 0) - const requestVersion = useState('notes-request-version', () => 0) - const { consume: consumeLocalNoteEvent, run: runLocalNoteMutation } = - useLocalRealtimeEvents('local-note-events') - - const fetchNotes = async (params: NotesParams = {}) => { - const version = ++requestVersion.value - loading.value = true - notesParams.value = params - notesPage.value = 1 - try { - const data = await apiFetch('/api/notes', { - query: { ...notesQuery(params, 1), _sync: String(Date.now()) }, - cache: 'no-store', - }) - if (version === requestVersion.value && JSON.stringify(params) === JSON.stringify(notesParams.value)) { - notes.value = data.notes - hasMore.value = data.hasMore - } - } finally { - loading.value = false - } - } - - const fetchMoreNotes = async () => { - if (loading.value || !hasMore.value) return - loading.value = true - try { - const nextPage = notesPage.value + 1 - const data = await apiFetch('/api/notes', { - query: notesQuery(notesParams.value, nextPage), - cache: 'no-store', - }) - notes.value = [...notes.value, ...data.notes] - hasMore.value = data.hasMore - notesPage.value = nextPage - } finally { - loading.value = false - } - } - - const refreshLoadedNotes = async () => { - const version = ++refreshVersion.value - const request = ++requestVersion.value - const params = { ...notesParams.value } - const page = notesPage.value - const pages: Note[] = [] - let nextHasMore = false - - for (let currentPage = 1; currentPage <= page; currentPage++) { - const data = await apiFetch('/api/notes', { - query: { ...notesQuery(params, currentPage), _sync: String(Date.now()) }, - cache: 'no-store', - }) - pages.push(...data.notes) - nextHasMore = data.hasMore - if (!data.hasMore) break - } - - if ( - version === refreshVersion.value && - request === requestVersion.value && - JSON.stringify(params) === JSON.stringify(notesParams.value) - ) { - notes.value = pages - hasMore.value = nextHasMore - await refreshSearch() - } - } - - const refreshNote = async (id: string) => { - const index = notes.value.findIndex((note) => note.id === id) - if (index === -1) return - const note = await apiFetch(noteUrl(id), { cache: 'no-store' }) - const currentIndex = notes.value.findIndex((item) => item.id === id) - if (currentIndex !== -1) { - notes.value[currentIndex] = note - syncNote(note) - } - } - - const logRefreshError = (error: unknown) => console.warn('[realtime] Failed to refresh notes:', error) - - if (options.realtime) { - const { subscribe } = useEventSource() - const unsubscribe = subscribe((event) => { - const labelsChanged = event.type === 'label.updated' || event.type === 'label.deleted' - const isLabelEvent = event.type.startsWith('label.') - if (isLabelEvent && !labelsChanged) return - const data = event.data as { - note?: { id?: string } - noteId?: string - refresh?: 'note' | 'list' - } | undefined - const noteId = data?.note?.id ?? data?.noteId - const eventKey = noteId ? `${event.type}:${noteId}` : null - if (eventKey && consumeLocalNoteEvent(eventKey)) return - - if (event.type.startsWith('attachment.') && noteId) { - refreshNote(noteId).catch(logRefreshError) - return - } - - if (event.type === 'note.updated' && noteId && data?.refresh !== 'list') { - refreshNote(noteId).catch(logRefreshError) - return - } - - if (event.resource === 'notes' || event.resource === 'all' || labelsChanged) { - refreshLoadedNotes().catch(logRefreshError) - } - }) - onScopeDispose(unsubscribe) - } - - const createNote = async (payload: Partial & { labelIds?: string[] }) => { - const note = await apiFetch('/api/notes', { method: 'POST', body: payload }) - notes.value.unshift(note) - return note - } - - const updateNote = async (id: string, payload: Partial & { labelIds?: string[] }) => { - const updated = await runLocalNoteMutation(`note.updated:${id}`, () => - apiFetch(noteUrl(id), { method: 'PUT', body: payload }) - ) - const idx = notes.value.findIndex((n) => n.id === id) - if (idx !== -1) { - const prev = notes.value[idx]! - const statusChanged = prev.isArchived !== updated.isArchived || prev.isTrashed !== updated.isTrashed - if (statusChanged && (updated.isArchived || updated.isTrashed)) { - notes.value.splice(idx, 1) - } else { - notes.value[idx] = updated - } - } - syncNote(updated) - return updated - } - - /** Change a note's archive/trash status and drop it from the current list. Resolves false on failure. */ - const setNoteStatus = async ( - id: string, - patch: { isArchived?: boolean; isTrashed?: boolean }, - successMessage: string, - errorMessage: string, - ) => { - const ok = await withErrorSnackbar(errorMessage, async () => { - await runLocalNoteMutation(`note.updated:${id}`, () => apiFetch(noteUrl(id), { method: 'PUT', body: patch })) - return true - }) - if (!ok) return false - notes.value = notes.value.filter((n) => n.id !== id) - useSnackbar().show(successMessage) - return true - } - - const trashNote = (id: string) => setNoteStatus(id, { isTrashed: true }, 'Note Moved to Trash', 'Failed to Move Note to Trash') - const restoreNote = (id: string) => setNoteStatus(id, { isTrashed: false }, 'Note Restored', 'Failed to Restore Note') - const archiveNote = (id: string) => setNoteStatus(id, { isArchived: true }, 'Note Archived', 'Failed to Archive Note') - const unarchiveNote = (id: string) => setNoteStatus(id, { isArchived: false }, 'Note Unarchived', 'Failed to Unarchive Note') - - const deleteNote = async (id: string) => { - const ok = await withErrorSnackbar('Failed to Delete Note', async () => { - await apiFetch(noteUrl(id), { method: 'DELETE' }) - return true - }) - if (!ok) return false - notes.value = notes.value.filter((n) => n.id !== id) - useSnackbar().show('Note Permanently Deleted') - return true - } - - // One request per file keeps each request under the server's upload size limit - const uploadAttachment = async (noteId: string, files: File[]) => { - const uploaded: Attachment[] = [] - for (const f of files) { - const form = new FormData() - const [file, name] = fileWithExt(f) - form.append('files', file, name) - const result = await withErrorSnackbar('Upload Failed', () => - apiFetch(`/api/notes/${noteId}/attachments`, { method: 'POST', body: form }) - ) - if (result) uploaded.push(...result) - } - const idx = notes.value.findIndex((n) => n.id === noteId) - if (idx !== -1) { - notes.value[idx]!.attachments.push(...uploaded) - } - return uploaded - } - - const deleteAttachment = async (noteId: string, attId: string) => { - const ok = await withErrorSnackbar('Failed to Delete Attachment', async () => { - await apiFetch(attachmentUrl(noteId, attId), { method: 'DELETE' }) - return true - }) - if (!ok) return false - const idx = notes.value.findIndex((n) => n.id === noteId) - if (idx !== -1) { - notes.value[idx]!.attachments = notes.value[idx]!.attachments.filter((a) => a.id !== attId) - } - return true - } - - /** Resolves to the renamed attachment, or undefined on failure (an error snackbar is shown). */ - const renameAttachment = async (noteId: string, attId: string, filename: string) => { - const attachment = await withErrorSnackbar('Rename Failed', () => - apiFetch(attachmentUrl(noteId, attId), { method: 'PUT', body: { filename } }) - ) - if (!attachment) return undefined - const idx = notes.value.findIndex((n) => n.id === noteId) - if (idx !== -1) { - const note = notes.value[idx]! - note.attachments = note.attachments.map((a) => (a.id === attId ? attachment : a)) - syncNote(note) - } - return attachment - } - - const reorderAttachments = async (noteId: string, ids: string[]) => { - const ok = await withErrorSnackbar('Reorder Failed', async () => { - await apiFetch(`/api/notes/${noteId}/attachments/reorder`, { method: 'PUT', body: { ids } }) - return true - }) - if (!ok) return false - const idx = notes.value.findIndex((n) => n.id === noteId) - if (idx !== -1) { - const note = notes.value[idx]! - const byId = new Map(note.attachments.map((a) => [a.id, a])) - note.attachments = ids - .map((id, position) => { - const att = byId.get(id) - return att ? { ...att, position } : null - }) - .filter((a): a is NonNullable => Boolean(a)) - syncNote(note) - } - return true - } - - const emptyTrash = async () => { - const ok = await withErrorSnackbar('Failed to Empty Trash', async () => { - await apiFetch('/api/notes/trash', { method: 'DELETE' }) - return true - }) - if (!ok) return false - notes.value = notes.value.filter((n) => !n.isTrashed) - useSnackbar().show('All Trash Permanently Deleted') - return true - } - - return { - notes: readonly(notes), - loading: readonly(loading), - hasMore: readonly(hasMore), - fetchNotes, - fetchMoreNotes, - refreshLoadedNotes, - createNote, - updateNote, - deleteNote, - trashNote, - restoreNote, - archiveNote, - unarchiveNote, - uploadAttachment, - deleteAttachment, - renameAttachment, - reorderAttachments, - emptyTrash, - } -} diff --git a/app/composables/useNotesList.ts b/app/composables/useNotesList.ts new file mode 100644 index 0000000..7b6dcf3 --- /dev/null +++ b/app/composables/useNotesList.ts @@ -0,0 +1,155 @@ +import type { Note } from '#shared/types/notes' + +export type NotesParams = { archived?: boolean; trashed?: boolean; labelId?: string } +type NotesPage = { notes: Note[]; hasMore: boolean } + +// Typed as plain strings: Nitro's typed routes match `/api/notes/${id}` against static +// siblings such as `/api/notes/search` (GET only) and would reject PUT/DELETE. +export const noteUrl = (id: string): string => `/api/notes/${id}` + +const notesQuery = (params: NotesParams, page: number): Record => { + const query: Record = { page: String(page) } + if (params.archived) query.archived = 'true' + if (params.trashed) query.trashed = 'true' + if (params.labelId) query.labelId = params.labelId + return query +} + +/** List state shared by the list, note mutations and attachment mutations. */ +export const useNotesState = () => { + const notes = useState('notes', () => []) + const { syncNote } = useSearch() + + /** Apply `update` to a loaded note and mirror the result into search results. */ + const updateLoadedNote = (id: string, update: (note: Note) => Note) => { + const index = notes.value.findIndex((n) => n.id === id) + if (index === -1) return + const next = update(notes.value[index]!) + notes.value[index] = next + syncNote(next) + } + + const removeLoadedNote = (id: string) => { + notes.value = notes.value.filter((n) => n.id !== id) + } + + return { + notes, + loading: useState('notes-loading', () => false), + hasMore: useState('notes-has-more', () => false), + page: useState('notes-page', () => 1), + params: useState('notes-params', () => ({})), + updateLoadedNote, + removeLoadedNote, + } +} + +export const useNotesList = (options: { realtime?: boolean } = {}) => { + const { notes, loading, hasMore, page, params, updateLoadedNote } = useNotesState() + const apiFetch = useNuxtApp().$api + const { refreshSearch } = useSearch() + const refreshVersion = useState('notes-refresh-version', () => 0) + const requestVersion = useState('notes-request-version', () => 0) + + const fetchPage = (pageParams: NotesParams, pageNumber: number, bypassCache = false) => + apiFetch('/api/notes', { + query: bypassCache ? { ...notesQuery(pageParams, pageNumber), _sync: String(Date.now()) } : notesQuery(pageParams, pageNumber), + cache: 'no-store', + }) + + const fetchNotes = async (nextParams: NotesParams = {}) => { + const version = ++requestVersion.value + loading.value = true + params.value = nextParams + page.value = 1 + try { + const data = await fetchPage(nextParams, 1, true) + if (version === requestVersion.value && JSON.stringify(nextParams) === JSON.stringify(params.value)) { + notes.value = data.notes + hasMore.value = data.hasMore + } + } finally { + loading.value = false + } + } + + const fetchMoreNotes = async () => { + if (loading.value || !hasMore.value) return + loading.value = true + try { + const nextPage = page.value + 1 + const data = await fetchPage(params.value, nextPage) + notes.value = [...notes.value, ...data.notes] + hasMore.value = data.hasMore + page.value = nextPage + } finally { + loading.value = false + } + } + + /** Re-fetch every page loaded so far, e.g. after another device changed the list. */ + const refreshLoadedNotes = async () => { + const version = ++refreshVersion.value + const request = ++requestVersion.value + const currentParams = { ...params.value } + const loaded: Note[] = [] + let nextHasMore = false + + for (let currentPage = 1; currentPage <= page.value; currentPage++) { + const data = await fetchPage(currentParams, currentPage, true) + loaded.push(...data.notes) + nextHasMore = data.hasMore + if (!data.hasMore) break + } + + if ( + version === refreshVersion.value && + request === requestVersion.value && + JSON.stringify(currentParams) === JSON.stringify(params.value) + ) { + notes.value = loaded + hasMore.value = nextHasMore + await refreshSearch() + } + } + + const refreshNote = async (id: string) => { + if (!notes.value.some((note) => note.id === id)) return + const note = await apiFetch(noteUrl(id), { cache: 'no-store' }) + updateLoadedNote(id, () => note) + } + + if (options.realtime) { + const logRefreshError = (error: unknown) => console.warn('[realtime] Failed to refresh notes:', error) + + useRealtimeResource({ + localEventsKey: 'local-note-events', + eventKeys: noteEventKeys, + onEvent: (event) => { + // Label renames and deletions change the labels shown on notes + if (event.type === 'label.updated' || event.type === 'label.deleted') { + refreshLoadedNotes().catch(logRefreshError) + return + } + if (event.data.resource === 'labels') return + + const noteId = 'noteId' in event.data ? event.data.noteId : 'note' in event.data ? event.data.note.id : undefined + if (event.type.startsWith('attachment.') && noteId) { + refreshNote(noteId).catch(logRefreshError) + } else if (event.type === 'note.updated' && event.data.refresh !== 'list') { + refreshNote(event.data.note.id).catch(logRefreshError) + } else { + refreshLoadedNotes().catch(logRefreshError) + } + }, + }) + } + + return { + notes: readonly(notes), + loading: readonly(loading), + hasMore: readonly(hasMore), + fetchNotes, + fetchMoreNotes, + } +} diff --git a/app/composables/useNotesPage.ts b/app/composables/useNotesPage.ts deleted file mode 100644 index 2cb4485..0000000 --- a/app/composables/useNotesPage.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { DeepReadonly } from 'vue' -import type { Note } from '#shared/types/notes' - -/** - * Shared setup for all note list pages. - * Consolidates search state, editingNote, displayedNotes, infinite scroll sentinel, - * and auto-clears search on unmount. - */ -export function useNotesPage( - notes: Readonly>>, - hasMore: Readonly>, - fetchMoreNotes: () => Promise, -) { - const { searchResults, searchQuery, hasMore: searchHasMore, fetchMore: fetchMoreSearch, clearSearch } = useSearch() - const editingNote = ref(null) - // useNotes/useSearch expose readonly views of their state; list components never mutate their props - const displayedNotes = computed(() => (searchResults.value ?? notes.value) as Note[]) - const { sentinel } = useInfiniteScroll(() => { - if (searchQuery.value.trim()) { - if (searchHasMore.value) fetchMoreSearch() - return - } - if (hasMore.value) fetchMoreNotes() - }) - - onUnmounted(() => clearSearch()) - - return { searchResults, editingNote, displayedNotes, sentinel } -} diff --git a/app/composables/useRealtimeResource.ts b/app/composables/useRealtimeResource.ts new file mode 100644 index 0000000..6c316d3 --- /dev/null +++ b/app/composables/useRealtimeResource.ts @@ -0,0 +1,32 @@ +import type { ClientRealtimeEvent } from './useEventSource' + +/** + * Subscribe to realtime events while skipping the echoes of this client's own mutations. + * Wrap a mutation in the returned `runLocal(key, request)` and the event with that key is ignored. + */ +export function useRealtimeResource(options: { + /** useState key holding this resource's pending local event keys */ + localEventsKey: string + /** Keys under which an event may have been marked as local, tried in order */ + eventKeys: (event: ClientRealtimeEvent) => string[] + onEvent?: (event: ClientRealtimeEvent) => void +}) { + const { consume, run } = useLocalRealtimeEvents(options.localEventsKey) + + if (options.onEvent) { + const onEvent = options.onEvent + const unsubscribe = useEventSource().subscribe((event) => { + if (options.eventKeys(event).some(consume)) return + onEvent(event) + }) + onScopeDispose(unsubscribe) + } + + return { runLocal: run } +} + +export const noteEventKeys = (event: ClientRealtimeEvent): string[] => { + const data = event.data as { note?: { id: string }; noteId?: string } + const noteId = data.note?.id ?? data.noteId + return noteId ? [`${event.type}:${noteId}`] : [] +} From 83476c5923e2ba3f1817c3d876fdfa0eb7421682 Mon Sep 17 00:00:00 2001 From: Irfan Fadilah Date: Thu, 15 Oct 2026 10:59:29 +0700 Subject: [PATCH 3/9] Add Note List View --- app/components/note/ListView.vue | 145 +++++++++++++++++++++++++++++++ app/pages/archive.vue | 40 +-------- app/pages/index.vue | 40 +-------- app/pages/label/[id].vue | 34 +------- app/pages/trash.vue | 76 +--------------- 5 files changed, 149 insertions(+), 186 deletions(-) create mode 100644 app/components/note/ListView.vue diff --git a/app/components/note/ListView.vue b/app/components/note/ListView.vue new file mode 100644 index 0000000..8482574 --- /dev/null +++ b/app/components/note/ListView.vue @@ -0,0 +1,145 @@ + + + diff --git a/app/pages/archive.vue b/app/pages/archive.vue index 21a1217..8d5f44f 100644 --- a/app/pages/archive.vue +++ b/app/pages/archive.vue @@ -1,45 +1,7 @@ diff --git a/app/pages/index.vue b/app/pages/index.vue index 1c2fc60..fc42eb7 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -1,45 +1,7 @@ diff --git a/app/pages/label/[id].vue b/app/pages/label/[id].vue index e49d7ce..a8e78b0 100644 --- a/app/pages/label/[id].vue +++ b/app/pages/label/[id].vue @@ -1,42 +1,10 @@ diff --git a/app/pages/trash.vue b/app/pages/trash.vue index e687fc0..58d5023 100644 --- a/app/pages/trash.vue +++ b/app/pages/trash.vue @@ -1,81 +1,7 @@ From 759aa0cd4b5b042b90ed5921bfdc4a2404cd6794 Mon Sep 17 00:00:00 2001 From: Irfan Fadilah Date: Thu, 15 Oct 2026 10:59:29 +0700 Subject: [PATCH 4/9] Split Note Card --- app/components/note/Card.vue | 226 ++++++----------------- app/components/note/ChecklistPreview.vue | 41 ++++ app/components/note/Grid.vue | 32 ++-- app/components/note/StatusActions.vue | 51 +++++ 4 files changed, 160 insertions(+), 190 deletions(-) create mode 100644 app/components/note/ChecklistPreview.vue create mode 100644 app/components/note/StatusActions.vue diff --git a/app/components/note/Card.vue b/app/components/note/Card.vue index 9571c4b..d6456e2 100644 --- a/app/components/note/Card.vue +++ b/app/components/note/Card.vue @@ -1,7 +1,11 @@ @@ -199,8 +107,8 @@ import type { Note } from '#shared/types/notes' const props = defineProps<{ note: Note - isTrashed?: boolean - isArchived?: boolean + /** Archived and trashed notes show restore/delete actions instead of the toolbar */ + status?: 'archived' | 'trashed' }>() defineEmits<{ @@ -210,60 +118,40 @@ defineEmits<{ trash: [note: Note] restore: [note: Note] delete: [note: Note] - label: [note: Note] }>() -const { deleteAttachment, uploadAttachment, updateNote } = useNotes() +const CHECKLIST_PREVIEW_LIMIT = 5 +const FILE_PREVIEW_LIMIT = 5 + +const { deleteAttachment, uploadAttachment } = useNoteAttachments() +const { updateNote } = useNoteMutations() const { validate: validateFiles } = useFileValidation() +const { isUploading } = useNoteUploadState() const deleteAtt = (attId: string) => deleteAttachment(props.note.id, attId) const { images: imageAttachments, files: fileAttachments } = splitAttachments(() => props.note.attachments) -const visibleChecklistItems = computed(() => props.note.checklistItems.slice(0, 5)) - -const FILE_LIMIT = 5 -const visibleFileAttachments = computed(() => fileAttachments.value.slice(0, FILE_LIMIT)) -const hiddenFileCount = computed(() => Math.max(0, fileAttachments.value.length - FILE_LIMIT)) +const visibleFileAttachments = computed(() => fileAttachments.value.slice(0, FILE_PREVIEW_LIMIT)) +const hiddenFileCount = computed(() => Math.max(0, fileAttachments.value.length - FILE_PREVIEW_LIMIT)) +const uploading = computed(() => isUploading(props.note.id)) -const contextMenu = ref() +const contextMenu = ref<{ open: (event: MouseEvent) => void }>() const fileInputRef = ref() const showLabelPicker = ref(false) -const confirmDelete = ref(false) - -const { startUpload, endUpload, isUploading } = useNoteUploadState() -const uploading = computed(() => isUploading(props.note.id)) - -useEscapeKey(() => { - if (confirmDelete.value) confirmDelete.value = false -}) const onContextMenu = (e: MouseEvent) => { - if (props.isTrashed || props.isArchived) return - contextMenu.value?.open(e) + if (!props.status) contextMenu.value?.open(e) } const onFilesSelected = async (e: Event) => { const input = e.target as HTMLInputElement const files = validateFiles(Array.from(input.files || [])) input.value = '' - if (!files.length) return - startUpload(props.note.id) - try { - await uploadAttachment(props.note.id, files) - } finally { - endUpload(props.note.id) - } + if (files.length) await uploadAttachment(props.note.id, files) } const onLabelUpdate = async (ids: string[]) => { - await updateNote(props.note.id, { labelIds: ids }) + await updateNote(props.note.id, { labelIds: ids }).catch((e) => { + useSnackbar().show(getErrorMessage(e, 'Failed to Update Labels'), 'error') + }) } - - diff --git a/app/components/note/ChecklistPreview.vue b/app/components/note/ChecklistPreview.vue new file mode 100644 index 0000000..e86e8b9 --- /dev/null +++ b/app/components/note/ChecklistPreview.vue @@ -0,0 +1,41 @@ + + + diff --git a/app/components/note/Grid.vue b/app/components/note/Grid.vue index 24311d2..ea32081 100644 --- a/app/components/note/Grid.vue +++ b/app/components/note/Grid.vue @@ -9,12 +9,12 @@ @@ -31,7 +31,7 @@ -
+

{{ emptyMessage }}

@@ -41,8 +41,7 @@ import type { Note } from '#shared/types/notes' const props = defineProps<{ notes: Note[] - isTrashed?: boolean - isArchived?: boolean + status?: 'archived' | 'trashed' emptyMessage?: string }>() @@ -53,26 +52,18 @@ defineEmits<{ trash: [note: Note] restore: [note: Note] delete: [note: Note] - label: [note: Note] }>() -const cardProps = (note: Note) => ({ - note, - isTrashed: props.isTrashed, - isArchived: props.isArchived, -}) - const gridRef = ref(null) const colCount = ref(1) let ro: ResizeObserver | null = null -const getColCount = (width: number) => { - if (width < 480) return 1 - if (width < 768) return 2 - if (width < 1024) return 3 - if (width < 1280) return 4 - return 5 -} +// Grid width thresholds (px) for 2, 3, 4 and 5 columns +const COLUMN_BREAKPOINTS = [480, 768, 1024, 1280] +// The sidebar animates its width over 300ms; measure again once it settles +const SIDEBAR_TRANSITION_MS = 320 + +const getColCount = (width: number) => 1 + COLUMN_BREAKPOINTS.filter((min) => width >= min).length const bindObserver = (el: HTMLElement | null) => { if (!el) return @@ -96,12 +87,11 @@ onUnmounted(() => { if (ro) ro.disconnect() }) -// Recalculate after sidebar transition (300ms) since padding change resizes the grid const { sidebarOpen } = useSidebar() watch(sidebarOpen, () => { setTimeout(() => { if (gridRef.value) colCount.value = getColCount(gridRef.value.offsetWidth) - }, 320) + }, SIDEBAR_TRANSITION_MS) }) const columns = computed(() => { diff --git a/app/components/note/StatusActions.vue b/app/components/note/StatusActions.vue new file mode 100644 index 0000000..4f57d38 --- /dev/null +++ b/app/components/note/StatusActions.vue @@ -0,0 +1,51 @@ + + + From 372f0b04d798fc21c5e76542c5e9a6b465ccac44 Mon Sep 17 00:00:00 2001 From: Irfan Fadilah Date: Thu, 15 Oct 2026 10:59:29 +0700 Subject: [PATCH 5/9] Share Note Editor Draft --- app/components/note/Checklist.vue | 125 +++++---- app/components/note/CreateBar.vue | 75 ++---- app/components/note/EditorContent.vue | 197 ++++++-------- app/components/note/EditorPendingImages.vue | 66 ++--- app/components/note/NoteModal.vue | 277 +++++--------------- app/components/note/ReadonlyView.vue | 77 ++++++ app/components/note/Toolbar.vue | 116 ++++---- app/composables/useEditorFileUpload.ts | 85 ++---- app/composables/useNoteDraft.ts | 56 ++++ app/types/editor.ts | 21 -- 10 files changed, 462 insertions(+), 633 deletions(-) create mode 100644 app/components/note/ReadonlyView.vue create mode 100644 app/composables/useNoteDraft.ts delete mode 100644 app/types/editor.ts diff --git a/app/components/note/Checklist.vue b/app/components/note/Checklist.vue index 02b5eee..2599d15 100644 --- a/app/components/note/Checklist.vue +++ b/app/components/note/Checklist.vue @@ -1,73 +1,74 @@ @@ -78,7 +79,7 @@ import type { ChecklistItem } from '#shared/types/notes' import { nanoid } from 'nanoid' const props = defineProps<{ modelValue: ChecklistItem[] }>() -const emit = defineEmits<{ 'update:modelValue': [items: ChecklistItem[]]; update: [] }>() +const emit = defineEmits<{ 'update:modelValue': [items: ChecklistItem[]] }>() const inputRefs = ref<(HTMLInputElement | null)[]>([]) const setInputRef = (el: Element | ComponentPublicInstance | null, idx: number) => { @@ -88,7 +89,6 @@ const setInputRef = (el: Element | ComponentPublicInstance | null, idx: number) const addItem = async () => { const items = [...props.modelValue, { id: nanoid(), text: '', isChecked: false, position: props.modelValue.length }] emit('update:modelValue', items) - emit('update') await nextTick() inputRefs.value[items.length - 1]?.focus() } @@ -97,26 +97,22 @@ const addAfter = async (idx: number) => { const items = [...props.modelValue] items.splice(idx + 1, 0, { id: nanoid(), text: '', isChecked: false, position: idx + 1 }) emit('update:modelValue', items.map((i, p) => ({ ...i, position: p }))) - emit('update') await nextTick() inputRefs.value[idx + 1]?.focus() } const removeItem = (idx: number) => { emit('update:modelValue', props.modelValue.filter((_, i) => i !== idx)) - emit('update') } const setText = (idx: number, text: string) => { emit('update:modelValue', props.modelValue.map((item, i) => (i === idx ? { ...item, text } : item))) - emit('update') } const toggle = (idx: number) => { emit('update:modelValue', props.modelValue.map((item, i) => i === idx ? { ...item, isChecked: !item.isChecked } : item )) - emit('update') } const onBackspace = (idx: number, item: ChecklistItem, e: KeyboardEvent) => { @@ -131,7 +127,6 @@ const { dragOverIdx, onDragStart, onDragOver, onDrop, onDragEnd, startTouchDrag getItems: () => props.modelValue, onReorder: (items) => { emit('update:modelValue', items.map((i, p) => ({ ...i, position: p }))) - emit('update') }, }) diff --git a/app/components/note/CreateBar.vue b/app/components/note/CreateBar.vue index e36ecbf..a121a53 100644 --- a/app/components/note/CreateBar.vue +++ b/app/components/note/CreateBar.vue @@ -1,75 +1,44 @@ diff --git a/app/components/note/EditorContent.vue b/app/components/note/EditorContent.vue index 589b08b..14b2f0e 100644 --- a/app/components/note/EditorContent.vue +++ b/app/components/note/EditorContent.vue @@ -13,46 +13,42 @@
- + - -