refactor(collab): share the notes list and attachment upload between desktop and phone

useCollabNotesData holds the note list with its first load and the live
note events, and the attachment upload both shells run after saving a
note. Writing notes, their colours and categories stays in each shell,
since the desktop panel keeps empty categories and colour batches the
phone tab does not have. A failed load still empties the desktop list only.
This commit is contained in:
Maurice
2026-10-09 10:53:41 +02:00
parent 428b4e6006
commit dd7cfca0b2
5 changed files with 284 additions and 113 deletions
@@ -0,0 +1,95 @@
import { useCallback, useEffect, useState } from 'react';
import { collabApi } from '../../api/client';
import { addListener, removeListener } from '../../api/websocket';
import type { useTranslation } from '../../i18n';
import type { useToast } from '../shared/Toast';
import type { CollabNoteData } from './collabModel';
export interface CollabNotesDataOptions {
tripId: number;
t: ReturnType<typeof useTranslation>['t'];
toast: ReturnType<typeof useToast>;
/** The desktop panel outlives a trip change, so a failed load there empties the list. */
resetOnLoadError?: boolean;
}
/**
* The trip notes behind the desktop Collab panel and the phone's notes tab, each reading
* the rows through its own note type: the list as the server has it, kept current by the
* WebSocket note events, and the attachment upload both run after a note is saved. Writing
* a note, its colours and its categories stays with each shell, which differ there. Collab
* has no store slice, so the notes live here.
*/
export function useCollabNotesData<N extends { id: number } = CollabNoteData>({
tripId,
t,
toast,
resetOnLoadError = false,
}: CollabNotesDataOptions) {
const [notes, setNotes] = useState<N[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!tripId) return;
let cancelled = false;
setLoading(true);
collabApi
.getNotes(tripId)
.then((data: N[] | { notes?: N[] } | null) => {
if (!cancelled) setNotes(Array.isArray(data) ? data : data?.notes || []);
})
.catch(() => {
if (!cancelled && resetOnLoadError) setNotes([]);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [tripId, resetOnLoadError]);
useEffect(() => {
if (!tripId) return;
const handler = (msg: Record<string, unknown>) => {
// An event still in flight from a trip just left must not land in this list.
if (String(msg?.tripId) !== String(tripId)) return;
const note = msg.note as N | undefined;
if (msg.type === 'collab:note:created' && note) {
setNotes((prev) => (prev.some((n) => n.id === note.id) ? prev : [note, ...prev]));
}
if (msg.type === 'collab:note:updated' && note) {
setNotes((prev) => prev.map((n) => (n.id === note.id ? { ...n, ...note } : n)));
}
if (msg.type === 'collab:note:deleted') {
const deletedId = (msg.noteId || msg.id) as number | undefined;
if (deletedId) setNotes((prev) => prev.filter((n) => n.id !== deletedId));
}
};
addListener(handler);
return () => removeListener(handler);
}, [tripId]);
/**
* Uploads a saved note's attachments one by one; a file the server refuses is toasted
* and the rest still go. `onFileError` sees each refusal before the toast.
*/
const uploadNoteFiles = useCallback(
async (noteId: number, files: File[], onFileError?: (err: unknown) => void) => {
for (const file of files) {
const fd = new FormData();
fd.append('file', file);
try {
await collabApi.uploadNoteFile(tripId, noteId, fd);
} catch (err) {
onFileError?.(err);
toast.error(t('common.error'));
}
}
},
[tripId, toast, t]
);
return { notes, setNotes, loading, uploadNoteFiles };
}