mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-01-22 20:18:30 +03:00
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:
parent
aa0b082184
commit
26744efb1e
@ -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",
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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"}
|
||||||
|
|||||||
@ -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");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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);
|
||||||
}}
|
}}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user