mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-04-11 09:37:37 +03:00
Add ability to edit and select cases for exports
This commit is contained in:
parent
7b4f747b6a
commit
c8aae7745f
@ -17,11 +17,21 @@
|
|||||||
"shareExport": "Share export",
|
"shareExport": "Share export",
|
||||||
"downloadVideo": "Download video",
|
"downloadVideo": "Download video",
|
||||||
"editName": "Edit name",
|
"editName": "Edit name",
|
||||||
"deleteExport": "Delete export"
|
"deleteExport": "Delete export",
|
||||||
|
"assignToCase": "Add to case"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"error": {
|
"error": {
|
||||||
"renameExportFailed": "Failed to rename export: {{errorMessage}}"
|
"renameExportFailed": "Failed to rename export: {{errorMessage}}",
|
||||||
|
"assignCaseFailed": "Failed to update case assignment: {{errorMessage}}"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"caseDialog": {
|
||||||
|
"title": "Add to case",
|
||||||
|
"description": "Choose an existing case or create a new one.",
|
||||||
|
"selectLabel": "Case",
|
||||||
|
"newCaseOption": "Create new case",
|
||||||
|
"nameLabel": "Case name",
|
||||||
|
"descriptionLabel": "Description"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,8 +35,6 @@ type CaseCardProps = {
|
|||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
};
|
};
|
||||||
export function CaseCard({ className, exportCase, onSelect }: CaseCardProps) {
|
export function CaseCard({ className, exportCase, onSelect }: CaseCardProps) {
|
||||||
const { t } = useTranslation(["views/exports"]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -59,6 +57,7 @@ type ExportCardProps = {
|
|||||||
onSelect: (selected: Export) => void;
|
onSelect: (selected: Export) => void;
|
||||||
onRename: (original: string, update: string) => void;
|
onRename: (original: string, update: string) => void;
|
||||||
onDelete: ({ file, exportName }: DeleteClipType) => void;
|
onDelete: ({ file, exportName }: DeleteClipType) => void;
|
||||||
|
onAssignToCase?: (selected: Export) => void;
|
||||||
};
|
};
|
||||||
export function ExportCard({
|
export function ExportCard({
|
||||||
className,
|
className,
|
||||||
@ -66,6 +65,7 @@ export function ExportCard({
|
|||||||
onSelect,
|
onSelect,
|
||||||
onRename,
|
onRename,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onAssignToCase,
|
||||||
}: ExportCardProps) {
|
}: ExportCardProps) {
|
||||||
const { t } = useTranslation(["views/exports"]);
|
const { t } = useTranslation(["views/exports"]);
|
||||||
const isAdmin = useIsAdmin();
|
const isAdmin = useIsAdmin();
|
||||||
@ -223,6 +223,18 @@ export function ExportCard({
|
|||||||
{t("tooltip.downloadVideo")}
|
{t("tooltip.downloadVideo")}
|
||||||
</a>
|
</a>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
{isAdmin && onAssignToCase && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="cursor-pointer"
|
||||||
|
aria-label={t("tooltip.assignToCase")}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAssignToCase(exportedRecording);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("tooltip.assignToCase")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
|
|||||||
166
web/src/components/overlay/dialog/OptionAndInputDialog.tsx
Normal file
166
web/src/components/overlay/dialog/OptionAndInputDialog.tsx
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { isMobile } from "react-device-detect";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
type Option = {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OptionAndInputDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
options: Option[];
|
||||||
|
newValueKey: string;
|
||||||
|
initialValue?: string;
|
||||||
|
nameLabel: string;
|
||||||
|
descriptionLabel: string;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
onSave: (value: string) => void;
|
||||||
|
onCreateNew: (name: string, description: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function OptionAndInputDialog({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
options,
|
||||||
|
newValueKey,
|
||||||
|
initialValue,
|
||||||
|
nameLabel,
|
||||||
|
descriptionLabel,
|
||||||
|
setOpen,
|
||||||
|
onSave,
|
||||||
|
onCreateNew,
|
||||||
|
}: OptionAndInputDialogProps) {
|
||||||
|
const { t } = useTranslation("common");
|
||||||
|
const firstOption = useMemo(() => options[0]?.value, [options]);
|
||||||
|
|
||||||
|
const [selectedValue, setSelectedValue] = useState<string | undefined>(
|
||||||
|
initialValue ?? firstOption,
|
||||||
|
);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [descriptionValue, setDescriptionValue] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setSelectedValue(initialValue ?? firstOption);
|
||||||
|
setName("");
|
||||||
|
setDescriptionValue("");
|
||||||
|
}
|
||||||
|
}, [open, initialValue, firstOption]);
|
||||||
|
|
||||||
|
const isNew = selectedValue === newValueKey;
|
||||||
|
const disableSave = !selectedValue || (isNew && name.trim().length === 0);
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!selectedValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedName = name.trim();
|
||||||
|
const trimmedDescription = descriptionValue.trim();
|
||||||
|
|
||||||
|
if (isNew) {
|
||||||
|
onCreateNew(trimmedName, trimmedDescription);
|
||||||
|
} else {
|
||||||
|
onSave(selectedValue);
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} defaultOpen={false} onOpenChange={setOpen}>
|
||||||
|
<DialogContent
|
||||||
|
className={cn("space-y-4", isMobile && "px-4")}
|
||||||
|
onOpenAutoFocus={(e) => {
|
||||||
|
if (isMobile) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
{description && <DialogDescription>{description}</DialogDescription>}
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Select
|
||||||
|
value={selectedValue}
|
||||||
|
onValueChange={(val) => setSelectedValue(val)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{options.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isNew && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm font-medium text-secondary-foreground">
|
||||||
|
{nameLabel}
|
||||||
|
</label>
|
||||||
|
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm font-medium text-secondary-foreground">
|
||||||
|
{descriptionLabel}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={descriptionValue}
|
||||||
|
onChange={(e) => setDescriptionValue(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter className={cn("pt-2", isMobile && "gap-2")}>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("button.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="select"
|
||||||
|
disabled={disableSave}
|
||||||
|
onClick={handleSave}
|
||||||
|
>
|
||||||
|
{t("button.save")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -18,6 +18,7 @@ import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
|||||||
import { useOverlayState, useSearchEffect } from "@/hooks/use-overlay-state";
|
import { useOverlayState, useSearchEffect } from "@/hooks/use-overlay-state";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { DeleteClipType, Export, ExportCase } from "@/types/export";
|
import { DeleteClipType, Export, ExportCase } from "@/types/export";
|
||||||
|
import OptionAndInputDialog from "@/components/overlay/dialog/OptionAndInputDialog";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -97,6 +98,7 @@ function Exports() {
|
|||||||
// Modifying
|
// Modifying
|
||||||
|
|
||||||
const [deleteClip, setDeleteClip] = useState<DeleteClipType | undefined>();
|
const [deleteClip, setDeleteClip] = useState<DeleteClipType | undefined>();
|
||||||
|
const [exportToAssign, setExportToAssign] = useState<Export | undefined>();
|
||||||
|
|
||||||
const onHandleDelete = useCallback(() => {
|
const onHandleDelete = useCallback(() => {
|
||||||
if (!deleteClip) {
|
if (!deleteClip) {
|
||||||
@ -146,10 +148,22 @@ function Exports() {
|
|||||||
[cases, selectedCaseId],
|
[cases, selectedCaseId],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const resetCaseDialog = useCallback(() => {
|
||||||
|
setExportToAssign(undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex size-full flex-col gap-2 overflow-hidden px-1 pt-2 md:p-2">
|
<div className="flex size-full flex-col gap-2 overflow-hidden px-1 pt-2 md:p-2">
|
||||||
<Toaster closeButton={true} />
|
<Toaster closeButton={true} />
|
||||||
|
|
||||||
|
<CaseAssignmentDialog
|
||||||
|
exportToAssign={exportToAssign}
|
||||||
|
cases={cases}
|
||||||
|
selectedCaseId={selectedCaseId}
|
||||||
|
onClose={resetCaseDialog}
|
||||||
|
mutate={mutate}
|
||||||
|
/>
|
||||||
|
|
||||||
<AlertDialog
|
<AlertDialog
|
||||||
open={deleteClip != undefined}
|
open={deleteClip != undefined}
|
||||||
onOpenChange={() => setDeleteClip(undefined)}
|
onOpenChange={() => setDeleteClip(undefined)}
|
||||||
@ -238,6 +252,7 @@ function Exports() {
|
|||||||
setSelected={setSelected}
|
setSelected={setSelected}
|
||||||
renameClip={onHandleRename}
|
renameClip={onHandleRename}
|
||||||
setDeleteClip={setDeleteClip}
|
setDeleteClip={setDeleteClip}
|
||||||
|
onAssignToCase={setExportToAssign}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<AllExportsView
|
<AllExportsView
|
||||||
@ -249,6 +264,7 @@ function Exports() {
|
|||||||
setSelected={setSelected}
|
setSelected={setSelected}
|
||||||
renameClip={onHandleRename}
|
renameClip={onHandleRename}
|
||||||
setDeleteClip={setDeleteClip}
|
setDeleteClip={setDeleteClip}
|
||||||
|
onAssignToCase={setExportToAssign}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -264,6 +280,7 @@ type AllExportsViewProps = {
|
|||||||
setSelected: (e: Export) => void;
|
setSelected: (e: Export) => void;
|
||||||
renameClip: (id: string, update: string) => void;
|
renameClip: (id: string, update: string) => void;
|
||||||
setDeleteClip: (d: DeleteClipType | undefined) => void;
|
setDeleteClip: (d: DeleteClipType | undefined) => void;
|
||||||
|
onAssignToCase: (e: Export) => void;
|
||||||
};
|
};
|
||||||
function AllExportsView({
|
function AllExportsView({
|
||||||
contentRef,
|
contentRef,
|
||||||
@ -274,6 +291,7 @@ function AllExportsView({
|
|||||||
setSelected,
|
setSelected,
|
||||||
renameClip,
|
renameClip,
|
||||||
setDeleteClip,
|
setDeleteClip,
|
||||||
|
onAssignToCase,
|
||||||
}: AllExportsViewProps) {
|
}: AllExportsViewProps) {
|
||||||
const { t } = useTranslation(["views/exports"]);
|
const { t } = useTranslation(["views/exports"]);
|
||||||
|
|
||||||
@ -354,6 +372,7 @@ function AllExportsView({
|
|||||||
onDelete={({ file, exportName }) =>
|
onDelete={({ file, exportName }) =>
|
||||||
setDeleteClip({ file, exportName })
|
setDeleteClip({ file, exportName })
|
||||||
}
|
}
|
||||||
|
onAssignToCase={onAssignToCase}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -377,6 +396,7 @@ type CaseViewProps = {
|
|||||||
setSelected: (e: Export) => void;
|
setSelected: (e: Export) => void;
|
||||||
renameClip: (id: string, update: string) => void;
|
renameClip: (id: string, update: string) => void;
|
||||||
setDeleteClip: (d: DeleteClipType | undefined) => void;
|
setDeleteClip: (d: DeleteClipType | undefined) => void;
|
||||||
|
onAssignToCase: (e: Export) => void;
|
||||||
};
|
};
|
||||||
function CaseView({
|
function CaseView({
|
||||||
contentRef,
|
contentRef,
|
||||||
@ -386,6 +406,7 @@ function CaseView({
|
|||||||
setSelected,
|
setSelected,
|
||||||
renameClip,
|
renameClip,
|
||||||
setDeleteClip,
|
setDeleteClip,
|
||||||
|
onAssignToCase,
|
||||||
}: CaseViewProps) {
|
}: CaseViewProps) {
|
||||||
const filteredExports = useMemo<Export[]>(() => {
|
const filteredExports = useMemo<Export[]>(() => {
|
||||||
const caseExports = (exports || []).filter(
|
const caseExports = (exports || []).filter(
|
||||||
@ -418,7 +439,7 @@ function CaseView({
|
|||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
className="scrollbar-container grid size-full gap-2 overflow-y-auto sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
className="scrollbar-container grid size-full gap-2 overflow-y-auto sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||||
>
|
>
|
||||||
{exports.map((item) => (
|
{exports?.map((item) => (
|
||||||
<ExportCard
|
<ExportCard
|
||||||
key={item.name}
|
key={item.name}
|
||||||
className={filteredExports.includes(item) ? "" : "hidden"}
|
className={filteredExports.includes(item) ? "" : "hidden"}
|
||||||
@ -428,6 +449,7 @@ function CaseView({
|
|||||||
onDelete={({ file, exportName }) =>
|
onDelete={({ file, exportName }) =>
|
||||||
setDeleteClip({ file, exportName })
|
setDeleteClip({ file, exportName })
|
||||||
}
|
}
|
||||||
|
onAssignToCase={onAssignToCase}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -435,4 +457,130 @@ function CaseView({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
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 errorMessage =
|
||||||
|
(
|
||||||
|
error as {
|
||||||
|
response?: { data?: { message?: string; detail?: string } };
|
||||||
|
}
|
||||||
|
).response?.data?.message ||
|
||||||
|
(
|
||||||
|
error as {
|
||||||
|
response?: { data?: { message?: string; detail?: string } };
|
||||||
|
}
|
||||||
|
).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 errorMessage =
|
||||||
|
(
|
||||||
|
error as {
|
||||||
|
response?: { data?: { message?: string; detail?: string } };
|
||||||
|
}
|
||||||
|
).response?.data?.message ||
|
||||||
|
(
|
||||||
|
error as {
|
||||||
|
response?: { data?: { message?: string; detail?: string } };
|
||||||
|
}
|
||||||
|
).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;
|
export default Exports;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user