mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-01-22 20:18:30 +03:00
* Refactor export cards to match existing cards in other UI pages * Show cases separately from exports * Add proper filtering and display of cases * Add ability to edit and select cases for exports * Cleanup typing * Hide if no unassigned * Cleanup hiding logic * fix scrolling * Improve layout
581 lines
16 KiB
TypeScript
581 lines
16 KiB
TypeScript
import { baseUrl } from "@/api/baseUrl";
|
|
import { CaseCard, ExportCard } from "@/components/card/ExportCard";
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from "@/components/ui/alert-dialog";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
|
import Heading from "@/components/ui/heading";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Toaster } from "@/components/ui/sonner";
|
|
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
|
import { useOverlayState, useSearchEffect } from "@/hooks/use-overlay-state";
|
|
import { cn } from "@/lib/utils";
|
|
import { DeleteClipType, Export, ExportCase } from "@/types/export";
|
|
import OptionAndInputDialog from "@/components/overlay/dialog/OptionAndInputDialog";
|
|
import axios from "axios";
|
|
|
|
import {
|
|
MutableRefObject,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import { isMobile } from "react-device-detect";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
import { LuFolderX } from "react-icons/lu";
|
|
import { toast } from "sonner";
|
|
import useSWR from "swr";
|
|
|
|
function Exports() {
|
|
const { t } = useTranslation(["views/exports"]);
|
|
|
|
useEffect(() => {
|
|
document.title = t("documentTitle");
|
|
}, [t]);
|
|
|
|
// Data
|
|
|
|
const { data: cases, mutate: updateCases } = useSWR<ExportCase[]>("cases");
|
|
const { data: rawExports, mutate: updateExports } =
|
|
useSWR<Export[]>("exports");
|
|
|
|
const exports = useMemo<Export[]>(
|
|
() => (rawExports ?? []).filter((e) => !e.export_case),
|
|
[rawExports],
|
|
);
|
|
|
|
const mutate = useCallback(() => {
|
|
updateExports();
|
|
updateCases();
|
|
}, [updateExports, updateCases]);
|
|
|
|
// Search
|
|
|
|
const [search, setSearch] = useState("");
|
|
|
|
// Viewing
|
|
|
|
const [selected, setSelected] = useState<Export>();
|
|
const [selectedCaseId, setSelectedCaseId] = useOverlayState<
|
|
string | undefined
|
|
>("caseId", undefined);
|
|
const [selectedAspect, setSelectedAspect] = useState(0.0);
|
|
|
|
useSearchEffect("id", (id) => {
|
|
if (!exports) {
|
|
return false;
|
|
}
|
|
|
|
setSelected(exports.find((exp) => exp.id == id));
|
|
return true;
|
|
});
|
|
|
|
useSearchEffect("caseId", (caseId: string) => {
|
|
if (!cases) {
|
|
return false;
|
|
}
|
|
|
|
const exists = cases.some((c) => c.id === caseId);
|
|
|
|
if (!exists) {
|
|
return false;
|
|
}
|
|
|
|
setSelectedCaseId(caseId);
|
|
return true;
|
|
});
|
|
|
|
// Modifying
|
|
|
|
const [deleteClip, setDeleteClip] = useState<DeleteClipType | undefined>();
|
|
const [exportToAssign, setExportToAssign] = useState<Export | undefined>();
|
|
|
|
const onHandleDelete = useCallback(() => {
|
|
if (!deleteClip) {
|
|
return;
|
|
}
|
|
|
|
axios.delete(`export/${deleteClip.file}`).then((response) => {
|
|
if (response.status == 200) {
|
|
setDeleteClip(undefined);
|
|
mutate();
|
|
}
|
|
});
|
|
}, [deleteClip, mutate]);
|
|
|
|
const onHandleRename = useCallback(
|
|
(id: string, update: string) => {
|
|
axios
|
|
.patch(`export/${id}/rename`, {
|
|
name: update,
|
|
})
|
|
.then((response) => {
|
|
if (response.status === 200) {
|
|
setDeleteClip(undefined);
|
|
mutate();
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
const errorMessage =
|
|
error.response?.data?.message ||
|
|
error.response?.data?.detail ||
|
|
"Unknown error";
|
|
toast.error(t("toast.error.renameExportFailed", { errorMessage }), {
|
|
position: "top-center",
|
|
});
|
|
});
|
|
},
|
|
[mutate, setDeleteClip, t],
|
|
);
|
|
|
|
// Keyboard Listener
|
|
|
|
const contentRef = useRef<HTMLDivElement | null>(null);
|
|
useKeyboardListener([], undefined, contentRef);
|
|
|
|
const selectedCase = useMemo(
|
|
() => cases?.find((c) => c.id === selectedCaseId),
|
|
[cases, selectedCaseId],
|
|
);
|
|
|
|
const resetCaseDialog = useCallback(() => {
|
|
setExportToAssign(undefined);
|
|
}, []);
|
|
|
|
return (
|
|
<div className="flex size-full flex-col gap-2 overflow-hidden px-1 pt-2 md:p-2">
|
|
<Toaster closeButton={true} />
|
|
|
|
<CaseAssignmentDialog
|
|
exportToAssign={exportToAssign}
|
|
cases={cases}
|
|
selectedCaseId={selectedCaseId}
|
|
onClose={resetCaseDialog}
|
|
mutate={mutate}
|
|
/>
|
|
|
|
<AlertDialog
|
|
open={deleteClip != undefined}
|
|
onOpenChange={() => setDeleteClip(undefined)}
|
|
>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>{t("deleteExport")}</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{t("deleteExport.desc", { exportName: deleteClip?.exportName })}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>
|
|
{t("button.cancel", { ns: "common" })}
|
|
</AlertDialogCancel>
|
|
<Button
|
|
className="text-white"
|
|
aria-label="Delete Export"
|
|
variant="destructive"
|
|
onClick={() => onHandleDelete()}
|
|
>
|
|
{t("button.delete", { ns: "common" })}
|
|
</Button>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
|
|
<Dialog
|
|
open={selected != undefined}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setSelected(undefined);
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent
|
|
className={cn(
|
|
"max-h-[95dvh] sm:max-w-xl md:max-w-4xl lg:max-w-4xl xl:max-w-7xl",
|
|
isMobile && "landscape:max-w-[60%]",
|
|
)}
|
|
>
|
|
<DialogTitle className="smart-capitalize">
|
|
{selected?.name?.replaceAll("_", " ")}
|
|
</DialogTitle>
|
|
<video
|
|
className={cn(
|
|
"size-full rounded-lg md:rounded-2xl",
|
|
selectedAspect < 1.5 && "aspect-video h-full",
|
|
)}
|
|
playsInline
|
|
preload="auto"
|
|
autoPlay
|
|
controls
|
|
muted
|
|
onLoadedData={(e) =>
|
|
setSelectedAspect(
|
|
e.currentTarget.videoWidth / e.currentTarget.videoHeight,
|
|
)
|
|
}
|
|
>
|
|
<source
|
|
src={`${baseUrl}${selected?.video_path?.replace("/media/frigate/", "")}`}
|
|
type="video/mp4"
|
|
/>
|
|
</video>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{(exports?.length || cases?.length) && (
|
|
<div className="flex w-full items-center justify-center p-2">
|
|
<Input
|
|
className="text-md w-full bg-muted md:w-1/3"
|
|
placeholder={t("search")}
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{selectedCase ? (
|
|
<CaseView
|
|
contentRef={contentRef}
|
|
selectedCase={selectedCase}
|
|
exports={rawExports}
|
|
search={search}
|
|
setSelected={setSelected}
|
|
renameClip={onHandleRename}
|
|
setDeleteClip={setDeleteClip}
|
|
onAssignToCase={setExportToAssign}
|
|
/>
|
|
) : (
|
|
<AllExportsView
|
|
contentRef={contentRef}
|
|
search={search}
|
|
cases={cases}
|
|
exports={exports}
|
|
setSelectedCaseId={setSelectedCaseId}
|
|
setSelected={setSelected}
|
|
renameClip={onHandleRename}
|
|
setDeleteClip={setDeleteClip}
|
|
onAssignToCase={setExportToAssign}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type AllExportsViewProps = {
|
|
contentRef: MutableRefObject<HTMLDivElement | null>;
|
|
search: string;
|
|
cases?: ExportCase[];
|
|
exports: Export[];
|
|
setSelectedCaseId: (id: string) => void;
|
|
setSelected: (e: Export) => void;
|
|
renameClip: (id: string, update: string) => void;
|
|
setDeleteClip: (d: DeleteClipType | undefined) => void;
|
|
onAssignToCase: (e: Export) => void;
|
|
};
|
|
function AllExportsView({
|
|
contentRef,
|
|
search,
|
|
cases,
|
|
exports,
|
|
setSelectedCaseId,
|
|
setSelected,
|
|
renameClip,
|
|
setDeleteClip,
|
|
onAssignToCase,
|
|
}: AllExportsViewProps) {
|
|
const { t } = useTranslation(["views/exports"]);
|
|
|
|
// Filter
|
|
|
|
const filteredCases = useMemo(() => {
|
|
if (!search || !cases) {
|
|
return cases || [];
|
|
}
|
|
|
|
return cases.filter(
|
|
(caseItem) =>
|
|
caseItem.name.toLowerCase().includes(search.toLowerCase()) ||
|
|
(caseItem.description &&
|
|
caseItem.description.toLowerCase().includes(search.toLowerCase())),
|
|
);
|
|
}, [search, cases]);
|
|
|
|
const filteredExports = useMemo<Export[]>(() => {
|
|
if (!search) {
|
|
return exports;
|
|
}
|
|
|
|
return exports.filter((exp) =>
|
|
exp.name
|
|
.toLowerCase()
|
|
.replaceAll("_", " ")
|
|
.includes(search.toLowerCase()),
|
|
);
|
|
}, [exports, search]);
|
|
|
|
return (
|
|
<div className="w-full overflow-hidden">
|
|
{filteredCases?.length || filteredExports.length ? (
|
|
<div
|
|
ref={contentRef}
|
|
className="scrollbar-container flex size-full flex-col gap-4 overflow-y-auto"
|
|
>
|
|
{filteredCases.length > 0 && (
|
|
<div className="space-y-2">
|
|
<Heading as="h4">{t("headings.cases")}</Heading>
|
|
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
|
{cases?.map((item) => (
|
|
<CaseCard
|
|
key={item.name}
|
|
className={
|
|
search == "" || filteredCases?.includes(item)
|
|
? ""
|
|
: "hidden"
|
|
}
|
|
exportCase={item}
|
|
onSelect={() => {
|
|
setSelectedCaseId(item.id);
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{filteredExports.length > 0 && (
|
|
<div className="space-y-4">
|
|
<Heading as="h4">{t("headings.uncategorizedExports")}</Heading>
|
|
<div
|
|
ref={contentRef}
|
|
className="scrollbar-container grid gap-2 overflow-y-auto sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
|
>
|
|
{exports.map((item) => (
|
|
<ExportCard
|
|
key={item.name}
|
|
className={
|
|
search == "" || filteredExports.includes(item)
|
|
? ""
|
|
: "hidden"
|
|
}
|
|
exportedRecording={item}
|
|
onSelect={setSelected}
|
|
onRename={renameClip}
|
|
onDelete={({ file, exportName }) =>
|
|
setDeleteClip({ file, exportName })
|
|
}
|
|
onAssignToCase={onAssignToCase}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center text-center">
|
|
<LuFolderX className="size-16" />
|
|
{t("noExports")}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type CaseViewProps = {
|
|
contentRef: MutableRefObject<HTMLDivElement | null>;
|
|
selectedCase: ExportCase;
|
|
exports?: Export[];
|
|
search: string;
|
|
setSelected: (e: Export) => void;
|
|
renameClip: (id: string, update: string) => void;
|
|
setDeleteClip: (d: DeleteClipType | undefined) => void;
|
|
onAssignToCase: (e: Export) => void;
|
|
};
|
|
function CaseView({
|
|
contentRef,
|
|
selectedCase,
|
|
exports,
|
|
search,
|
|
setSelected,
|
|
renameClip,
|
|
setDeleteClip,
|
|
onAssignToCase,
|
|
}: CaseViewProps) {
|
|
const filteredExports = useMemo<Export[]>(() => {
|
|
const caseExports = (exports || []).filter(
|
|
(e) => e.export_case == selectedCase.id,
|
|
);
|
|
|
|
if (!search) {
|
|
return caseExports;
|
|
}
|
|
|
|
return caseExports.filter((exp) =>
|
|
exp.name
|
|
.toLowerCase()
|
|
.replaceAll("_", " ")
|
|
.includes(search.toLowerCase()),
|
|
);
|
|
}, [selectedCase, exports, search]);
|
|
|
|
return (
|
|
<div className="flex size-full flex-col gap-8">
|
|
<div className="flex flex-col gap-1">
|
|
<Heading className="capitalize" as="h2">
|
|
{selectedCase.name}
|
|
</Heading>
|
|
<div className="text-secondary-foreground">
|
|
{selectedCase.description}
|
|
</div>
|
|
</div>
|
|
<div
|
|
ref={contentRef}
|
|
className="scrollbar-container grid gap-2 overflow-y-auto sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
|
>
|
|
{exports?.map((item) => (
|
|
<ExportCard
|
|
key={item.name}
|
|
className={filteredExports.includes(item) ? "" : "hidden"}
|
|
exportedRecording={item}
|
|
onSelect={setSelected}
|
|
onRename={renameClip}
|
|
onDelete={({ file, exportName }) =>
|
|
setDeleteClip({ file, exportName })
|
|
}
|
|
onAssignToCase={onAssignToCase}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type CaseAssignmentDialogProps = {
|
|
exportToAssign?: Export;
|
|
cases?: ExportCase[];
|
|
selectedCaseId?: string;
|
|
onClose: () => void;
|
|
mutate: () => void;
|
|
};
|
|
function CaseAssignmentDialog({
|
|
exportToAssign,
|
|
cases,
|
|
selectedCaseId,
|
|
onClose,
|
|
mutate,
|
|
}: CaseAssignmentDialogProps) {
|
|
const { t } = useTranslation(["views/exports"]);
|
|
const caseOptions = useMemo(
|
|
() => [
|
|
...(cases ?? [])
|
|
.map((c) => ({
|
|
value: c.id,
|
|
label: c.name,
|
|
}))
|
|
.sort((cA, cB) => cA.label.localeCompare(cB.label)),
|
|
{
|
|
value: "new",
|
|
label: t("caseDialog.newCaseOption"),
|
|
},
|
|
],
|
|
[cases, t],
|
|
);
|
|
|
|
const handleSave = useCallback(
|
|
async (caseId: string) => {
|
|
if (!exportToAssign) return;
|
|
|
|
try {
|
|
await axios.patch(`export/${exportToAssign.id}/case`, {
|
|
export_case_id: caseId,
|
|
});
|
|
mutate();
|
|
onClose();
|
|
} catch (error: unknown) {
|
|
const apiError = error as {
|
|
response?: { data?: { message?: string; detail?: string } };
|
|
};
|
|
const errorMessage =
|
|
apiError.response?.data?.message ||
|
|
apiError.response?.data?.detail ||
|
|
"Unknown error";
|
|
toast.error(t("toast.error.assignCaseFailed", { errorMessage }), {
|
|
position: "top-center",
|
|
});
|
|
}
|
|
},
|
|
[exportToAssign, mutate, onClose, t],
|
|
);
|
|
|
|
const handleCreateNew = useCallback(
|
|
async (name: string, description: string) => {
|
|
if (!exportToAssign) return;
|
|
|
|
try {
|
|
const createResp = await axios.post("cases", {
|
|
name,
|
|
description,
|
|
});
|
|
|
|
const newCaseId: string | undefined = createResp.data?.id;
|
|
|
|
if (newCaseId) {
|
|
await axios.patch(`export/${exportToAssign.id}/case`, {
|
|
export_case_id: newCaseId,
|
|
});
|
|
}
|
|
|
|
mutate();
|
|
onClose();
|
|
} catch (error: unknown) {
|
|
const apiError = error as {
|
|
response?: { data?: { message?: string; detail?: string } };
|
|
};
|
|
const errorMessage =
|
|
apiError.response?.data?.message ||
|
|
apiError.response?.data?.detail ||
|
|
"Unknown error";
|
|
toast.error(t("toast.error.assignCaseFailed", { errorMessage }), {
|
|
position: "top-center",
|
|
});
|
|
}
|
|
},
|
|
[exportToAssign, mutate, onClose, t],
|
|
);
|
|
|
|
if (!exportToAssign) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<OptionAndInputDialog
|
|
open={!!exportToAssign}
|
|
title={t("caseDialog.title")}
|
|
description={t("caseDialog.description")}
|
|
setOpen={(open) => {
|
|
if (!open) {
|
|
onClose();
|
|
}
|
|
}}
|
|
options={caseOptions}
|
|
nameLabel={t("caseDialog.nameLabel")}
|
|
descriptionLabel={t("caseDialog.descriptionLabel")}
|
|
initialValue={selectedCaseId}
|
|
newValueKey="new"
|
|
onSave={handleSave}
|
|
onCreateNew={handleCreateNew}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export default Exports;
|