mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-06-26 06:11:54 +03:00
Some checks failed
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
* remove redundant per-view toasters in settings * add variants to standardize dialog footer button layouts * remove text-md this class name compiles to nothing in tailwind. we used to add it to prevent iOS from zooming when focusing on an input, but that is now solved via the viewport meta in index.html * make wizard footers consistent with dialog footers * consistent destructive button style remove text-white from individual buttons and add it to the variant
529 lines
17 KiB
TypeScript
529 lines
17 KiB
TypeScript
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";
|
|
import axios from "axios";
|
|
import { toast } from "sonner";
|
|
import { useNavigate } from "react-router-dom";
|
|
|
|
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 (
|
|
<div
|
|
className={cn(
|
|
"relative flex aspect-video size-full cursor-pointer items-center justify-center overflow-hidden rounded-lg bg-secondary md:rounded-2xl",
|
|
className,
|
|
)}
|
|
onClick={() => onSelect()}
|
|
>
|
|
{firstExport && (
|
|
<img
|
|
className="absolute inset-0 size-full object-cover"
|
|
src={`${baseUrl}${firstExport.thumb_path.replace("/media/frigate/", "")}`}
|
|
alt=""
|
|
/>
|
|
)}
|
|
{!firstExport && (
|
|
<div className="absolute inset-0 bg-gradient-to-br from-secondary via-secondary/80 to-muted" />
|
|
)}
|
|
<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 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>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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", "views/replay"]);
|
|
const navigate = useNavigate();
|
|
const isAdmin = useIsAdmin();
|
|
const [loading, setLoading] = useState(
|
|
exportedRecording.thumb_path.length > 0,
|
|
);
|
|
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]);
|
|
|
|
// 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<HTMLDivElement | null>(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 (
|
|
<>
|
|
<Dialog
|
|
open={editName != undefined}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setEditName(undefined);
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent
|
|
onOpenAutoFocus={(e) => {
|
|
if (isMobile) {
|
|
e.preventDefault();
|
|
}
|
|
}}
|
|
>
|
|
<DialogTitle>{t("editExport.title")}</DialogTitle>
|
|
<DialogDescription>{t("editExport.desc")}</DialogDescription>
|
|
{editName && (
|
|
<>
|
|
<Input
|
|
className="mt-3"
|
|
type="search"
|
|
placeholder={editName?.original}
|
|
value={
|
|
editName?.update == undefined
|
|
? editName?.original
|
|
: editName?.update
|
|
}
|
|
onChange={(e) =>
|
|
setEditName({
|
|
original: editName.original ?? "",
|
|
update: e.target.value,
|
|
})
|
|
}
|
|
/>
|
|
<DialogFooter>
|
|
<Button
|
|
aria-label={t("editExport.saveExport")}
|
|
variant="select"
|
|
disabled={(editName?.update?.length ?? 0) == 0}
|
|
onClick={() => submitRename()}
|
|
>
|
|
{t("button.save", { ns: "common" })}
|
|
</Button>
|
|
</DialogFooter>
|
|
</>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<div
|
|
ref={cardRef}
|
|
className={cn(
|
|
"relative flex aspect-video cursor-pointer items-center justify-center rounded-lg bg-black md:rounded-2xl",
|
|
className,
|
|
)}
|
|
onClick={(e) => {
|
|
if (!exportedRecording.in_progress) {
|
|
if ((selectionMode || e.ctrlKey || e.metaKey) && onContextSelect) {
|
|
onContextSelect(exportedRecording);
|
|
} else {
|
|
onSelect(exportedRecording);
|
|
}
|
|
}
|
|
}}
|
|
>
|
|
{exportedRecording.in_progress ? (
|
|
<ActivityIndicator />
|
|
) : (
|
|
<>
|
|
{exportedRecording.thumb_path.length > 0 ? (
|
|
<img
|
|
className="absolute inset-0 aspect-video size-full rounded-lg object-cover md:rounded-2xl"
|
|
src={`${baseUrl}${exportedRecording.thumb_path.replace("/media/frigate/", "")}`}
|
|
onLoad={() => setLoading(false)}
|
|
/>
|
|
) : (
|
|
<div className="absolute inset-0 rounded-lg bg-secondary md:rounded-2xl" />
|
|
)}
|
|
</>
|
|
)}
|
|
{!exportedRecording.in_progress && !selectionMode && (
|
|
<div className="absolute bottom-2 right-3 z-40">
|
|
<DropdownMenu>
|
|
<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")}
|
|
>
|
|
<a
|
|
download
|
|
href={`${baseUrl}${exportedRecording.video_path.replace("/media/frigate/", "")}`}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{t("tooltip.downloadVideo")}
|
|
</a>
|
|
</DropdownMenuItem>
|
|
{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>
|
|
)}
|
|
{isAdmin && onAssignToCase && (
|
|
<DropdownMenuItem
|
|
className="cursor-pointer"
|
|
aria-label={t("tooltip.assignToCase")}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onAssignToCase(exportedRecording);
|
|
}}
|
|
>
|
|
{t("tooltip.assignToCase")}
|
|
</DropdownMenuItem>
|
|
)}
|
|
{isAdmin && onRemoveFromCase && (
|
|
<DropdownMenuItem
|
|
className="cursor-pointer"
|
|
aria-label={t("tooltip.removeFromCase")}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onRemoveFromCase(exportedRecording);
|
|
}}
|
|
>
|
|
{t("tooltip.removeFromCase")}
|
|
</DropdownMenuItem>
|
|
)}
|
|
{isAdmin && (
|
|
<DropdownMenuItem
|
|
className="cursor-pointer"
|
|
aria-label={t("tooltip.editName")}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setEditName({
|
|
original: exportedRecording.name,
|
|
update: undefined,
|
|
});
|
|
}}
|
|
>
|
|
{t("tooltip.editName")}
|
|
</DropdownMenuItem>
|
|
)}
|
|
{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>
|
|
)}
|
|
{loading && (
|
|
<Skeleton className="absolute inset-0 aspect-video rounded-lg md:rounded-2xl" />
|
|
)}
|
|
<ImageShadowOverlay />
|
|
<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>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<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>
|
|
)}
|
|
<div className="text-sm font-medium text-primary">{displayName}</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|