mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-04-11 01:27:36 +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",
|
||||
"downloadVideo": "Download video",
|
||||
"editName": "Edit name",
|
||||
"deleteExport": "Delete export"
|
||||
"deleteExport": "Delete export",
|
||||
"assignToCase": "Add to case"
|
||||
},
|
||||
"toast": {
|
||||
"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;
|
||||
};
|
||||
export function CaseCard({ className, exportCase, onSelect }: CaseCardProps) {
|
||||
const { t } = useTranslation(["views/exports"]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@ -59,6 +57,7 @@ type ExportCardProps = {
|
||||
onSelect: (selected: Export) => void;
|
||||
onRename: (original: string, update: string) => void;
|
||||
onDelete: ({ file, exportName }: DeleteClipType) => void;
|
||||
onAssignToCase?: (selected: Export) => void;
|
||||
};
|
||||
export function ExportCard({
|
||||
className,
|
||||
@ -66,6 +65,7 @@ export function ExportCard({
|
||||
onSelect,
|
||||
onRename,
|
||||
onDelete,
|
||||
onAssignToCase,
|
||||
}: ExportCardProps) {
|
||||
const { t } = useTranslation(["views/exports"]);
|
||||
const isAdmin = useIsAdmin();
|
||||
@ -223,6 +223,18 @@ export function ExportCard({
|
||||
{t("tooltip.downloadVideo")}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && onAssignToCase && (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
aria-label={t("tooltip.assignToCase")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAssignToCase(exportedRecording);
|
||||
}}
|
||||
>
|
||||
{t("tooltip.assignToCase")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
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 { cn } from "@/lib/utils";
|
||||
import { DeleteClipType, Export, ExportCase } from "@/types/export";
|
||||
import OptionAndInputDialog from "@/components/overlay/dialog/OptionAndInputDialog";
|
||||
import axios from "axios";
|
||||
|
||||
import {
|
||||
@ -97,6 +98,7 @@ function Exports() {
|
||||
// Modifying
|
||||
|
||||
const [deleteClip, setDeleteClip] = useState<DeleteClipType | undefined>();
|
||||
const [exportToAssign, setExportToAssign] = useState<Export | undefined>();
|
||||
|
||||
const onHandleDelete = useCallback(() => {
|
||||
if (!deleteClip) {
|
||||
@ -146,10 +148,22 @@ function Exports() {
|
||||
[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)}
|
||||
@ -238,6 +252,7 @@ function Exports() {
|
||||
setSelected={setSelected}
|
||||
renameClip={onHandleRename}
|
||||
setDeleteClip={setDeleteClip}
|
||||
onAssignToCase={setExportToAssign}
|
||||
/>
|
||||
) : (
|
||||
<AllExportsView
|
||||
@ -249,6 +264,7 @@ function Exports() {
|
||||
setSelected={setSelected}
|
||||
renameClip={onHandleRename}
|
||||
setDeleteClip={setDeleteClip}
|
||||
onAssignToCase={setExportToAssign}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@ -264,6 +280,7 @@ type AllExportsViewProps = {
|
||||
setSelected: (e: Export) => void;
|
||||
renameClip: (id: string, update: string) => void;
|
||||
setDeleteClip: (d: DeleteClipType | undefined) => void;
|
||||
onAssignToCase: (e: Export) => void;
|
||||
};
|
||||
function AllExportsView({
|
||||
contentRef,
|
||||
@ -274,6 +291,7 @@ function AllExportsView({
|
||||
setSelected,
|
||||
renameClip,
|
||||
setDeleteClip,
|
||||
onAssignToCase,
|
||||
}: AllExportsViewProps) {
|
||||
const { t } = useTranslation(["views/exports"]);
|
||||
|
||||
@ -354,6 +372,7 @@ function AllExportsView({
|
||||
onDelete={({ file, exportName }) =>
|
||||
setDeleteClip({ file, exportName })
|
||||
}
|
||||
onAssignToCase={onAssignToCase}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@ -377,6 +396,7 @@ type CaseViewProps = {
|
||||
setSelected: (e: Export) => void;
|
||||
renameClip: (id: string, update: string) => void;
|
||||
setDeleteClip: (d: DeleteClipType | undefined) => void;
|
||||
onAssignToCase: (e: Export) => void;
|
||||
};
|
||||
function CaseView({
|
||||
contentRef,
|
||||
@ -386,6 +406,7 @@ function CaseView({
|
||||
setSelected,
|
||||
renameClip,
|
||||
setDeleteClip,
|
||||
onAssignToCase,
|
||||
}: CaseViewProps) {
|
||||
const filteredExports = useMemo<Export[]>(() => {
|
||||
const caseExports = (exports || []).filter(
|
||||
@ -418,7 +439,7 @@ function CaseView({
|
||||
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"
|
||||
>
|
||||
{exports.map((item) => (
|
||||
{exports?.map((item) => (
|
||||
<ExportCard
|
||||
key={item.name}
|
||||
className={filteredExports.includes(item) ? "" : "hidden"}
|
||||
@ -428,6 +449,7 @@ function CaseView({
|
||||
onDelete={({ file, exportName }) =>
|
||||
setDeleteClip({ file, exportName })
|
||||
}
|
||||
onAssignToCase={onAssignToCase}
|
||||
/>
|
||||
))}
|
||||
</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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user