Files
frigate/web/src/components/card/ExportCard.tsx
T

529 lines
17 KiB
TypeScript
Raw Normal View History

2024-03-03 10:32:47 -06:00
import ActivityIndicator from "../indicators/activity-indicator";
2023-12-12 16:21:42 -07:00
import { Button } from "../ui/button";
import { Progress } from "../ui/progress";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2025-12-15 13:10:50 -07:00
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";
2024-04-03 08:02:07 -06:00
import { Input } from "../ui/input";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
2026-04-14 09:19:50 -05:00
import { DeleteClipType, Export, ExportCase, ExportJob } from "@/types/export";
2024-04-19 16:11:41 -06:00
import { baseUrl } from "@/api/baseUrl";
import { cn } from "@/lib/utils";
2024-09-12 08:46:29 -06:00
import { shareOrCopy } from "@/utils/browserUtil";
import { useTranslation } from "react-i18next";
2025-10-22 07:36:09 -06:00
import { ImageShadowOverlay } from "../overlay/ImageShadowOverlay";
2025-10-27 07:44:34 -05:00
import BlurredIconButton from "../button/BlurredIconButton";
2025-12-04 12:19:07 -06:00
import { useIsAdmin } from "@/hooks/use-is-admin";
2025-12-15 13:10:50 -07:00
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
2026-04-14 09:19:50 -05:00
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";
2026-05-18 22:52:40 -05:00
import axios from "axios";
import { toast } from "sonner";
import { useNavigate } from "react-router-dom";
2023-12-12 16:21:42 -07:00
2025-12-15 13:10:50 -07:00
type CaseCardProps = {
className: string;
exportCase: ExportCase;
2026-01-03 08:03:33 -07:00
exports: Export[];
2025-12-15 13:10:50 -07:00
onSelect: () => void;
};
2026-01-03 08:03:33 -07:00
export function CaseCard({
className,
exportCase,
exports,
onSelect,
}: CaseCardProps) {
2026-04-14 09:19:50 -05:00
const { t } = useTranslation(["views/exports"]);
2026-01-03 08:03:33 -07:00
const firstExport = useMemo(
() => exports.find((exp) => exp.thumb_path && exp.thumb_path.length > 0),
[exports],
);
2026-04-14 09:19:50 -05:00
const cameraCount = useMemo(
() => new Set(exports.map((exp) => exp.camera)).size,
[exports],
);
2026-01-03 08:03:33 -07:00
2025-12-15 13:10:50 -07:00
return (
<div
className={cn(
2026-01-03 08:03:33 -07:00
"relative flex aspect-video size-full cursor-pointer items-center justify-center overflow-hidden rounded-lg bg-secondary md:rounded-2xl",
2025-12-15 13:10:50 -07:00
className,
)}
onClick={() => onSelect()}
>
2026-01-03 08:03:33 -07:00
{firstExport && (
<img
className="absolute inset-0 size-full object-cover"
src={`${baseUrl}${firstExport.thumb_path.replace("/media/frigate/", "")}`}
alt=""
/>
)}
2026-04-14 09:19:50 -05:00
{!firstExport && (
<div className="absolute inset-0 bg-gradient-to-br from-secondary via-secondary/80 to-muted" />
)}
2026-01-03 08:03:33 -07:00
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-16 bg-gradient-to-t from-black/60 to-transparent" />
2026-04-14 09:19:50 -05:00
<div className="absolute right-1 top-1 z-40 flex items-center gap-2 rounded-lg bg-black/50 px-2 py-1 text-xs text-white">
<div className="flex items-center gap-1">
<HiSquare2Stack className="size-3" />
<div>{exports.length}</div>
</div>
<div className="flex items-center gap-1">
<FaVideo className="size-3" />
<div>{cameraCount}</div>
</div>
</div>
<div className="absolute inset-x-2 bottom-2 z-20 text-white">
<div className="flex items-center justify-start gap-2">
<FaFolder />
<div className="truncate smart-capitalize">{exportCase.name}</div>
</div>
{exports.length === 0 && (
<div className="mt-1 text-xs text-white/80">
{t("caseCard.emptyCase")}
</div>
)}
2025-12-15 13:10:50 -07:00
</div>
</div>
);
}
type ExportCardProps = {
2024-04-07 14:35:45 -06:00
className: string;
2024-04-19 16:11:41 -06:00
exportedRecording: Export;
2026-04-14 09:19:50 -05:00
isSelected?: boolean;
selectionMode?: boolean;
2024-04-19 16:11:41 -06:00
onSelect: (selected: Export) => void;
2026-04-14 09:19:50 -05:00
onContextSelect?: (selected: Export) => void;
2024-04-03 08:02:07 -06:00
onRename: (original: string, update: string) => void;
onDelete: ({ file, exportName }: DeleteClipType) => void;
2025-12-15 13:10:50 -07:00
onAssignToCase?: (selected: Export) => void;
2026-04-14 09:19:50 -05:00
onRemoveFromCase?: (selected: Export) => void;
2023-12-12 16:21:42 -07:00
};
2025-12-15 13:10:50 -07:00
export function ExportCard({
2024-04-07 14:35:45 -06:00
className,
2024-04-19 16:11:41 -06:00
exportedRecording,
2026-04-14 09:19:50 -05:00
isSelected,
selectionMode,
2024-04-19 16:11:41 -06:00
onSelect,
2026-04-14 09:19:50 -05:00
onContextSelect,
2024-04-07 14:35:45 -06:00
onRename,
onDelete,
2025-12-15 13:10:50 -07:00
onAssignToCase,
2026-04-14 09:19:50 -05:00
onRemoveFromCase,
2025-12-15 13:10:50 -07:00
}: ExportCardProps) {
2026-05-18 22:52:40 -05:00
const { t } = useTranslation(["views/exports", "views/replay"]);
const navigate = useNavigate();
2025-12-04 12:19:07 -06:00
const isAdmin = useIsAdmin();
2024-04-19 16:11:41 -06:00
const [loading, setLoading] = useState(
exportedRecording.thumb_path.length > 0,
2024-03-10 07:25:16 -06:00
);
2026-05-18 22:52:40 -05:00
const [isStartingReplay, setIsStartingReplay] = useState(false);
const handleDebugReplay = useCallback(() => {
setIsStartingReplay(true);
axios
.post("debug_replay/start_from_export", {
export_id: exportedRecording.id,
})
.then((response) => {
if (response.status === 202 || response.status === 200) {
navigate("/replay");
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
if (error.response?.status === 409) {
toast.error(t("dialog.toast.alreadyActive", { ns: "views/replay" }), {
position: "top-center",
closeButton: true,
dismissible: false,
action: (
<a
href={`${baseUrl}replay`}
target="_blank"
rel="noopener noreferrer"
>
<Button>
{t("dialog.toast.goToReplay", { ns: "views/replay" })}
</Button>
</a>
),
});
} else {
toast.error(
t("dialog.toast.error", {
ns: "views/replay",
error: errorMessage,
}),
{ position: "top-center" },
);
}
})
.finally(() => {
setIsStartingReplay(false);
});
}, [exportedRecording.id, navigate, t]);
2024-03-10 07:25:16 -06:00
// 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]);
2026-04-14 09:19:50 -05:00
// selection
const cardRef = useRef<HTMLDivElement | null>(null);
useContextMenu(cardRef, () => {
if (!exportedRecording.in_progress && onContextSelect) {
onContextSelect(exportedRecording);
}
});
2024-04-03 08:02:07 -06:00
// editing name
const [editName, setEditName] = useState<{
original: string;
2024-08-13 09:12:06 -06:00
update?: string;
2024-04-03 08:02:07 -06:00
}>();
2024-07-17 07:39:37 -06:00
const submitRename = useCallback(() => {
if (editName == undefined) {
return;
}
2024-08-13 09:12:06 -06:00
onRename(exportedRecording.id, editName.update ?? "");
2024-07-17 07:39:37 -06:00
setEditName(undefined);
}, [editName, exportedRecording, onRename, setEditName]);
2024-04-03 08:02:07 -06:00
useKeyboardListener(
editName != undefined ? ["Enter"] : [],
(key, modifiers) => {
if (
key == "Enter" &&
modifiers.down &&
!modifiers.repeat &&
editName &&
2024-08-13 09:12:06 -06:00
(editName.update?.length ?? 0) > 0
) {
2024-07-17 07:39:37 -06:00
submitRename();
2025-10-02 07:21:37 -06:00
return true;
2024-04-03 08:02:07 -06:00
}
2025-10-02 07:21:37 -06:00
return false;
2024-04-03 08:02:07 -06:00
},
);
2023-12-12 16:21:42 -07:00
return (
2024-04-03 08:02:07 -06:00
<>
<Dialog
open={editName != undefined}
onOpenChange={(open) => {
if (!open) {
setEditName(undefined);
}
}}
>
2024-09-12 22:07:35 -05:00
<DialogContent
onOpenAutoFocus={(e) => {
if (isMobile) {
e.preventDefault();
}
}}
>
<DialogTitle>{t("editExport.title")}</DialogTitle>
<DialogDescription>{t("editExport.desc")}</DialogDescription>
2024-04-03 08:02:07 -06:00
{editName && (
<>
<Input
2026-05-29 17:00:30 -05:00
className="mt-3"
2024-04-03 08:02:07 -06:00
type="search"
placeholder={editName?.original}
2024-08-13 09:12:06 -06:00
value={
editName?.update == undefined
? editName?.original
: editName?.update
}
2024-04-03 08:02:07 -06:00
onChange={(e) =>
setEditName({
original: editName.original ?? "",
update: e.target.value,
})
}
/>
<DialogFooter>
<Button
aria-label={t("editExport.saveExport")}
2024-04-03 08:02:07 -06:00
variant="select"
disabled={(editName?.update?.length ?? 0) == 0}
2024-07-17 07:39:37 -06:00
onClick={() => submitRename()}
2024-04-03 08:02:07 -06:00
>
{t("button.save", { ns: "common" })}
2024-04-03 08:02:07 -06:00
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
<div
2026-04-14 09:19:50 -05:00
ref={cardRef}
className={cn(
2025-12-15 13:10:50 -07:00
"relative flex aspect-video cursor-pointer items-center justify-center rounded-lg bg-black md:rounded-2xl",
className,
)}
2026-04-14 09:19:50 -05:00
onClick={(e) => {
2025-12-15 13:10:50 -07:00
if (!exportedRecording.in_progress) {
2026-04-14 09:19:50 -05:00
if ((selectionMode || e.ctrlKey || e.metaKey) && onContextSelect) {
onContextSelect(exportedRecording);
} else {
onSelect(exportedRecording);
}
2025-12-15 13:10:50 -07:00
}
}}
2024-04-03 08:02:07 -06:00
>
2024-08-05 07:20:21 -06:00
{exportedRecording.in_progress ? (
<ActivityIndicator />
) : (
2024-04-03 08:02:07 -06:00
<>
2024-08-05 07:20:21 -06:00
{exportedRecording.thumb_path.length > 0 ? (
<img
2025-10-22 07:36:09 -06:00
className="absolute inset-0 aspect-video size-full rounded-lg object-cover md:rounded-2xl"
2024-08-05 07:20:21 -06:00
src={`${baseUrl}${exportedRecording.thumb_path.replace("/media/frigate/", "")}`}
onLoad={() => setLoading(false)}
/>
) : (
<div className="absolute inset-0 rounded-lg bg-secondary md:rounded-2xl" />
)}
</>
)}
2026-04-14 09:19:50 -05:00
{!exportedRecording.in_progress && !selectionMode && (
2025-12-15 13:10:50 -07:00
<div className="absolute bottom-2 right-3 z-40">
2026-04-25 08:21:13 -05:00
<DropdownMenu>
2025-12-15 13:10:50 -07:00
<DropdownMenuTrigger>
<BlurredIconButton
aria-label={t("tooltip.editName")}
onClick={(e) => e.stopPropagation()}
>
<FiMoreVertical className="size-5" />
</BlurredIconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="cursor-pointer"
aria-label={t("tooltip.shareExport")}
onClick={(e) => {
e.stopPropagation();
shareOrCopy(
`${baseUrl}export?id=${exportedRecording.id}`,
exportedRecording.name.replaceAll("_", " "),
);
}}
>
{t("tooltip.shareExport")}
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
aria-label={t("tooltip.downloadVideo")}
>
2025-10-27 07:44:34 -05:00
<a
download
href={`${baseUrl}${exportedRecording.video_path.replace("/media/frigate/", "")}`}
2025-12-15 13:10:50 -07:00
onClick={(e) => e.stopPropagation()}
2025-10-27 07:44:34 -05:00
>
2025-12-15 13:10:50 -07:00
{t("tooltip.downloadVideo")}
2025-10-27 07:44:34 -05:00
</a>
2025-12-15 13:10:50 -07:00
</DropdownMenuItem>
2026-05-18 22:52:40 -05:00
{isAdmin && (
<DropdownMenuItem
className="cursor-pointer"
aria-label={t("title", { ns: "views/replay" })}
disabled={isStartingReplay}
onClick={(e) => {
e.stopPropagation();
handleDebugReplay();
}}
>
{isStartingReplay
? t("dialog.starting", { ns: "views/replay" })
: t("title", { ns: "views/replay" })}
</DropdownMenuItem>
)}
2025-12-15 13:10:50 -07:00
{isAdmin && onAssignToCase && (
<DropdownMenuItem
className="cursor-pointer"
aria-label={t("tooltip.assignToCase")}
onClick={(e) => {
e.stopPropagation();
onAssignToCase(exportedRecording);
}}
>
{t("tooltip.assignToCase")}
</DropdownMenuItem>
2025-10-27 07:44:34 -05:00
)}
2026-04-14 09:19:50 -05:00
{isAdmin && onRemoveFromCase && (
<DropdownMenuItem
className="cursor-pointer"
aria-label={t("tooltip.removeFromCase")}
onClick={(e) => {
e.stopPropagation();
onRemoveFromCase(exportedRecording);
}}
>
{t("tooltip.removeFromCase")}
</DropdownMenuItem>
)}
2025-12-04 12:19:07 -06:00
{isAdmin && (
2025-12-15 13:10:50 -07:00
<DropdownMenuItem
className="cursor-pointer"
aria-label={t("tooltip.editName")}
onClick={(e) => {
e.stopPropagation();
setEditName({
original: exportedRecording.name,
update: undefined,
});
}}
>
{t("tooltip.editName")}
</DropdownMenuItem>
2025-12-04 12:19:07 -06:00
)}
2025-12-15 13:10:50 -07:00
{isAdmin && (
<DropdownMenuItem
className="cursor-pointer"
aria-label={t("tooltip.deleteExport")}
onClick={(e) => {
e.stopPropagation();
onDelete({
file: exportedRecording.id,
exportName: exportedRecording.name,
});
}}
>
{t("tooltip.deleteExport")}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
2024-04-03 08:02:07 -06:00
)}
{loading && (
<Skeleton className="absolute inset-0 aspect-video rounded-lg md:rounded-2xl" />
2024-04-03 08:02:07 -06:00
)}
2025-10-22 07:36:09 -06:00
<ImageShadowOverlay />
2026-04-14 09:19:50 -05:00
<div
className={cn(
"pointer-events-none absolute inset-0 z-10 size-full rounded-lg outline outline-[3px] -outline-offset-[2.8px] md:rounded-2xl",
isSelected
? "shadow-selected outline-selected"
: "outline-transparent duration-500",
)}
/>
<div className="absolute bottom-2 left-3 right-12 z-30 text-white">
<div className="truncate smart-capitalize">
{exportedRecording.name.replaceAll("_", " ")}
</div>
2024-04-19 16:11:41 -06:00
</div>
2024-04-03 08:02:07 -06:00
</div>
</>
2024-03-10 07:25:16 -06:00
);
}
2026-04-14 09:19:50 -05:00
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";
2026-04-14 09:19:50 -05:00
return (
<div
className={cn(
"relative flex aspect-video items-center justify-center overflow-hidden rounded-lg border border-dashed border-border bg-secondary/40 md:rounded-2xl",
className,
)}
>
<div className="flex w-full max-w-xs flex-col items-center gap-2 space-y-2 px-6 text-center">
<div className="text-xs text-muted-foreground">
{stepLabel}
{hasDeterminateProgress && ` · ${percent}%`}
</div>
{step === "queued" ? (
<ActivityIndicator className="size-5" />
) : hasDeterminateProgress ? (
<Progress value={percent} className="h-2 w-full" />
) : (
<div className="relative h-2 w-full overflow-hidden rounded-full bg-secondary">
<div className="absolute inset-y-0 left-0 w-1/2 animate-pulse bg-primary" />
</div>
)}
2026-04-14 09:19:50 -05:00
<div className="text-sm font-medium text-primary">{displayName}</div>
</div>
</div>
);
}