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

302 lines
9.4 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";
2026-01-03 08:03:33 -07:00
import { useCallback, useMemo, 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";
2025-12-15 13:10:50 -07:00
import { DeleteClipType, Export, ExportCase } 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";
import { FaFolder } from "react-icons/fa";
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) {
const firstExport = useMemo(
() => exports.find((exp) => exp.thumb_path && exp.thumb_path.length > 0),
[exports],
);
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=""
/>
)}
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-16 bg-gradient-to-t from-black/60 to-transparent" />
<div className="absolute bottom-2 left-2 z-20 flex items-center justify-start gap-2 text-white">
2025-12-15 13:10:50 -07:00
<FaFolder />
<div className="capitalize">{exportCase.name}</div>
</div>
</div>
);
}
type ExportCardProps = {
2024-04-07 14:35:45 -06:00
className: string;
2024-04-19 16:11:41 -06:00
exportedRecording: Export;
onSelect: (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;
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,
onSelect,
2024-04-07 14:35:45 -06:00
onRename,
onDelete,
2025-12-15 13:10:50 -07:00
onAssignToCase,
}: ExportCardProps) {
const { t } = useTranslation(["views/exports"]);
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
);
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
2024-09-12 22:07:35 -05:00
className="text-md 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
size="sm"
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
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,
)}
2025-12-15 13:10:50 -07:00
onClick={() => {
if (!exportedRecording.in_progress) {
onSelect(exportedRecording);
}
}}
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" />
)}
</>
)}
2025-12-15 13:10:50 -07:00
{!exportedRecording.in_progress && (
<div className="absolute bottom-2 right-3 z-40">
<DropdownMenu modal={false}>
<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>
{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
)}
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 />
2025-10-27 07:44:34 -05:00
<div className="absolute bottom-2 left-3 flex items-end text-white smart-capitalize">
2025-10-22 07:36:09 -06:00
{exportedRecording.name.replaceAll("_", " ")}
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
);
}