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 };
}
@@ -2,7 +2,7 @@ import { useRef, useState } from 'react'
import { Check, Link2, Loader2, MapPin, Ticket, TrainFront } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import MSheet from '../../../components/MSheet'
import { filesApi } from '../../../../api/client'
import { toggleFileLink, type FileLinkField } from '../../../../components/Files/fileActions'
import type { TripFile } from '../../../../types'
import type { TripPlanner } from '../MTripShell'
import { Eyebrow, TileHeader } from '../sheets/MTripSheetUi'
@@ -14,18 +14,12 @@ interface MFileLinkSheetProps {
onClose: () => void
}
interface FileLinkRecord {
id: number
place_id?: number | string | null
reservation_id?: number | string | null
}
/**
* Link picker for a file (spec 03 §5.3 "Verknüpfen", §7.3 m:n): toggles the
* file's place_id/reservation_id (first link) or an addLink/removeLink file_link
* record (further links) — the exact two-tier pattern FileManagerAssignModal.tsx
* uses on desktop, flattened to a single list per section instead of grouping
* places by day (v1 simplification, see report).
* record (further links) through toggleFileLink, the two-tier rule
* FileManagerAssignModal.tsx runs on desktop, flattened to a single list per
* section instead of grouping places by day (v1 simplification, see report).
*/
export default function MFileLinkSheet({ planner, file, onClose }: MFileLinkSheetProps) {
const { t, tripId, places, reservations, TRANSPORT_TYPES, tripActions, toast } = planner
@@ -49,24 +43,12 @@ export default function MFileLinkSheet({ planner, file, onClose }: MFileLinkShee
const refresh = () => tripActions.loadFiles(tripId)
const togglePlace = async (placeId: number) => {
const toggle = async (field: FileLinkField, targetId: number) => {
if (busyKey) return
const key = `p${placeId}`
const key = `${field === 'place_id' ? 'p' : 'r'}${targetId}`
setBusyKey(key)
try {
if (placeIds.has(placeId)) {
if (shown.place_id === placeId) {
await filesApi.update(tripId, shown.id, { place_id: null })
} else {
const linksRes = (await filesApi.getLinks(tripId, shown.id)) as { links: FileLinkRecord[] }
const link = (linksRes.links || []).find(l => Number(l.place_id) === placeId)
if (link) await filesApi.removeLink(tripId, shown.id, link.id)
}
} else if (shown.place_id == null) {
await filesApi.update(tripId, shown.id, { place_id: placeId })
} else {
await filesApi.addLink(tripId, shown.id, { place_id: placeId })
}
await toggleFileLink(tripId, shown, field, targetId)
refresh()
} catch {
toast.error(t('files.toast.assignError'))
@@ -75,31 +57,8 @@ export default function MFileLinkSheet({ planner, file, onClose }: MFileLinkShee
}
}
const toggleReservation = async (resId: number) => {
if (busyKey) return
const key = `r${resId}`
setBusyKey(key)
try {
if (resIds.has(resId)) {
if (shown.reservation_id === resId) {
await filesApi.update(tripId, shown.id, { reservation_id: null })
} else {
const linksRes = (await filesApi.getLinks(tripId, shown.id)) as { links: FileLinkRecord[] }
const link = (linksRes.links || []).find(l => Number(l.reservation_id) === resId)
if (link) await filesApi.removeLink(tripId, shown.id, link.id)
}
} else if (shown.reservation_id == null) {
await filesApi.update(tripId, shown.id, { reservation_id: resId })
} else {
await filesApi.addLink(tripId, shown.id, { reservation_id: resId })
}
refresh()
} catch {
toast.error(t('files.toast.assignError'))
} finally {
setBusyKey(null)
}
}
const togglePlace = (placeId: number) => toggle('place_id', placeId)
const toggleReservation = (resId: number) => toggle('reservation_id', resId)
const bookingReservations = reservations.filter(r => !TRANSPORT_TYPES.has(r.type))
const transportReservations = reservations.filter(r => TRANSPORT_TYPES.has(r.type))
@@ -1,14 +1,13 @@
import { useEffect, useState } from 'react'
import { useState } from 'react'
import { Loader2, RotateCcw, Trash2 } from 'lucide-react'
import MSheet from '../../../components/MSheet'
import MConfirmSheet from '../../settings/MConfirmSheet'
import { filesApi } from '../../../../api/client'
import type { TripFile } from '../../../../types'
import type { TripPlanner } from '../MTripShell'
import { TileHeader } from '../sheets/MTripSheetUi'
import { formatFileDate, getFileTypeMeta } from './filesModel'
import { useTranslation } from '../../../../i18n'
import { formatSize } from '../../../../components/Files/FileManager.helpers'
import { useFileTrash } from '../../../../components/Files/useFileTrash'
interface MFileTrashSheetProps {
planner: TripPlanner
@@ -17,66 +16,38 @@ interface MFileTrashSheetProps {
}
/**
* Trash sheet (spec 03 §5.3 trashGo, §7.3): fetches the trashed files
* (filesApi.list(tripId, true)) on open, same lazy-load-on-toggle pattern as
* useFileManager.ts's toggleTrash/loadTrash. Restore/permanent-delete/empty
* all bypass the store like the rest of §7.3.
* Trash sheet (spec 03 §5.3 trashGo, §7.3): the trashed files load on open
* through useFileTrash, the same hook the desktop trash view reads. Restore,
* permanent delete and empty run there too; this sheet adds the confirm sheets
* and the busy markers.
*/
export default function MFileTrashSheet({ planner, open, onClose }: MFileTrashSheetProps) {
const { t, tripId, can, trip, toast, tripActions } = planner
const { locale } = useTranslation()
const [files, setFiles] = useState<TripFile[]>([])
const [loading, setLoading] = useState(false)
const { files, loading, ...trash } = useFileTrash({ tripId, t, toast, onRestored: () => tripActions.loadFiles(tripId), open })
const [busyId, setBusyId] = useState<number | null>(null)
const [emptying, setEmptying] = useState(false)
const [confirmEmpty, setConfirmEmpty] = useState(false)
const [confirmDeleteId, setConfirmDeleteId] = useState<number | null>(null)
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 canDelete = can('file_delete', trip)
const restore = (id: number) => {
setBusyId(id)
filesApi.restore(tripId, id)
.then(() => {
setFiles(prev => prev.filter(f => f.id !== id))
tripActions.loadFiles(tripId)
toast.success(t('files.toast.restored'))
})
.catch(() => toast.error(t('files.toast.restoreError')))
.finally(() => setBusyId(null))
void trash.restore(id).finally(() => setBusyId(null))
}
const permanentDelete = (id: number) => {
setConfirmDeleteId(null)
setBusyId(id)
filesApi.permanentDelete(tripId, id)
.then(() => {
setFiles(prev => prev.filter(f => f.id !== id))
toast.success(t('files.toast.deleted'))
})
.catch(() => toast.error(t('files.toast.deleteError')))
.finally(() => setBusyId(null))
void trash.permanentDelete(id).finally(() => setBusyId(null))
}
// Row actions stay disabled while this runs so a restore cannot race the empty.
const emptyTrash = () => {
setConfirmEmpty(false)
setEmptying(true)
filesApi.emptyTrash(tripId)
.then(() => { setFiles([]); toast.success(t('files.toast.trashEmptied')) })
.catch(() => toast.error(t('files.toast.deleteError')))
.finally(() => setEmptying(false))
void trash.emptyTrash().finally(() => setEmptying(false))
}
return (
@@ -13,6 +13,7 @@ import MFileLinkSheet from './MFileLinkSheet'
import MFileTrashSheet from './MFileTrashSheet'
import MDocSyncSheet from './MDocSyncSheet'
import { canManageDocSync } from '../../../../components/Files/docsync/useDocSync'
import { filesFromClipboard } from '../../../../components/Files/fileActions'
import { useAuthStore } from '../../../../store/authStore'
import MFileLightbox from './MFileLightbox'
import {
@@ -91,15 +92,7 @@ export default function MFilesTab({ planner, shell }: MTabScreenProps) {
const onPaste = (e: ClipboardEvent<HTMLDivElement>) => {
if (!planner.canUploadFiles) return
const items = e.clipboardData?.items
if (!items) return
const pasted: File[] = []
for (const item of Array.from(items)) {
if (item.kind === 'file') {
const f = item.getAsFile()
if (f) pasted.push(f)
}
}
const pasted = filesFromClipboard(e.clipboardData)
if (pasted.length > 0) {
e.preventDefault()
void uploadFiles(pasted)