Exports Improvements (#21521)

* Add images to case folder view

* Add ability to select case in export dialog

* Add to mobile review too
This commit is contained in:
Nicolas Mowen 2026-01-03 08:03:33 -07:00 committed by GitHub
parent aa0b082184
commit 26744efb1e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 101 additions and 7 deletions

View File

@ -48,6 +48,10 @@
"name": { "name": {
"placeholder": "Name the Export" "placeholder": "Name the Export"
}, },
"case": {
"label": "Case",
"placeholder": "Select a case"
},
"select": "Select", "select": "Select",
"export": "Export", "export": "Export",
"selectOrExport": "Select or Export", "selectOrExport": "Select or Export",

View File

@ -1,6 +1,6 @@
import ActivityIndicator from "../indicators/activity-indicator"; import ActivityIndicator from "../indicators/activity-indicator";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { useCallback, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { isMobile } from "react-device-detect"; import { isMobile } from "react-device-detect";
import { FiMoreVertical } from "react-icons/fi"; import { FiMoreVertical } from "react-icons/fi";
import { Skeleton } from "../ui/skeleton"; import { Skeleton } from "../ui/skeleton";
@ -32,18 +32,37 @@ import { FaFolder } from "react-icons/fa";
type CaseCardProps = { type CaseCardProps = {
className: string; className: string;
exportCase: ExportCase; exportCase: ExportCase;
exports: Export[];
onSelect: () => void; onSelect: () => void;
}; };
export function CaseCard({ className, exportCase, onSelect }: CaseCardProps) { export function CaseCard({
className,
exportCase,
exports,
onSelect,
}: CaseCardProps) {
const firstExport = useMemo(
() => exports.find((exp) => exp.thumb_path && exp.thumb_path.length > 0),
[exports],
);
return ( return (
<div <div
className={cn( className={cn(
"relative flex aspect-video size-full cursor-pointer items-center justify-center rounded-lg bg-secondary md:rounded-2xl", "relative flex aspect-video size-full cursor-pointer items-center justify-center overflow-hidden rounded-lg bg-secondary md:rounded-2xl",
className, className,
)} )}
onClick={() => onSelect()} onClick={() => onSelect()}
> >
<div className="absolute bottom-2 left-2 flex items-center justify-start gap-2"> {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">
<FaFolder /> <FaFolder />
<div className="capitalize">{exportCase.name}</div> <div className="capitalize">{exportCase.name}</div>
</div> </div>

View File

@ -22,7 +22,14 @@ import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import { TimezoneAwareCalendar } from "./ReviewActivityCalendar"; import { TimezoneAwareCalendar } from "./ReviewActivityCalendar";
import { SelectSeparator } from "../ui/select"; import {
Select,
SelectContent,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "../ui/select";
import { isDesktop, isIOS, isMobile } from "react-device-detect"; import { isDesktop, isIOS, isMobile } from "react-device-detect";
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
import SaveExportOverlay from "./SaveExportOverlay"; import SaveExportOverlay from "./SaveExportOverlay";
@ -31,6 +38,7 @@ import { baseUrl } from "@/api/baseUrl";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { GenericVideoPlayer } from "../player/GenericVideoPlayer"; import { GenericVideoPlayer } from "../player/GenericVideoPlayer";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ExportCase } from "@/types/export";
const EXPORT_OPTIONS = [ const EXPORT_OPTIONS = [
"1", "1",
@ -67,6 +75,9 @@ export default function ExportDialog({
}: ExportDialogProps) { }: ExportDialogProps) {
const { t } = useTranslation(["components/dialog"]); const { t } = useTranslation(["components/dialog"]);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [selectedCaseId, setSelectedCaseId] = useState<string | undefined>(
undefined,
);
const onStartExport = useCallback(() => { const onStartExport = useCallback(() => {
if (!range) { if (!range) {
@ -89,6 +100,7 @@ export default function ExportDialog({
{ {
playback: "realtime", playback: "realtime",
name, name,
export_case_id: selectedCaseId || undefined,
}, },
) )
.then((response) => { .then((response) => {
@ -102,6 +114,7 @@ export default function ExportDialog({
), ),
}); });
setName(""); setName("");
setSelectedCaseId(undefined);
setRange(undefined); setRange(undefined);
setMode("none"); setMode("none");
} }
@ -118,10 +131,11 @@ export default function ExportDialog({
{ position: "top-center" }, { position: "top-center" },
); );
}); });
}, [camera, name, range, setRange, setName, setMode, t]); }, [camera, name, range, selectedCaseId, setRange, setName, setMode, t]);
const handleCancel = useCallback(() => { const handleCancel = useCallback(() => {
setName(""); setName("");
setSelectedCaseId(undefined);
setMode("none"); setMode("none");
setRange(undefined); setRange(undefined);
}, [setMode, setRange]); }, [setMode, setRange]);
@ -190,8 +204,10 @@ export default function ExportDialog({
currentTime={currentTime} currentTime={currentTime}
range={range} range={range}
name={name} name={name}
selectedCaseId={selectedCaseId}
onStartExport={onStartExport} onStartExport={onStartExport}
setName={setName} setName={setName}
setSelectedCaseId={setSelectedCaseId}
setRange={setRange} setRange={setRange}
setMode={setMode} setMode={setMode}
onCancel={handleCancel} onCancel={handleCancel}
@ -207,8 +223,10 @@ type ExportContentProps = {
currentTime: number; currentTime: number;
range?: TimeRange; range?: TimeRange;
name: string; name: string;
selectedCaseId?: string;
onStartExport: () => void; onStartExport: () => void;
setName: (name: string) => void; setName: (name: string) => void;
setSelectedCaseId: (caseId: string | undefined) => void;
setRange: (range: TimeRange | undefined) => void; setRange: (range: TimeRange | undefined) => void;
setMode: (mode: ExportMode) => void; setMode: (mode: ExportMode) => void;
onCancel: () => void; onCancel: () => void;
@ -218,14 +236,17 @@ export function ExportContent({
currentTime, currentTime,
range, range,
name, name,
selectedCaseId,
onStartExport, onStartExport,
setName, setName,
setSelectedCaseId,
setRange, setRange,
setMode, setMode,
onCancel, onCancel,
}: ExportContentProps) { }: ExportContentProps) {
const { t } = useTranslation(["components/dialog"]); const { t } = useTranslation(["components/dialog"]);
const [selectedOption, setSelectedOption] = useState<ExportOption>("1"); const [selectedOption, setSelectedOption] = useState<ExportOption>("1");
const { data: cases } = useSWR<ExportCase[]>("cases");
const onSelectTime = useCallback( const onSelectTime = useCallback(
(option: ExportOption) => { (option: ExportOption) => {
@ -320,6 +341,44 @@ export function ExportContent({
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
/> />
<div className="my-4">
<Label className="text-sm text-secondary-foreground">
{t("export.case.label", { defaultValue: "Case (optional)" })}
</Label>
<Select
value={selectedCaseId || "none"}
onValueChange={(value) =>
setSelectedCaseId(value === "none" ? undefined : value)
}
>
<SelectTrigger className="mt-2">
<SelectValue
placeholder={t("export.case.placeholder", {
defaultValue: "Select a case (optional)",
})}
/>
</SelectTrigger>
<SelectContent>
<SelectItem
value="none"
className="cursor-pointer hover:bg-accent hover:text-accent-foreground"
>
{t("label.none", { ns: "common" })}
</SelectItem>
{cases
?.sort((a, b) => a.name.localeCompare(b.name))
.map((caseItem) => (
<SelectItem
key={caseItem.id}
value={caseItem.id}
className="cursor-pointer hover:bg-accent hover:text-accent-foreground"
>
{caseItem.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{isDesktop && <SelectSeparator className="my-4 bg-secondary" />} {isDesktop && <SelectSeparator className="my-4 bg-secondary" />}
<DialogFooter <DialogFooter
className={isDesktop ? "" : "mt-3 flex flex-col-reverse gap-4"} className={isDesktop ? "" : "mt-3 flex flex-col-reverse gap-4"}

View File

@ -75,6 +75,9 @@ export default function MobileReviewSettingsDrawer({
// exports // exports
const [name, setName] = useState(""); const [name, setName] = useState("");
const [selectedCaseId, setSelectedCaseId] = useState<string | undefined>(
undefined,
);
const onStartExport = useCallback(() => { const onStartExport = useCallback(() => {
if (!range) { if (!range) {
toast.error(t("toast.error.noValidTimeSelected"), { toast.error(t("toast.error.noValidTimeSelected"), {
@ -96,6 +99,7 @@ export default function MobileReviewSettingsDrawer({
{ {
playback: "realtime", playback: "realtime",
name, name,
export_case_id: selectedCaseId || undefined,
}, },
) )
.then((response) => { .then((response) => {
@ -114,6 +118,7 @@ export default function MobileReviewSettingsDrawer({
}, },
); );
setName(""); setName("");
setSelectedCaseId(undefined);
setRange(undefined); setRange(undefined);
setMode("none"); setMode("none");
} }
@ -133,7 +138,7 @@ export default function MobileReviewSettingsDrawer({
}, },
); );
}); });
}, [camera, name, range, setRange, setName, setMode, t]); }, [camera, name, range, selectedCaseId, setRange, setName, setMode, t]);
// filters // filters
@ -200,8 +205,10 @@ export default function MobileReviewSettingsDrawer({
currentTime={currentTime} currentTime={currentTime}
range={range} range={range}
name={name} name={name}
selectedCaseId={selectedCaseId}
onStartExport={onStartExport} onStartExport={onStartExport}
setName={setName} setName={setName}
setSelectedCaseId={setSelectedCaseId}
setRange={setRange} setRange={setRange}
setMode={(mode) => { setMode={(mode) => {
setMode(mode); setMode(mode);
@ -213,6 +220,7 @@ export default function MobileReviewSettingsDrawer({
onCancel={() => { onCancel={() => {
setMode("none"); setMode("none");
setRange(undefined); setRange(undefined);
setSelectedCaseId(undefined);
setDrawerMode("select"); setDrawerMode("select");
}} }}
/> />

View File

@ -321,6 +321,7 @@ function Exports() {
search={search} search={search}
cases={filteredCases} cases={filteredCases}
exports={exports} exports={exports}
exportsByCase={exportsByCase}
setSelectedCaseId={setSelectedCaseId} setSelectedCaseId={setSelectedCaseId}
setSelected={setSelected} setSelected={setSelected}
renameClip={onHandleRename} renameClip={onHandleRename}
@ -337,6 +338,7 @@ type AllExportsViewProps = {
search: string; search: string;
cases?: ExportCase[]; cases?: ExportCase[];
exports: Export[]; exports: Export[];
exportsByCase: { [caseId: string]: Export[] };
setSelectedCaseId: (id: string) => void; setSelectedCaseId: (id: string) => void;
setSelected: (e: Export) => void; setSelected: (e: Export) => void;
renameClip: (id: string, update: string) => void; renameClip: (id: string, update: string) => void;
@ -348,6 +350,7 @@ function AllExportsView({
search, search,
cases, cases,
exports, exports,
exportsByCase,
setSelectedCaseId, setSelectedCaseId,
setSelected, setSelected,
renameClip, renameClip,
@ -404,6 +407,7 @@ function AllExportsView({
: "hidden" : "hidden"
} }
exportCase={item} exportCase={item}
exports={exportsByCase[item.id] || []}
onSelect={() => { onSelect={() => {
setSelectedCaseId(item.id); setSelectedCaseId(item.id);
}} }}