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
This commit is contained in:
Nicolas Mowen 2025-12-16 15:10:48 -07:00 committed by GitHub
parent 7cc16161b3
commit f9e06bb7b7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 168 additions and 29 deletions

View File

@ -62,7 +62,7 @@ router = APIRouter(tags=[Tags.export])
def get_exports( def get_exports(
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter), allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
export_case_id: Optional[str] = None, export_case_id: Optional[str] = None,
camera: Optional[List[str]] = Query(default=None), cameras: Optional[str] = Query(default="all"),
start_date: Optional[float] = None, start_date: Optional[float] = None,
end_date: Optional[float] = None, end_date: Optional[float] = None,
): ):
@ -74,8 +74,9 @@ def get_exports(
else: else:
query = query.where(Export.export_case == export_case_id) query = query.where(Export.export_case == export_case_id)
if camera: if cameras and cameras != "all":
filtered_cameras = [c for c in camera if c in allowed_cameras] requested = set(cameras.split(","))
filtered_cameras = list(requested.intersection(allowed_cameras))
if not filtered_cameras: if not filtered_cameras:
return JSONResponse(content=[]) return JSONResponse(content=[])
query = query.where(Export.camera << filtered_cameras) query = query.where(Export.camera << filtered_cameras)

View File

@ -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<FrigateConfig>("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 (
<div
className={cn(
"scrollbar-container flex justify-center gap-2 overflow-x-auto",
className,
)}
>
{filters.includes("cameras") && (
<CamerasFilterButton
allCameras={filterValues.cameras}
groups={groups}
selectedCameras={filter?.cameras}
hideText={false}
updateCameraFilter={(newCameras) => {
onUpdateFilter({ ...filter, cameras: newCameras });
}}
/>
)}
</div>
);
}

View File

