"use client"; import { useState } from "react"; import { Paperclip, Trash2, Undo2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { IconTooltip } from "@/components/ui/tooltip"; import type { Recording } from "@/lib/api/dives"; import { deleteFileConfirmation, diveFileRows, UNNAMED_DEVICE_LABEL, type DiveFileRow, type EntryFix, type RecordingConfirmation, } from "@/lib/dive-recordings"; import { formatFileSize } from "@/lib/format"; /** * A file the diver has picked and the API has parsed, waiting for a dive to * exist (create form) or for the edit to be saved (edit form). * * The page holds these, not this component: `/dive/parse` stores nothing, so * there is nowhere to send a file until the save succeeds, and importing a file * and then cancelling the edit must not change the dive's stored exports. */ export interface PendingDiveFile { /** Identity for the list and for removal; the `File` itself is not unique. */ id: string; file: File; /** The `file_token` proving the API parsed these exact bytes for this user. */ token: string; /** What the parse said recorded it, already rendered. */ deviceLabel: string | null; /** Where the parse placed the dive's entry - see `entryFixAfterSave`. */ entry: EntryFix | null; } interface DiveRecordingFilesProps { /** What the dive already holds. Empty on the create form, where nothing is stored yet. */ recordings: Recording[]; pending: PendingDiveFile[]; onRemovePending: (id: string) => void; /** * The uuids of stored files the diver has marked for deletion. Their rows stay * on the list, struck through, until the edit is saved. */ removedStored?: string[]; /** * Marks one stored file for deletion, behind this component's confirm dialog. * **Nothing is sent until the form is saved** - the page collects these the * way it collects `pending`, so that Cancel leaves the dive exactly as it * found it. Absent on the create form, where every row is pending and removal * is local anyway. */ onRemoveStored?: (fileUuid: string) => void; /** Takes a marked file back off the list. */ onRestoreStored?: (fileUuid: string) => void; } /** * The confirmation before striking a stored file off the list. * * **It only describes an outcome while it is the first thing struck off**, and * that limit is the point rather than a gap. `deleteFileConfirmation` tells the * three outcomes apart — the recording keeps its other files, it survives * file-less, or it goes with the file — by reading the dive as the server holds * it, and the immediate delete this replaces kept that true for free by * re-reading after every one. Nothing re-reads now, so from the second mark on, * the answer depends on a cascade that happens on the server when the save runs: * a recording emptied by an earlier mark is deleted, its profile is re-derived * from whatever files are left (which turns a merge's unreproducible samples * into reproducible ones), and the ordinals are renumbered, moving which * recording the dive reads its computer figures from. * * Three rounds of review found three different ways for a local model of that to * be wrong, each in a dialog whose whole job is to be right about a destructive * action, so there is no local model: past the first mark this says what is * certain and stops. */ function removeFileConfirmation( recordings: Recording[], fileUuid: string, othersAlreadyMarked: boolean, ): RecordingConfirmation { if (othersAlreadyMarked) { return { title: "Delete this file?", description: "It is permanently deleted when you save, along with the others you have struck off. Between them they may leave a recording with no files, and a recording with nothing left to re-read is deleted with its profile and its samples — so what this dive shows can change. Nothing happens until you save; until then the row stays on the list, marked, and you can put it back.", }; } const confirmation = deleteFileConfirmation(recordings, fileUuid); return { title: confirmation.title, description: `${confirmation.description} None of it happens until you save this edit - until then the row stays on the list, marked, and you can put it back.`, }; } /** * The list of files attached to a dive, on both dive forms: what is stored, what * is about to be, and which computer each came off. * * **Every row is one box with a delete control inside it**, and a recording that * kept no file gets a row of its own saying so. Both are statements about what * the account holds: a diver looking at this list is checking whether their * files are still there, and a device that silently has no row is the thing that * would read as data loss. * * Renders nothing at all when there is neither a stored file nor a pending one — * a dive logged by hand has no list to show, and an empty box under the picker * would only be a place for the eye to stop. */ export function DiveRecordingFiles({ recordings, pending, onRemovePending, removedStored = [], onRemoveStored, onRestoreStored, }: DiveRecordingFilesProps) { // The file uuid awaiting confirmation, or null. Kept here rather than in the // page because the dialog is this list's, and the page has no reason to know // which row the pointer is over. const [pendingDeletion, setPendingDeletion] = useState( null, ); const removed = new Set(removedStored); const storedRows = diveFileRows(recordings); if (storedRows.length === 0 && pending.length === 0) return null; const confirmDelete = () => { if (!pendingDeletion || pendingDeletion.kind !== "file" || !onRemoveStored) return; onRemoveStored(pendingDeletion.file.uuid); setPendingDeletion(null); }; return ( <> {/* Three outcomes sit behind one Trash icon - the recording keeps its other files, it survives file-less because nothing could re-read its samples, or **it is deleted along with the file** - and they differ in what the dive shows afterwards. `deleteFileConfirmation` is the single place that decides which. Mounted only while a row is pending, so both strings come from that row: the nullable-prop idiom used elsewhere would let the heading fall back to a neutral "Delete this file?" as the dialog closed, which is the wrong half of the very difference this draws. */} {pendingDeletion?.kind === "file" && ( !open && setPendingDeletion(null)} {...removeFileConfirmation( recordings, pendingDeletion.file.uuid, removed.size > 0, )} confirmText="Delete" onConfirm={confirmDelete} /> )} ); } // One row's box. Shared by both kinds so a stored file and a pending one line up // down the list instead of each inventing its own padding. function Row({ children, action, }: { children: React.ReactNode; action: React.ReactNode; }) { return (
  • {children}
    {action}
  • ); } function FileRow({ row, isRemoved = false, onDelete, onRestore, }: { row: DiveFileRow; /** Marked for deletion on save - struck through rather than taken off the list. */ isRemoved?: boolean; onDelete?: () => void; onRestore?: () => void; }) { const device = row.deviceLabel ?? UNNAMED_DEVICE_LABEL; if (row.kind === "empty") { return (
    {device}
    {row.recordingName && (
    {row.recordingName}
    )}
    {row.reason}
    ); } return ( ) : onDelete && ( // The file name rather than "Delete": this list has one of these per // row, and a screen reader reading them out gets a column of // identical names otherwise. ) } >
    {row.file.original_filename}
    {device} {row.recordingName ? ` · ${row.recordingName}` : ""}
    {/* The strike-through alone is a colour-and-decoration claim about a row that is otherwise unchanged, so the state is also in words - beside the pending rows, which say the mirror-image thing. */} {isRemoved ? "Deleted when you save" : `${row.formatLabel ? `${row.formatLabel} · ` : ""}${formatFileSize(row.file.byte_size)}`}
    ); } function PendingRow({ item, onRemove, }: { item: PendingDiveFile; onRemove: () => void; }) { return ( } >
    {item.file.name}
    {item.deviceLabel ?? UNNAMED_DEVICE_LABEL}
    Will be attached when you save · {formatFileSize(item.file.size)}
    ); }