"use client"; import { cn } from "@/lib/utils"; import { useState } from "react"; import { Download, Paperclip, Star, Trash2 } from "lucide-react"; import { CARD_SECTION_CONTENT, Card, CardContent } from "@/components/ui/card"; import { DiveCardHeader } from "@/components/dives/dive-card-header"; import { Button } from "@/components/ui/button"; import { ButtonSpinner } from "@/components/ui/button-spinner"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { IconTooltip } from "@/components/ui/tooltip"; import { useToast } from "@/components/ui/use-toast"; import { Dive, DiveFileInfo, diveFilePath, divesAPI, Recording, } from "@/lib/api/dives"; import { getApiErrorMessage } from "@/lib/api/error"; import { importSourceLabel, UNREAD_FILE_NOTE, UNREAD_FILE_PARSER_KEY, } from "@/lib/api/logbook-import"; import { deleteRecordingConfirmation, diveRecordings, noFileKeptSentence, recordingDeviceLabel, recordingLabel, recordingReadoutsLabel, recordingSettingsLabel, UNNAMED_DEVICE_LABEL, } from "@/lib/dive-recordings"; import { saveApiDownload } from "@/lib/download"; import { formatDiveTimeOnly } from "@/lib/date-time"; import { formatFileSize } from "@/lib/format"; interface DiveRecordingsCardProps { dive: Dive; // Called after anything here changes the dive, so the page can re-read it. A // delete can take a recording with it, promote the next one and re-derive what // the dive shows by default, so nothing here predicts the new state. onChanged: () => void | Promise; /** Opens the Import section's edit dialog. */ onEdit?: () => void; } /** * What recorded this dive, on the dive detail page: one block per recording, * with its device, what it was set to and what it reported, its own start where * that differs from the dive's, and its files to download. * * Renders nothing when the dive has no recordings at all - most dives are logged * by hand, and there is no empty state worth showing for a thing the diver * deliberately did not do. * * This was the *Imported From* card, which showed one file because a dive had * one. A dive now has an ordered list of recordings, each with its own device * and its own files, and the two facts this card exists to state are which * computers recorded the dive and what the account still holds from each. */ export function DiveRecordingsCard({ dive, onChanged, onEdit, }: DiveRecordingsCardProps) { const { toast } = useToast(); const [downloadingUuid, setDownloadingUuid] = useState(null); const [busyUuid, setBusyUuid] = useState(null); // A recording with no files left, about to be deleted. const [removal, setRemoval] = useState(null); const [isRemoving, setIsRemoving] = useState(false); const recordings = diveRecordings(dive); if (recordings.length === 0) return null; // Downloading goes through `saveApiDownload` rather than a plain ``: the // endpoint needs an `Authorization` header, which a link cannot send (the // access token lives in memory, not in a cookie). See `lib/download.ts`. // // The `v` param is the file's identity: `uuid` covers delete-then-reattach, // `updated_at` covers a replace. Without it the 5-minute `max-age` would keep // serving the previous file's bytes. const handleDownload = async (file: DiveFileInfo) => { try { setDownloadingUuid(file.uuid); await saveApiDownload(diveFilePath(dive.uuid, file.uuid), async () => ({ blob: await divesAPI.getDiveFileBlob( dive.uuid, file.uuid, `${file.uuid}:${file.updated_at ?? ""}`, ), filename: file.original_filename, })); } catch (error) { toast({ title: "Error", description: getApiErrorMessage( error, "Failed to download the file. Please try again.", ), variant: "destructive", }); } finally { setDownloadingUuid(null); } }; const handleMakePrimary = async (recording: Recording) => { try { setBusyUuid(recording.uuid); await divesAPI.makeRecordingPrimary(dive.uuid, recording.uuid); await onChanged(); toast({ title: "Shown by default", description: "This recording now supplies the dive's figures and its charted profile.", }); } catch (error) { toast({ title: "Error", description: getApiErrorMessage( error, "Failed to change which recording is shown. Please try again.", ), variant: "destructive", }); } finally { setBusyUuid(null); } }; const confirmRemoval = async () => { if (!removal) return; try { setIsRemoving(true); await divesAPI.deleteRecording(dive.uuid, removal.uuid); await onChanged(); setRemoval(null); toast({ title: "Recording deleted", description: "The recording and its profile were removed from this dive.", }); } catch (error) { toast({ title: "Error", description: getApiErrorMessage( error, "Failed to delete. Please try again.", ), variant: "destructive", }); } finally { setIsRemoving(false); } }; const namesRecordings = recordings.length > 1; return ( <> {/* Mounted only while a removal is pending, so the strings are derived from it rather than from a nullable one. */} {removal && ( !open && setRemoval(null)} {...deleteRecordingConfirmation(recordings, removal.uuid)} confirmText="Delete" isLoading={isRemoving} onConfirm={confirmRemoval} /> )} {recordings.map((recording) => { const device = recordingDeviceLabel(recording.device); // What this computer was set to, where the file said: its mode and // its decompression model. On the recording rather than on the dive, // because a backup run in gauge mode does not make the dive a gauge // dive - and absent entirely rather than guessed, which is why there // is no "Open circuit" here on a file that recorded no mode. const settings = recordingSettingsLabel(recording); // Its own CNS, OTU and surface pressure. Every recording's, not just // the primary's the exposure card shows: two computers give two // answers, and this is the one place both are on screen. const readouts = recordingReadoutsLabel(recording); const noFileKept = noFileKeptSentence(recording); // Its own start, but only where it says something the dive's start // does not: a second computer that entered the water a minute later // is worth a line, and the primary recording agreeing with the dive // is not. const ownStart = recording.started_at && recording.started_at !== dive.start_time ? formatDiveTimeOnly(recording.started_at) : null; return (
{device ?? UNNAMED_DEVICE_LABEL}
{namesRecordings && (
{recordingLabel(recording)} {recording.ordinal === 0 ? " ยท shown by default" : ""}
)} {settings && (
{settings}
)} {readouts && (
{readouts}
)} {ownStart && (
Started {ownStart}
)} {recording.device?.firmware && (
Firmware {recording.device.firmware}
)}
{noFileKept ? (

{noFileKept}

) : (
    {recording.files.map((file) => (
  • {file.original_filename}
    {importSourceLabel(file.parser_key)} ·{" "} {formatFileSize(file.byte_size)}
    {file.parser_key === UNREAD_FILE_PARSER_KEY && (
    {UNREAD_FILE_NOTE}
    )}
    {/* Named by the file rather than by the verb: a dive with two recordings and three files between them renders three of these, and "Download, Download..." names none of them. Removing a file is the edit dialog's, beside importing one. */}
  • ))}
)}
{recording.ordinal !== 0 && ( )} {/* A recording with no files has no per-file delete control, and the recording route is the only way to remove it at all - so without this a converter-imported or merged recording would be permanent. Offered only in that case: where there are files, deleting them one at a time is the smaller action, and the server removes the recording with the last of them. */} {noFileKept && ( )}
); })}
); }