import ActivityIndicator from "../indicators/activity-indicator"; import { Button } from "../ui/button"; import { Progress } from "../ui/progress"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { isMobile } from "react-device-detect"; import { FiMoreVertical } from "react-icons/fi"; import { Skeleton } from "../ui/skeleton"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogTitle, } from "../ui/dialog"; import { Input } from "../ui/input"; import useKeyboardListener from "@/hooks/use-keyboard-listener"; import { DeleteClipType, Export, ExportCase, ExportJob } from "@/types/export"; import { baseUrl } from "@/api/baseUrl"; import { cn } from "@/lib/utils"; import { shareOrCopy } from "@/utils/browserUtil"; import { useTranslation } from "react-i18next"; import { ImageShadowOverlay } from "../overlay/ImageShadowOverlay"; import BlurredIconButton from "../button/BlurredIconButton"; import { useIsAdmin } from "@/hooks/use-is-admin"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "../ui/dropdown-menu"; import { FaFolder, FaVideo } from "react-icons/fa"; import { HiSquare2Stack } from "react-icons/hi2"; import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name"; import useContextMenu from "@/hooks/use-contextmenu"; type CaseCardProps = { className: string; exportCase: ExportCase; exports: Export[]; onSelect: () => void; }; export function CaseCard({ className, exportCase, exports, onSelect, }: CaseCardProps) { const { t } = useTranslation(["views/exports"]); const firstExport = useMemo( () => exports.find((exp) => exp.thumb_path && exp.thumb_path.length > 0), [exports], ); const cameraCount = useMemo( () => new Set(exports.map((exp) => exp.camera)).size, [exports], ); return (
onSelect()} > {firstExport && ( )} {!firstExport && (
)}
{exports.length}
{cameraCount}
{exportCase.name}
{exports.length === 0 && (
{t("caseCard.emptyCase")}
)}
); } type ExportCardProps = { className: string; exportedRecording: Export; isSelected?: boolean; selectionMode?: boolean; onSelect: (selected: Export) => void; onContextSelect?: (selected: Export) => void; onRename: (original: string, update: string) => void; onDelete: ({ file, exportName }: DeleteClipType) => void; onAssignToCase?: (selected: Export) => void; onRemoveFromCase?: (selected: Export) => void; }; export function ExportCard({ className, exportedRecording, isSelected, selectionMode, onSelect, onContextSelect, onRename, onDelete, onAssignToCase, onRemoveFromCase, }: ExportCardProps) { const { t } = useTranslation(["views/exports"]); const isAdmin = useIsAdmin(); const [loading, setLoading] = useState( exportedRecording.thumb_path.length > 0, ); // Resync the skeleton state whenever the backing export changes. The // list keys by id now, so in practice the component remounts instead // of receiving new props — but this keeps the card honest if a parent // ever reuses the instance across different exports. useEffect(() => { setLoading(exportedRecording.thumb_path.length > 0); }, [exportedRecording.thumb_path]); // selection const cardRef = useRef(null); useContextMenu(cardRef, () => { if (!exportedRecording.in_progress && onContextSelect) { onContextSelect(exportedRecording); } }); // editing name const [editName, setEditName] = useState<{ original: string; update?: string; }>(); const submitRename = useCallback(() => { if (editName == undefined) { return; } onRename(exportedRecording.id, editName.update ?? ""); setEditName(undefined); }, [editName, exportedRecording, onRename, setEditName]); useKeyboardListener( editName != undefined ? ["Enter"] : [], (key, modifiers) => { if ( key == "Enter" && modifiers.down && !modifiers.repeat && editName && (editName.update?.length ?? 0) > 0 ) { submitRename(); return true; } return false; }, ); return ( <> { if (!open) { setEditName(undefined); } }} > { if (isMobile) { e.preventDefault(); } }} > {t("editExport.title")} {t("editExport.desc")} {editName && ( <> setEditName({ original: editName.original ?? "", update: e.target.value, }) } /> )}
{ if (!exportedRecording.in_progress) { if ((selectionMode || e.ctrlKey || e.metaKey) && onContextSelect) { onContextSelect(exportedRecording); } else { onSelect(exportedRecording); } } }} > {exportedRecording.in_progress ? ( ) : ( <> {exportedRecording.thumb_path.length > 0 ? ( setLoading(false)} /> ) : (
)} )} {!exportedRecording.in_progress && !selectionMode && (
e.stopPropagation()} > { e.stopPropagation(); shareOrCopy( `${baseUrl}export?id=${exportedRecording.id}`, exportedRecording.name.replaceAll("_", " "), ); }} > {t("tooltip.shareExport")} e.stopPropagation()} > {t("tooltip.downloadVideo")} {isAdmin && onAssignToCase && ( { e.stopPropagation(); onAssignToCase(exportedRecording); }} > {t("tooltip.assignToCase")} )} {isAdmin && onRemoveFromCase && ( { e.stopPropagation(); onRemoveFromCase(exportedRecording); }} > {t("tooltip.removeFromCase")} )} {isAdmin && ( { e.stopPropagation(); setEditName({ original: exportedRecording.name, update: undefined, }); }} > {t("tooltip.editName")} )} {isAdmin && ( { e.stopPropagation(); onDelete({ file: exportedRecording.id, exportName: exportedRecording.name, }); }} > {t("tooltip.deleteExport")} )}
)} {loading && ( )}
{exportedRecording.name.replaceAll("_", " ")}
); } type ActiveExportJobCardProps = { className?: string; job: ExportJob; }; export function ActiveExportJobCard({ className = "", job, }: ActiveExportJobCardProps) { const { t } = useTranslation(["views/exports", "common"]); const cameraName = useCameraFriendlyName(job.camera); const displayName = useMemo(() => { if (job.name && job.name.length > 0) { return job.name.replaceAll("_", " "); } return t("jobCard.defaultName", { camera: cameraName, }); }, [cameraName, job.name, t]); const step = job.current_step ? job.current_step : job.status === "queued" ? "queued" : "preparing"; const percent = Math.round(job.progress_percent ?? 0); const stepLabel = useMemo(() => { switch (step) { case "queued": return t("jobCard.queued"); case "preparing": return t("jobCard.preparing"); case "copying": return t("jobCard.copying"); case "encoding": return t("jobCard.encoding"); case "encoding_retry": return t("jobCard.encodingRetry"); case "finalizing": return t("jobCard.finalizing"); default: return t("jobCard.running"); } }, [step, t]); const hasDeterminateProgress = step === "copying" || step === "encoding" || step === "encoding_retry"; return (
{stepLabel} {hasDeterminateProgress && ` · ${percent}%`}
{step === "queued" ? ( ) : hasDeterminateProgress ? ( ) : (
)}
{displayName}
); }