Ctrl K
ComunidadGuías…

src/components/dives/dive-recording-files.tsx

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"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<DiveFileRow | null>(
    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" && (
        <ConfirmDialog
          open
          onOpenChange={(open) => !open && setPendingDeletion(null)}
          {...removeFileConfirmation(
            recordings,
            pendingDeletion.file.uuid,
            removed.size > 0,
          )}
          confirmText="Delete"
          onConfirm={confirmDelete}
        />
      )}

      <ul className="space-y-2" data-testid="dive-file-list">
        {storedRows.map((row) => (
          <FileRow
            key={row.key}
            row={row}
            isRemoved={row.kind === "file" && removed.has(row.file.uuid)}
            onDelete={
              row.kind === "file" && onRemoveStored
                ? () => setPendingDeletion(row)
                : undefined
            }
            onRestore={
              row.kind === "file" && onRestoreStored
                ? () => onRestoreStored(row.file.uuid)
                : undefined
            }
          />
        ))}
        {pending.map((item) => (
          <PendingRow
            key={item.id}
            item={item}
            onRemove={() => onRemovePending(item.id)}
          />
        ))}
      </ul>
    </>
  );
}

// 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 (
    <li
      data-testid="dive-file-row"
      className="flex items-start justify-between gap-2 rounded-md border px-3 py-2"
    >
      <div className="flex min-w-0 items-start gap-2">
        <Paperclip className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
        <div className="min-w-0">{children}</div>
      </div>
      {action}
    </li>
  );
}

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 (
      <Row action={null}>
        <div className="text-sm font-medium break-words">{device}</div>
        {row.recordingName && (
          <div className="text-xs text-muted-foreground">
            {row.recordingName}
          </div>
        )}
        <div className="text-sm text-muted-foreground">{row.reason}</div>
      </Row>
    );
  }

  return (
    <Row
      action={
        isRemoved
          ? onRestore && (
              <IconTooltip label={`Keep ${row.file.original_filename}`}>
                <Button
                  type="button"
                  variant="ghost"
                  size="sm"
                  onClick={onRestore}
                >
                  <Undo2 className="h-4 w-4" />
                </Button>
              </IconTooltip>
            )
          : 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.
              <IconTooltip label={`Delete ${row.file.original_filename}`}>
                <Button
                  type="button"
                  variant="ghost"
                  size="sm"
                  onClick={onDelete}
                >
                  <Trash2 className="h-4 w-4" />
                </Button>
              </IconTooltip>
            )
      }
    >
      <div
        className={
          isRemoved
            ? "text-sm font-medium break-all text-muted-foreground line-through"
            : "text-sm font-medium break-all"
        }
      >
        {row.file.original_filename}
      </div>
      <div className="text-sm text-muted-foreground break-words">
        {device}
        {row.recordingName ? ` · ${row.recordingName}` : ""}
      </div>
      <div className="text-xs text-muted-foreground">
        {/* 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)}`}
      </div>
    </Row>
  );
}

function PendingRow({
  item,
  onRemove,
}: {
  item: PendingDiveFile;
  onRemove: () => void;
}) {
  return (
    <Row
      action={
        <IconTooltip label={`Remove ${item.file.name}`}>
          <Button type="button" variant="ghost" size="sm" onClick={onRemove}>
            <Trash2 className="h-4 w-4" />
          </Button>
        </IconTooltip>
      }
    >
      <div className="text-sm font-medium break-all">{item.file.name}</div>
      <div className="text-sm text-muted-foreground break-words">
        {item.deviceLabel ?? UNNAMED_DEVICE_LABEL}
      </div>
      <div className="text-xs text-muted-foreground">
        Will be attached when you save · {formatFileSize(item.file.size)}
      </div>
    </Row>
  );
}