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(
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)

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 { 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<ExportFilter>(EXPORT_FILTER_ARRAY_KEYS);
// Data
const { data: cases, mutate: updateCases } = useSWR<ExportCase[]>("cases");
const { data: rawExports, mutate: updateExports } =
useSWR<Export[]>("exports");
const { data: rawExports, mutate: updateExports } = useSWR<Export[]>(
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[]>(
() => (rawExports ?? []).filter((e) => !e.export_case),
[rawExports],
() => exportsByCase["none"] || [],
[exportsByCase],
);
const mutate = useCallback(() => {
@ -66,26 +109,33 @@ function Exports() {
// Viewing
const [selected, setSelected] = useState<Export>();
const [selectedCaseId, setSelectedCaseId] = useOverlayState<
string | undefined
>("caseId", undefined);
const [selectedCaseId, setSelectedCaseId] = useState<string | undefined>(
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() {
</DialogContent>
</Dialog>
{(exports?.length || cases?.length) && (
<div className="flex w-full items-center justify-center p-2">
<div
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
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")}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
)}
<ExportFilterGroup
className="w-full justify-between md:justify-start lg:justify-end"
filter={exportFilter}
filters={["cameras"]}
onUpdateFilter={setExportFilter}
/>
</div>
{selectedCase ? (
<CaseView
contentRef={contentRef}
selectedCase={selectedCase}
exports={rawExports}
exports={exportsByCase[selectedCase.id] || []}
search={search}
setSelected={setSelected}
renameClip={onHandleRename}
@ -258,7 +319,7 @@ function Exports() {
<AllExportsView
contentRef={contentRef}
search={search}
cases={cases}
cases={filteredCases}
exports={exports}
setSelectedCaseId={setSelectedCaseId}
setSelected={setSelected}
@ -428,8 +489,8 @@ function CaseView({
}, [selectedCase, exports, search]);
return (
<div className="flex size-full flex-col gap-8">
<div className="flex flex-col gap-1">
<div className="flex size-full flex-col gap-8 overflow-hidden">
<div className="flex shrink-0 flex-col gap-1">
<Heading className="capitalize" as="h2">
{selectedCase.name}
</Heading>
@ -439,7 +500,7 @@ function CaseView({
</div>
<div
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) => (
<ExportCard

View File

@ -21,3 +21,13 @@ export type DeleteClipType = {
file: 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 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/
export default defineConfig({