From f9e06bb7b7ef053c9d2ad92a5895e3d9b73b3172 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Tue, 16 Dec 2025 15:10:48 -0700 Subject: [PATCH] Export filter UI (#21322) * Get started on export filters * implement basic filter * Implement filtering and adjust api * Improve filter handling * Improve navigation * Cleanup * handle scrolling --- frigate/api/export.py | 7 +- .../components/filter/ExportFilterGroup.tsx | 67 +++++++++++ web/src/pages/Exports.tsx | 111 ++++++++++++++---- web/src/types/export.ts | 10 ++ web/vite.config.ts | 2 +- 5 files changed, 168 insertions(+), 29 deletions(-) create mode 100644 web/src/components/filter/ExportFilterGroup.tsx diff --git a/frigate/api/export.py b/frigate/api/export.py index 812a1b4b2..c2cf66a34 100644 --- a/frigate/api/export.py +++ b/frigate/api/export.py @@ -62,7 +62,7 @@ router = APIRouter(tags=[Tags.export]) def get_exports( allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter), export_case_id: Optional[str] = None, - camera: Optional[List[str]] = Query(default=None), + cameras: Optional[str] = Query(default="all"), start_date: Optional[float] = None, end_date: Optional[float] = None, ): @@ -74,8 +74,9 @@ def get_exports( else: query = query.where(Export.export_case == export_case_id) - if camera: - filtered_cameras = [c for c in camera if c in allowed_cameras] + if cameras and cameras != "all": + requested = set(cameras.split(",")) + filtered_cameras = list(requested.intersection(allowed_cameras)) if not filtered_cameras: return JSONResponse(content=[]) query = query.where(Export.camera << filtered_cameras) diff --git a/web/src/components/filter/ExportFilterGroup.tsx b/web/src/components/filter/ExportFilterGroup.tsx new file mode 100644 index 000000000..c5fe4f33c --- /dev/null +++ b/web/src/components/filter/ExportFilterGroup.tsx @@ -0,0 +1,67 @@ +import { cn } from "@/lib/utils"; +import { + DEFAULT_EXPORT_FILTERS, + ExportFilter, + ExportFilters, +} from "@/types/export"; +import { CamerasFilterButton } from "./CamerasFilterButton"; +import { useAllowedCameras } from "@/hooks/use-allowed-cameras"; +import { useMemo } from "react"; +import { FrigateConfig } from "@/types/frigateConfig"; +import useSWR from "swr"; + +type ExportFilterGroupProps = { + className: string; + filters?: ExportFilters[]; + filter?: ExportFilter; + onUpdateFilter: (filter: ExportFilter) => void; +}; +export default function ExportFilterGroup({ + className, + filter, + filters = DEFAULT_EXPORT_FILTERS, + onUpdateFilter, +}: ExportFilterGroupProps) { + const { data: config } = useSWR("config", { + revalidateOnFocus: false, + }); + const allowedCameras = useAllowedCameras(); + + const filterValues = useMemo( + () => ({ + cameras: allowedCameras, + }), + [allowedCameras], + ); + + const groups = useMemo(() => { + if (!config) { + return []; + } + + return Object.entries(config.camera_groups).sort( + (a, b) => a[1].order - b[1].order, + ); + }, [config]); + + return ( +
+ {filters.includes("cameras") && ( + { + onUpdateFilter({ ...filter, cameras: newCameras }); + }} + /> + )} +
+ ); +} diff --git a/web/src/pages/Exports.tsx b/web/src/pages/Exports.tsx index 70fa5d5b8..5b4a3ef3a 100644 --- a/web/src/pages/Exports.tsx +++ b/web/src/pages/Exports.tsx @@ -15,9 +15,16 @@ 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 { useSearchEffect } from "@/hooks/use-overlay-state"; +import { useHistoryBack } from "@/hooks/use-history-back"; +import { useApiFilterArgs } from "@/hooks/use-api-filter"; import { cn } from "@/lib/utils"; -import { DeleteClipType, Export, ExportCase } from "@/types/export"; +import { + DeleteClipType, + Export, + ExportCase, + ExportFilter, +} from "@/types/export"; import OptionAndInputDialog from "@/components/overlay/dialog/OptionAndInputDialog"; import axios from "axios"; @@ -29,12 +36,16 @@ import { useRef, useState, } from "react"; -import { isMobile } from "react-device-detect"; +import { isMobile, isMobileOnly } from "react-device-detect"; import { useTranslation } from "react-i18next"; import { LuFolderX } from "react-icons/lu"; import { toast } from "sonner"; import useSWR from "swr"; +import ExportFilterGroup from "@/components/filter/ExportFilterGroup"; + +// always parse these as string arrays +const EXPORT_FILTER_ARRAY_KEYS = ["cameras"]; function Exports() { const { t } = useTranslation(["views/exports"]); @@ -43,15 +54,47 @@ function Exports() { document.title = t("documentTitle"); }, [t]); + // Filters + + const [exportFilter, setExportFilter, exportSearchParams] = + useApiFilterArgs(EXPORT_FILTER_ARRAY_KEYS); + // Data const { data: cases, mutate: updateCases } = useSWR("cases"); - const { data: rawExports, mutate: updateExports } = - useSWR("exports"); + const { data: rawExports, mutate: updateExports } = useSWR( + exportSearchParams && Object.keys(exportSearchParams).length > 0 + ? ["exports", exportSearchParams] + : "exports", + ); + + const exportsByCase = useMemo<{ [caseId: string]: Export[] }>(() => { + const grouped: { [caseId: string]: Export[] } = {}; + (rawExports ?? []).forEach((exp) => { + const caseId = exp.export_case || "none"; + if (!grouped[caseId]) { + grouped[caseId] = []; + } + + grouped[caseId].push(exp); + }); + return grouped; + }, [rawExports]); + + const filteredCases = useMemo(() => { + if (!cases) { + return []; + } + + return cases.filter((caseItem) => { + const caseExports = exportsByCase[caseItem.id]; + return caseExports?.length; + }); + }, [cases, exportsByCase]); const exports = useMemo( - () => (rawExports ?? []).filter((e) => !e.export_case), - [rawExports], + () => exportsByCase["none"] || [], + [exportsByCase], ); const mutate = useCallback(() => { @@ -66,26 +109,33 @@ function Exports() { // Viewing const [selected, setSelected] = useState(); - const [selectedCaseId, setSelectedCaseId] = useOverlayState< - string | undefined - >("caseId", undefined); + const [selectedCaseId, setSelectedCaseId] = useState( + undefined, + ); const [selectedAspect, setSelectedAspect] = useState(0.0); + // Handle browser back button to deselect case before navigating away + useHistoryBack({ + enabled: true, + open: selectedCaseId !== undefined, + onClose: () => setSelectedCaseId(undefined), + }); + useSearchEffect("id", (id) => { - if (!exports) { + if (!rawExports) { return false; } - setSelected(exports.find((exp) => exp.id == id)); + setSelected(rawExports.find((exp) => exp.id == id)); return true; }); useSearchEffect("caseId", (caseId: string) => { - if (!cases) { + if (!filteredCases) { return false; } - const exists = cases.some((c) => c.id === caseId); + const exists = filteredCases.some((c) => c.id === caseId); if (!exists) { return false; @@ -144,8 +194,8 @@ function Exports() { useKeyboardListener([], undefined, contentRef); const selectedCase = useMemo( - () => cases?.find((c) => c.id === selectedCaseId), - [cases, selectedCaseId], + () => filteredCases?.find((c) => c.id === selectedCaseId), + [filteredCases, selectedCaseId], ); const resetCaseDialog = useCallback(() => { @@ -232,22 +282,33 @@ function Exports() { - {(exports?.length || cases?.length) && ( -
+
+
setSearch(e.target.value)} />
- )} + +
{selectedCase ? ( -
+
+
{selectedCase.name} @@ -439,7 +500,7 @@ function CaseView({
{exports?.map((item) => (