@ -15,9 +15,16 @@ import Heading from "@/components/ui/heading";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import useKeyboardListener from "@/hooks/use-keyboard-listener"; 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 { 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 OptionAndInputDialog from "@/components/overlay/dialog/OptionAndInputDialog";
import axios from "axios"; import axios from "axios";
@ -29,12 +36,16 @@ import {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import { isMobile } from "react-device-detect"; import { isMobile, isMobileOnly } from "react-device-detect";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { LuFolderX } from "react-icons/lu"; import { LuFolderX } from "react-icons/lu";
import { toast } from "sonner"; import { toast } from "sonner";
import useSWR from "swr"; import useSWR from "swr";
import ExportFilterGroup from "@/components/filter/ExportFilterGroup";
// always parse these as string arrays
const EXPORT_FILTER_ARRAY_KEYS = ["cameras"];
function Exports() { function Exports() {
const { t } = useTranslation(["views/exports"]); const { t } = useTranslation(["views/exports"]);
@ -43,15 +54,47 @@ function Exports() {
document.title = t("documentTitle"); document.title = t("documentTitle");
}, [t]); }, [t]);
// Filters
const [exportFilter, setExportFilter, exportSearchParams] =
useApiFilterArgs<ExportFilter>(EXPORT_FILTER_ARRAY_KEYS);
// Data // Data
const { data: cases, mutate: updateCases } = useSWR<ExportCase[]>("cases"); const { data: cases, mutate: updateCases } = useSWR<ExportCase[]>("cases");
const { data: rawExports, mutate: updateExports } = const { data: rawExports, mutate: updateExports } = useSWR<Export[]>(
useSWR<Export[]>("exports"); 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<ExportCase[]>(() => {
if (!cases) {
return [];
}
return cases.filter((caseItem) => {
const caseExports = exportsByCase[caseItem.id];
return caseExports?.length;
});
}, [cases, exportsByCase]);
const exports = useMemo<Export[]>( const exports = useMemo<Export[]>(
() => (rawExports ?? []).filter((e) => !e.export_case), () => exportsByCase["none"] || [],
[rawExports], [exportsByCase],
); );
const mutate = useCallback(() => { const mutate = useCallback(() => {
@ -66,26 +109,33 @@ function Exports() {
// Viewing // Viewing
const [selected, setSelected] = useState<Export>(); const [selected, setSelected] = useState<Export>();
const [selectedCaseId, setSelectedCaseId] = useOverlayState< const [selectedCaseId, setSelectedCaseId] = useState<string | undefined>(
string | undefined undefined,
>("caseId", undefined); );
const [selectedAspect, setSelectedAspect] = useState(0.0); 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) => { useSearchEffect("id", (id) => {
if (!exports) { if (!rawExports) {
return false; return false;
} }
setSelected(exports.find((exp) => exp.id == id)); setSelected(rawExports.find((exp) => exp.id == id));
return true; return true;
}); });
useSearchEffect("caseId", (caseId: string) => { useSearchEffect("caseId", (caseId: string) => {
if (!cases) { if (!filteredCases) {
return false; return false;
} }
const exists = cases.some((c) => c.id === caseId); const exists = filteredCases.some((c) => c.id === caseId);
if (!exists) { if (!exists) {
return false; return false;
@ -144,8 +194,8 @@ function Exports() {
useKeyboardListener([], undefined, contentRef); useKeyboardListener([], undefined, contentRef);
const selectedCase = useMemo( const selectedCase = useMemo(
() => cases?.find((c) => c.id === selectedCaseId), () => filteredCases?.find((c) => c.id === selectedCaseId),
[cases, selectedCaseId], [filteredCases, selectedCaseId],
); );
const resetCaseDialog = useCallback(() => { const resetCaseDialog = useCallback(() => {
@ -232,22 +282,33 @@ function Exports() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{(exports?.length || cases?.length) && ( <div
<div className="flex w-full items-center justify-center p-2"> className={cn(
"flex w-full flex-col items-start space-y-2 pr-2 md:mb-2 lg:relative lg:h-10 lg:flex-row lg:items-center lg:space-y-0",
isMobileOnly && "mb-2 h-auto flex-wrap gap-2 space-y-0",
)}
>
<div className="w-full">
<Input <Input
className="text-md w-full bg-muted md:w-1/3" className="text-md w-full bg-muted md:w-1/2"
placeholder={t("search")} placeholder={t("search")}
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
/> />
</div> </div>
)} <ExportFilterGroup
className="w-full justify-between md:justify-start lg:justify-end"
filter={exportFilter}
filters={["cameras"]}
onUpdateFilter={setExportFilter}
/>
</div>
{selectedCase ? ( {selectedCase ? (
<CaseView <CaseView
contentRef={contentRef} contentRef={contentRef}
selectedCase={selectedCase} selectedCase={selectedCase}
exports={rawExports} exports={exportsByCase[selectedCase.id] || []}
search={search} search={search}
setSelected={setSelected} setSelected={setSelected}
renameClip={onHandleRename} renameClip={onHandleRename}
@ -258,7 +319,7 @@ function Exports() {
<AllExportsView <AllExportsView
contentRef={contentRef} contentRef={contentRef}
search={search} search={search}
cases={cases} cases={filteredCases}
exports={exports} exports={exports}
setSelectedCaseId={setSelectedCaseId} setSelectedCaseId={setSelectedCaseId}
setSelected={setSelected} setSelected={setSelected}
@ -428,8 +489,8 @@ function CaseView({
}, [selectedCase, exports, search]); }, [selectedCase, exports, search]);
return ( return (
<div className="flex size-full flex-col gap-8"> <div className="flex size-full flex-col gap-8 overflow-hidden">
<div className="flex flex-col gap-1"> <div className="flex shrink-0 flex-col gap-1">
<Heading className="capitalize" as="h2"> <Heading className="capitalize" as="h2">
{selectedCase.name} {selectedCase.name}
</Heading> </Heading>
@ -439,7 +500,7 @@ function CaseView({
</div> </div>
<div <div
ref={contentRef} ref={contentRef}
className="scrollbar-container grid gap-2 overflow-y-auto sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4" className="scrollbar-container grid min-h-0 flex-1 content-start 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

View File

@ -21,3 +21,13 @@ export type DeleteClipType = {
file: string; file: string;
exportName: string; exportName: string;
}; };
// filtering
const EXPORT_FILTERS = ["cameras"] as const;
export type ExportFilters = (typeof EXPORT_FILTERS)[number];
export const DEFAULT_EXPORT_FILTERS: ExportFilters[] = ["cameras"];
export type ExportFilter = {
cameras?: string[];
};

View File

@ -4,7 +4,7 @@ import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc"; import react from "@vitejs/plugin-react-swc";
import monacoEditorPlugin from "vite-plugin-monaco-editor"; import monacoEditorPlugin from "vite-plugin-monaco-editor";
const proxyHost = process.env.PROXY_HOST || "localhost:5000"; const proxyHost = process.env.PROXY_HOST || "1ocalhost:5000";
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({