refactor(files): share the file trash, paste and link rules between desktop and phone

useFileTrash holds the trashed files with their restore, permanent delete
and empty actions; the desktop trash view loads it on demand and the phone
sheet while it is open. fileActions holds the clipboard file picking and the
two tier link toggle (own column first, then link records) that the desktop
assign dialog and the phone link sheet both ran on their own.
This commit is contained in:
Maurice
2026-10-09 10:16:13 +02:00
parent 000c9dfa30
commit 076ed897e7
9 changed files with 500 additions and 194 deletions
@@ -1,10 +1,10 @@
import { useId } from 'react'
import { MapPin, Ticket, Check, Paperclip } from 'lucide-react'
import { filesApi } from '../../api/client'
import type { Place, Reservation, Day } from '../../types'
import type { Place, Reservation, Day, TripFile } from '../../types'
import type { FileManagerState } from './useFileManager'
import { TRANSPORT_TYPES } from './FileManager.constants'
import { transportIcon } from './FileManager.helpers'
import { planFileLinkToggle, runFileLinkRecordStep, type FileLinkField } from './fileActions'
import { DialogHeader, DialogShell, DialogTile, NEUTRAL_TINT } from '../shared/DialogShell'
import { EditorField, INPUT } from '../shared/dialogParts'
@@ -12,6 +12,20 @@ export function AssignModal(S: FileManagerState) {
const { files, assignFileId, setAssignFileId, t, days, assignments, places, reservations, tripId, handleAssign, refreshFiles } = S
const labelId = useId()
const close = () => setAssignFileId(null)
// The file's own column goes through handleAssign, which toasts a failure.
const toggleLink = async (file: TripFile, field: FileLinkField, targetId: number) => {
const step = planFileLinkToggle(file, field, targetId)
if (step.kind === 'update') {
await handleAssign(file.id, step.data)
return
}
try {
await runFileLinkRecordStep(tripId, file.id, field, targetId, step.kind)
refreshFiles()
} catch {
// A failed link record change stays quiet.
}
}
return (
<DialogShell
onClose={close}
@@ -61,29 +75,7 @@ export function AssignModal(S: FileManagerState) {
const placeBtn = (p: Place, idx: number) => {
const isLinked = file.place_id === p.id || (file.linked_place_ids || []).includes(p.id)
return (
<button type="button" key={`${p.id}-${idx}`} onClick={async () => {
if (isLinked) {
if (file.place_id === p.id) {
await handleAssign(file.id, { place_id: null })
} else {
try {
const linksRes = await filesApi.getLinks(tripId, file.id)
const link = (linksRes.links || []).find((l: any) => l.place_id === p.id)
if (link) await filesApi.removeLink(tripId, file.id, link.id)
refreshFiles()
} catch {}
}
} else {
if (!file.place_id) {
await handleAssign(file.id, { place_id: p.id })
} else {
try {
await filesApi.addLink(tripId, file.id, { place_id: p.id })
refreshFiles()
} catch {}
}
}
}} style={{
<button type="button" key={`${p.id}-${idx}`} onClick={() => toggleLink(file, 'place_id', p.id)} style={{
width: '100%', textAlign: 'start', paddingBlock: 6, paddingInlineEnd: 10, paddingInlineStart: 20, background: isLinked ? 'var(--bg-hover)' : 'none',
border: 'none', cursor: 'pointer', fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: 'var(--text-primary)',
borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400,
@@ -136,29 +128,7 @@ export function AssignModal(S: FileManagerState) {
const isLinked = file.reservation_id === r.id || (file.linked_reservation_ids || []).includes(r.id)
const Icon = TRANSPORT_TYPES.has(r.type) ? transportIcon(r.type) : Ticket
return (
<button type="button" key={r.id} onClick={async () => {
if (isLinked) {
if (file.reservation_id === r.id) {
await handleAssign(file.id, { reservation_id: null })
} else {
try {
const linksRes = await filesApi.getLinks(tripId, file.id)
const link = (linksRes.links || []).find((l: any) => l.reservation_id === r.id)
if (link) await filesApi.removeLink(tripId, file.id, link.id)
refreshFiles()
} catch {}
}
} else {
if (!file.reservation_id) {
await handleAssign(file.id, { reservation_id: r.id })
} else {
try {
await filesApi.addLink(tripId, file.id, { reservation_id: r.id })
refreshFiles()
} catch {}
}
}
}} style={{
<button type="button" key={r.id} onClick={() => toggleLink(file, 'reservation_id', r.id)} style={{
width: '100%', textAlign: 'start', paddingBlock: 6, paddingInlineEnd: 10, paddingInlineStart: 20, background: isLinked ? 'var(--bg-hover)' : 'none',
border: 'none', cursor: 'pointer', fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: 'var(--text-primary)',
borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400,
@@ -0,0 +1,110 @@
// FE-FILES-ACTIONS-001 to FE-FILES-ACTIONS-010: the paste and link rules the desktop file
// manager and the phone's files tab and link sheet share.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { buildTripFile } from '../../../tests/helpers/factories';
import { filesApi } from '../../api/client';
import { filesFromClipboard, planFileLinkToggle, runFileLinkRecordStep, toggleFileLink } from './fileActions';
function clipboard(items: { kind: string; file: File | null }[]): DataTransfer {
return { items: items.map((i) => ({ kind: i.kind, getAsFile: () => i.file })) } as unknown as DataTransfer;
}
describe('filesFromClipboard', () => {
it('FE-FILES-ACTIONS-001: keeps the file items in order and skips text and empty ones', () => {
const a = new File(['a'], 'a.png');
const b = new File(['b'], 'b.pdf');
const data = clipboard([
{ kind: 'file', file: a },
{ kind: 'string', file: null },
{ kind: 'file', file: null },
{ kind: 'file', file: b },
]);
expect(filesFromClipboard(data)).toEqual([a, b]);
});
it('FE-FILES-ACTIONS-002: no clipboard data or no items means no files', () => {
expect(filesFromClipboard(null)).toEqual([]);
expect(filesFromClipboard(undefined)).toEqual([]);
expect(filesFromClipboard({} as DataTransfer)).toEqual([]);
});
});
describe('planFileLinkToggle', () => {
it('FE-FILES-ACTIONS-003: the first link goes into the free column', () => {
const file = buildTripFile({ place_id: null, reservation_id: null });
expect(planFileLinkToggle(file, 'place_id', 5)).toEqual({ kind: 'update', data: { place_id: 5 } });
expect(planFileLinkToggle(file, 'reservation_id', 7)).toEqual({ kind: 'update', data: { reservation_id: 7 } });
});
it('FE-FILES-ACTIONS-004: a further link becomes a link record', () => {
const file = buildTripFile({ place_id: 1, reservation_id: 2 });
expect(planFileLinkToggle(file, 'place_id', 5)).toEqual({ kind: 'addLink' });
expect(planFileLinkToggle(file, 'reservation_id', 7)).toEqual({ kind: 'addLink' });
});
it('FE-FILES-ACTIONS-005: unlinking the column target clears the column', () => {
const file = buildTripFile({ place_id: 5, reservation_id: 7 });
expect(planFileLinkToggle(file, 'place_id', 5)).toEqual({ kind: 'update', data: { place_id: null } });
expect(planFileLinkToggle(file, 'reservation_id', 7)).toEqual({ kind: 'update', data: { reservation_id: null } });
});
it('FE-FILES-ACTIONS-006: unlinking a target linked by record removes the record', () => {
const file = buildTripFile({ place_id: 1, linked_place_ids: [5], reservation_id: 2, linked_reservation_ids: [7] });
expect(planFileLinkToggle(file, 'place_id', 5)).toEqual({ kind: 'removeLink' });
expect(planFileLinkToggle(file, 'reservation_id', 7)).toEqual({ kind: 'removeLink' });
});
});
describe('running a link toggle', () => {
beforeEach(() => {
vi.spyOn(filesApi, 'update').mockResolvedValue({});
vi.spyOn(filesApi, 'addLink').mockResolvedValue({});
vi.spyOn(filesApi, 'removeLink').mockResolvedValue({});
vi.spyOn(filesApi, 'getLinks').mockResolvedValue({
links: [
{ id: 70, place_id: null, reservation_id: 7 },
{ id: 50, place_id: '5', reservation_id: null },
],
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it('FE-FILES-ACTIONS-007: adding a record posts the target in its own field', async () => {
await runFileLinkRecordStep(1, 9, 'place_id', 5, 'addLink');
await runFileLinkRecordStep(1, 9, 'reservation_id', 7, 'addLink');
expect(filesApi.addLink).toHaveBeenNthCalledWith(1, 1, 9, { place_id: 5 });
expect(filesApi.addLink).toHaveBeenNthCalledWith(2, 1, 9, { reservation_id: 7 });
});
it('FE-FILES-ACTIONS-008: removing a record looks it up by the target, numeric or not', async () => {
await runFileLinkRecordStep(1, 9, 'place_id', 5, 'removeLink');
await runFileLinkRecordStep(1, 9, 'reservation_id', 7, 'removeLink');
expect(filesApi.getLinks).toHaveBeenCalledWith(1, 9);
expect(filesApi.removeLink).toHaveBeenNthCalledWith(1, 1, 9, 50);
expect(filesApi.removeLink).toHaveBeenNthCalledWith(2, 1, 9, 70);
});
it('FE-FILES-ACTIONS-009: no matching record or no links array removes nothing', async () => {
await runFileLinkRecordStep(1, 9, 'place_id', 99, 'removeLink');
vi.mocked(filesApi.getLinks).mockResolvedValue({});
await runFileLinkRecordStep(1, 9, 'place_id', 5, 'removeLink');
expect(filesApi.removeLink).not.toHaveBeenCalled();
});
it('FE-FILES-ACTIONS-010: toggleFileLink writes the column or the record, and rethrows', async () => {
await toggleFileLink(1, buildTripFile({ id: 9, place_id: null }), 'place_id', 5);
expect(filesApi.update).toHaveBeenCalledWith(1, 9, { place_id: 5 });
await toggleFileLink(1, buildTripFile({ id: 9, place_id: 1 }), 'place_id', 5);
expect(filesApi.addLink).toHaveBeenCalledWith(1, 9, { place_id: 5 });
vi.mocked(filesApi.update).mockRejectedValue(new Error('nope'));
await expect(toggleFileLink(1, buildTripFile({ id: 9, reservation_id: 7 }), 'reservation_id', 7)).rejects.toThrow(
'nope'
);
});
});
@@ -0,0 +1,86 @@
import { filesApi } from '../../api/client';
import type { TripFile } from '../../types';
/** The files a paste carries, in clipboard order; text and other non-file items are skipped. */
export function filesFromClipboard(clipboardData: DataTransfer | null | undefined): File[] {
const items = clipboardData?.items;
if (!items) return [];
const pasted: File[] = [];
for (const item of Array.from(items)) {
if (item.kind === 'file') {
const file = item.getAsFile();
if (file) pasted.push(file);
}
}
return pasted;
}
/** Which of a file's two link columns a toggle works on. */
export type FileLinkField = 'place_id' | 'reservation_id';
/**
* What toggling one place or booking on a file does. The first link lives on the file
* itself (`place_id` / `reservation_id`); any further one is a separate file link record.
* So an unlinked target becomes the file's own link while that column is free and an extra
* link record otherwise, and a linked target is cleared from the column it sits in or its
* link record is removed.
*/
export type FileLinkStep =
| { kind: 'update'; data: { place_id: number | null } | { reservation_id: number | null } }
| { kind: 'addLink' }
| { kind: 'removeLink' };
function linkedIds(file: TripFile, field: FileLinkField): number[] {
const own = file[field];
const extra = field === 'place_id' ? file.linked_place_ids : file.linked_reservation_ids;
return [...(own != null ? [own] : []), ...(extra || []).filter((id) => id != null)];
}
export function planFileLinkToggle(file: TripFile, field: FileLinkField, targetId: number): FileLinkStep {
const column = (value: number | null) => (field === 'place_id' ? { place_id: value } : { reservation_id: value });
if (linkedIds(file, field).includes(targetId)) {
if (file[field] === targetId) return { kind: 'update', data: column(null) };
return { kind: 'removeLink' };
}
if (!file[field]) return { kind: 'update', data: column(targetId) };
return { kind: 'addLink' };
}
interface FileLinkRecord {
id: number;
place_id?: number | string | null;
reservation_id?: number | string | null;
}
/** Runs an `addLink` or `removeLink` step against the server. */
export async function runFileLinkRecordStep(
tripId: number,
fileId: number,
field: FileLinkField,
targetId: number,
step: 'addLink' | 'removeLink'
): Promise<void> {
if (step === 'addLink') {
await filesApi.addLink(
tripId,
fileId,
field === 'place_id' ? { place_id: targetId } : { reservation_id: targetId }
);
return;
}
const linksRes = (await filesApi.getLinks(tripId, fileId)) as { links?: FileLinkRecord[] };
const link = (linksRes.links || []).find((l) => Number(l[field]) === targetId);
if (link) await filesApi.removeLink(tripId, fileId, link.id);
}
/** Toggles one place or booking on a file, whichever of the server calls that takes. */
export async function toggleFileLink(
tripId: number,
file: TripFile,
field: FileLinkField,
targetId: number
): Promise<void> {
const step = planFileLinkToggle(file, field, targetId);
if (step.kind === 'update') await filesApi.update(tripId, file.id, step.data);
else await runFileLinkRecordStep(tripId, file.id, field, targetId, step.kind);
}
+15 -48
View File
@@ -12,6 +12,8 @@ import { useDocSyncOffered } from './docsync/useDocSyncOffered'
import { getAuthUrl } from '../../api/authUrl'
import { isImage, isMedia, isWalletPass } from './FileManager.helpers'
import { openFile as openFileInTab } from '../../utils/fileDownload'
import { useFileTrash } from './useFileTrash'
import { filesFromClipboard } from './fileActions'
export interface FileManagerProps {
files?: TripFile[]
@@ -37,8 +39,6 @@ export function useFileManager({ files = [], onUpload, onDelete, onUpdate, place
const [filterType, setFilterType] = useState('all')
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null)
const [showTrash, setShowTrash] = useState(false)
const [trashFiles, setTrashFiles] = useState<TripFile[]>([])
const [loadingTrash, setLoadingTrash] = useState(false)
const toast = useToast()
const can = useCanDo()
const trip = useTripStore((s) => s.trip)
@@ -48,25 +48,21 @@ export function useFileManager({ files = [], onUpload, onDelete, onUpdate, place
const docSyncOffered = useDocSyncOffered(tripId, canManageSync)
const { t, locale } = useTranslation()
const loadTrash = useCallback(async () => {
setLoadingTrash(true)
try {
const data = await filesApi.list(tripId, true)
setTrashFiles(data.files || [])
} catch { /* */ }
setLoadingTrash(false)
}, [tripId])
// onUpdate doubles as the "files changed" signal towards the parent; the arguments carry no payload.
const refreshFiles = useCallback(async () => {
if (onUpdate) void onUpdate(0, {} as any)
}, [onUpdate])
const trash = useFileTrash({ tripId, t, toast, onRestored: refreshFiles })
const { load: loadTrash } = trash
const trashFiles = trash.files
const loadingTrash = trash.loading
const toggleTrash = useCallback(() => {
if (!showTrash) loadTrash()
setShowTrash(v => !v)
}, [showTrash, loadTrash])
// onUpdate doubles as the "files changed" signal towards the parent; the arguments carry no payload.
const refreshFiles = useCallback(async () => {
if (onUpdate) void onUpdate(0, {} as any)
}, [onUpdate])
const handleStar = async (fileId: number) => {
try {
await filesApi.toggleStar(tripId, fileId)
@@ -74,37 +70,16 @@ export function useFileManager({ files = [], onUpload, onDelete, onUpdate, place
} catch { /* */ }
}
const handleRestore = async (fileId: number) => {
try {
await filesApi.restore(tripId, fileId)
setTrashFiles(prev => prev.filter(f => f.id !== fileId))
refreshFiles()
toast.success(t('files.toast.restored'))
} catch {
toast.error(t('files.toast.restoreError'))
}
}
const handleRestore = trash.restore
const handlePermanentDelete = async (fileId: number) => {
if (!confirm(t('files.confirm.permanentDelete'))) return
try {
await filesApi.permanentDelete(tripId, fileId)
setTrashFiles(prev => prev.filter(f => f.id !== fileId))
toast.success(t('files.toast.deleted'))
} catch {
toast.error(t('files.toast.deleteError'))
}
await trash.permanentDelete(fileId)
}
const handleEmptyTrash = async () => {
if (!confirm(t('files.confirm.emptyTrash'))) return
try {
await filesApi.emptyTrash(tripId)
setTrashFiles([])
toast.success(t('files.toast.trashEmptied') || 'Trash emptied')
} catch {
toast.error(t('files.toast.deleteError'))
}
await trash.emptyTrash()
}
const [previewFile, setPreviewFile] = useState(null)
@@ -150,15 +125,7 @@ export function useFileManager({ files = [], onUpload, onDelete, onUpdate, place
const handlePaste = useCallback((e: React.ClipboardEvent) => {
if (!can('file_upload', trip)) return
const items = e.clipboardData?.items
if (!items) return
const pastedFiles: File[] = []
for (const item of Array.from(items)) {
if (item.kind === 'file') {
const file = item.getAsFile()
if (file) pastedFiles.push(file)
}
}
const pastedFiles = filesFromClipboard(e.clipboardData)
if (pastedFiles.length > 0) {
e.preventDefault()
onDrop(pastedFiles)
@@ -0,0 +1,158 @@
// FE-FILES-TRASH-001 to FE-FILES-TRASH-010: the trash logic behind the desktop file
// manager's trash view (load on demand) and the phone's trash sheet (load while open).
import { act, renderHook, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { buildTripFile } from '../../../tests/helpers/factories';
import { filesApi } from '../../api/client';
import { useFileTrash, type FileTrashOptions } from './useFileTrash';
const t = (key: string) => key;
const TRASHED = [buildTripFile({ id: 31 }), buildTripFile({ id: 32 })];
function setup(over: Partial<FileTrashOptions> = {}) {
const toast = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() };
const onRestored = vi.fn();
const hook = renderHook(
(props: Partial<FileTrashOptions>) => useFileTrash({ tripId: 1, t, toast, onRestored, ...over, ...props }),
{ initialProps: {} }
);
return { ...hook, toast, onRestored };
}
beforeEach(() => {
vi.spyOn(filesApi, 'list').mockResolvedValue({ files: TRASHED });
vi.spyOn(filesApi, 'restore').mockResolvedValue({});
vi.spyOn(filesApi, 'permanentDelete').mockResolvedValue({});
vi.spyOn(filesApi, 'emptyTrash').mockResolvedValue({});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('useFileTrash', () => {
it('FE-FILES-TRASH-001: without `open` nothing loads until load() is called', async () => {
const { result } = setup();
expect(filesApi.list).not.toHaveBeenCalled();
expect(result.current.files).toEqual([]);
let pending: Promise<void> | undefined;
act(() => {
pending = result.current.load();
});
expect(result.current.loading).toBe(true);
await act(async () => {
await pending;
});
expect(filesApi.list).toHaveBeenCalledWith(1, true);
expect(result.current.files.map((f) => f.id)).toEqual([31, 32]);
expect(result.current.loading).toBe(false);
});
it('FE-FILES-TRASH-002: a failing load() leaves the trash empty and stops loading', async () => {
vi.mocked(filesApi.list).mockRejectedValue(new Error('offline'));
const { result } = setup();
await act(async () => {
await result.current.load();
});
expect(result.current.files).toEqual([]);
expect(result.current.loading).toBe(false);
});
it('FE-FILES-TRASH-003: with `open` the trash loads on open and not while closed', async () => {
const { result, rerender } = setup({ open: false });
expect(filesApi.list).not.toHaveBeenCalled();
rerender({ open: true });
expect(result.current.loading).toBe(true);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(filesApi.list).toHaveBeenCalledWith(1, true);
expect(result.current.files.map((f) => f.id)).toEqual([31, 32]);
});
it('FE-FILES-TRASH-004: a load answered after the sheet closed is dropped', async () => {
let resolve: (v: { files: typeof TRASHED }) => void = () => {};
vi.mocked(filesApi.list).mockReturnValue(new Promise((r) => (resolve = r)));
const { result, rerender } = setup({ open: true });
rerender({ open: false });
await act(async () => {
resolve({ files: TRASHED });
});
expect(result.current.files).toEqual([]);
// The cancelled load also leaves the spinner as it was.
expect(result.current.loading).toBe(true);
});
it('FE-FILES-TRASH-005: a response without a files array reads as an empty trash', async () => {
vi.mocked(filesApi.list).mockResolvedValue({});
const { result } = setup({ open: true });
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.files).toEqual([]);
});
it('FE-FILES-TRASH-006: restore drops the row, reloads the live files and toasts', async () => {
const { result, toast, onRestored } = setup({ open: true });
await waitFor(() => expect(result.current.files).toHaveLength(2));
await act(async () => {
await result.current.restore(31);
});
expect(filesApi.restore).toHaveBeenCalledWith(1, 31);
expect(result.current.files.map((f) => f.id)).toEqual([32]);
expect(onRestored).toHaveBeenCalledTimes(1);
expect(toast.success).toHaveBeenCalledWith('files.toast.restored');
});
it('FE-FILES-TRASH-007: a failed restore keeps the row and toasts the error', async () => {
vi.mocked(filesApi.restore).mockRejectedValue(new Error('nope'));
const { result, toast, onRestored } = setup({ open: true });
await waitFor(() => expect(result.current.files).toHaveLength(2));
await act(async () => {
await result.current.restore(31);
});
expect(result.current.files).toHaveLength(2);
expect(onRestored).not.toHaveBeenCalled();
expect(toast.error).toHaveBeenCalledWith('files.toast.restoreError');
});
it('FE-FILES-TRASH-008: permanent delete drops the row and toasts, or toasts the error', async () => {
const { result, toast, onRestored } = setup({ open: true });
await waitFor(() => expect(result.current.files).toHaveLength(2));
await act(async () => {
await result.current.permanentDelete(32);
});
expect(filesApi.permanentDelete).toHaveBeenCalledWith(1, 32);
expect(result.current.files.map((f) => f.id)).toEqual([31]);
expect(toast.success).toHaveBeenCalledWith('files.toast.deleted');
expect(onRestored).not.toHaveBeenCalled();
vi.mocked(filesApi.permanentDelete).mockRejectedValue(new Error('nope'));
await act(async () => {
await result.current.permanentDelete(31);
});
expect(result.current.files.map((f) => f.id)).toEqual([31]);
expect(toast.error).toHaveBeenCalledWith('files.toast.deleteError');
});
it('FE-FILES-TRASH-009: emptying clears the list and toasts', async () => {
const { result, toast } = setup({ open: true });
await waitFor(() => expect(result.current.files).toHaveLength(2));
await act(async () => {
await result.current.emptyTrash();
});
expect(filesApi.emptyTrash).toHaveBeenCalledWith(1);
expect(result.current.files).toEqual([]);
expect(toast.success).toHaveBeenCalledWith('files.toast.trashEmptied');
});
it('FE-FILES-TRASH-010: a failed empty keeps the list and toasts the error', async () => {
vi.mocked(filesApi.emptyTrash).mockRejectedValue(new Error('nope'));
const { result, toast } = setup({ open: true });
await waitFor(() => expect(result.current.files).toHaveLength(2));
await act(async () => {
await result.current.emptyTrash();
});
expect(result.current.files).toHaveLength(2);
expect(toast.error).toHaveBeenCalledWith('files.toast.deleteError');
});
});
@@ -0,0 +1,92 @@
import { useCallback, useEffect, useState } from 'react';
import { filesApi } from '../../api/client';
import type { useTranslation } from '../../i18n';
import type { TripFile } from '../../types';
import type { useToast } from '../shared/Toast';
export interface FileTrashOptions {
tripId: number;
t: ReturnType<typeof useTranslation>['t'];
toast: ReturnType<typeof useToast>;
/** Reloads the trip's live files after a restore brought one back. */
onRestored: () => void;
/**
* The phone's sheet: the trash loads every time this turns true, and a load still in
* flight when it closes or the trip changes is dropped. Left out, nothing loads by
* itself and the desktop view calls `load` when it opens the trash.
*/
open?: boolean;
}
/**
* The trashed files of a trip behind both shells: the desktop file manager's trash view
* and the phone's trash sheet. Each shell keeps its own confirm step and busy markers and
* calls the actions here once the user said yes.
*/
export function useFileTrash({ tripId, t, toast, onRestored, open }: FileTrashOptions) {
const [files, setFiles] = useState<TripFile[]>([]);
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const data = await filesApi.list(tripId, true);
setFiles(data.files || []);
} catch {
// An unreachable trash shows as empty.
}
setLoading(false);
}, [tripId]);
useEffect(() => {
if (!open) return;
let cancelled = false;
setLoading(true);
filesApi
.list(tripId, true)
.then((data: { files?: TripFile[] }) => {
if (!cancelled) setFiles(data.files || []);
})
.catch(() => {})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [open, tripId]);
const restore = async (fileId: number) => {
try {
await filesApi.restore(tripId, fileId);
setFiles((prev) => prev.filter((f) => f.id !== fileId));
onRestored();
toast.success(t('files.toast.restored'));
} catch {
toast.error(t('files.toast.restoreError'));
}
};
const permanentDelete = async (fileId: number) => {
try {
await filesApi.permanentDelete(tripId, fileId);
setFiles((prev) => prev.filter((f) => f.id !== fileId));
toast.success(t('files.toast.deleted'));
} catch {
toast.error(t('files.toast.deleteError'));
}
};
const emptyTrash = async () => {
try {
await filesApi.emptyTrash(tripId);
setFiles([]);
toast.success(t('files.toast.trashEmptied'));
} catch {
toast.error(t('files.toast.deleteError'));
}
};
return { files, loading, load, restore, permanentDelete, emptyTrash };
}