Export improvements (#22867)
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions

* backend

* frontend + i18n

* tests + api spec

* tweak backend to use Job infrastructure for exports

* frontend tweaks and Job infrastructure

* tests

* tweaks

- add ability to remove from case
- change location of counts in case card

* add stale export reaper on startup

* fix toaster close button color

* improve add dialog

* formatting

* hide max_concurrent from camera config export settings

* remove border

* refactor batch endpoint for multiple review items

* frontend

* tests and fastapi spec

* fix deletion of in-progress exports in a case

* tweaks

- hide cases when filtering cameras that have no exports from those cameras
- remove description from case card
- use textarea instead of input for case description in add new case dialog

* add auth exceptions for exports

* add e2e test for deleting cases with exports

* refactor delete and case endpoints

allow bulk deleting and reassigning

* frontend

- bulk selection like Review
- gate admin-only actions
- consolidate dialogs
- spacing/padding tweaks

* i18n and tests

* update openapi spec

* tweaks

- add None to case selection list
- allow new case creation from single cam export dialog

* fix codeql

* fix i18n

* remove unused

* fix frontend tests
This commit is contained in:
Josh Hawkins
2026-04-14 08:19:50 -06:00
committed by GitHub
parent 18c068a3f9
commit e7e6f87682
31 changed files with 6789 additions and 733 deletions
+124 -11
View File
@@ -1,6 +1,6 @@
import ActivityIndicator from "../indicators/activity-indicator";
import { Button } from "../ui/button";
import { useCallback, useMemo, useState } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import { isMobile } from "react-device-detect";
import { FiMoreVertical } from "react-icons/fi";
import { Skeleton } from "../ui/skeleton";
@@ -13,7 +13,7 @@ import {
} from "../ui/dialog";
import { Input } from "../ui/input";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { DeleteClipType, Export, ExportCase } from "@/types/export";
import { DeleteClipType, Export, ExportCase, ExportJob } from "@/types/export";
import { baseUrl } from "@/api/baseUrl";
import { cn } from "@/lib/utils";
import { shareOrCopy } from "@/utils/browserUtil";
@@ -27,7 +27,10 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import { FaFolder } from "react-icons/fa";
import { FaFolder, FaVideo } from "react-icons/fa";
import { HiSquare2Stack } from "react-icons/hi2";
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
import useContextMenu from "@/hooks/use-contextmenu";
type CaseCardProps = {
className: string;
@@ -41,10 +44,15 @@ export function CaseCard({
exports,
onSelect,
}: CaseCardProps) {
const { t } = useTranslation(["views/exports"]);
const firstExport = useMemo(
() => exports.find((exp) => exp.thumb_path && exp.thumb_path.length > 0),
[exports],
);
const cameraCount = useMemo(
() => new Set(exports.map((exp) => exp.camera)).size,
[exports],
);
return (
<div
@@ -61,10 +69,30 @@ export function CaseCard({
alt=""
/>
)}
{!firstExport && (
<div className="absolute inset-0 bg-gradient-to-br from-secondary via-secondary/80 to-muted" />
)}
<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 />
<div className="capitalize">{exportCase.name}</div>
<div className="absolute right-1 top-1 z-40 flex items-center gap-2 rounded-lg bg-black/50 px-2 py-1 text-xs text-white">
<div className="flex items-center gap-1">
<HiSquare2Stack className="size-3" />
<div>{exports.length}</div>
</div>
<div className="flex items-center gap-1">
<FaVideo className="size-3" />
<div>{cameraCount}</div>
</div>
</div>
<div className="absolute inset-x-2 bottom-2 z-20 text-white">
<div className="flex items-center justify-start gap-2">
<FaFolder />
<div className="truncate smart-capitalize">{exportCase.name}</div>
</div>
{exports.length === 0 && (
<div className="mt-1 text-xs text-white/80">
{t("caseCard.emptyCase")}
</div>
)}
</div>
</div>
);
@@ -73,18 +101,26 @@ export function CaseCard({
type ExportCardProps = {
className: string;
exportedRecording: Export;
isSelected?: boolean;
selectionMode?: boolean;
onSelect: (selected: Export) => void;
onContextSelect?: (selected: Export) => void;
onRename: (original: string, update: string) => void;
onDelete: ({ file, exportName }: DeleteClipType) => void;
onAssignToCase?: (selected: Export) => void;
onRemoveFromCase?: (selected: Export) => void;
};
export function ExportCard({
className,
exportedRecording,
isSelected,
selectionMode,
onSelect,
onContextSelect,
onRename,
onDelete,
onAssignToCase,
onRemoveFromCase,
}: ExportCardProps) {
const { t } = useTranslation(["views/exports"]);
const isAdmin = useIsAdmin();
@@ -92,6 +128,15 @@ export function ExportCard({
exportedRecording.thumb_path.length > 0,
);
// selection
const cardRef = useRef<HTMLDivElement | null>(null);
useContextMenu(cardRef, () => {
if (!exportedRecording.in_progress && onContextSelect) {
onContextSelect(exportedRecording);
}
});
// editing name
const [editName, setEditName] = useState<{
@@ -180,13 +225,18 @@ export function ExportCard({
</Dialog>
<div
ref={cardRef}
className={cn(
"relative flex aspect-video cursor-pointer items-center justify-center rounded-lg bg-black md:rounded-2xl",
className,
)}
onClick={() => {
onClick={(e) => {
if (!exportedRecording.in_progress) {
onSelect(exportedRecording);
if ((selectionMode || e.ctrlKey || e.metaKey) && onContextSelect) {
onContextSelect(exportedRecording);
} else {
onSelect(exportedRecording);
}
}
}}
>
@@ -205,7 +255,7 @@ export function ExportCard({
)}
</>
)}
{!exportedRecording.in_progress && (
{!exportedRecording.in_progress && !selectionMode && (
<div className="absolute bottom-2 right-3 z-40">
<DropdownMenu modal={false}>
<DropdownMenuTrigger>
@@ -254,6 +304,18 @@ export function ExportCard({
{t("tooltip.assignToCase")}
</DropdownMenuItem>
)}
{isAdmin && onRemoveFromCase && (
<DropdownMenuItem
className="cursor-pointer"
aria-label={t("tooltip.removeFromCase")}
onClick={(e) => {
e.stopPropagation();
onRemoveFromCase(exportedRecording);
}}
>
{t("tooltip.removeFromCase")}
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
className="cursor-pointer"
@@ -292,10 +354,61 @@ export function ExportCard({
<Skeleton className="absolute inset-0 aspect-video rounded-lg md:rounded-2xl" />
)}
<ImageShadowOverlay />
<div className="absolute bottom-2 left-3 flex items-end text-white smart-capitalize">
{exportedRecording.name.replaceAll("_", " ")}
<div
className={cn(
"pointer-events-none absolute inset-0 z-10 size-full rounded-lg outline outline-[3px] -outline-offset-[2.8px] md:rounded-2xl",
isSelected
? "shadow-selected outline-selected"
: "outline-transparent duration-500",
)}
/>
<div className="absolute bottom-2 left-3 right-12 z-30 text-white">
<div className="truncate smart-capitalize">
{exportedRecording.name.replaceAll("_", " ")}
</div>
</div>
</div>
</>
);
}
type ActiveExportJobCardProps = {
className?: string;
job: ExportJob;
};
export function ActiveExportJobCard({
className = "",
job,
}: ActiveExportJobCardProps) {
const { t } = useTranslation(["views/exports", "common"]);
const cameraName = useCameraFriendlyName(job.camera);
const displayName = useMemo(() => {
if (job.name && job.name.length > 0) {
return job.name.replaceAll("_", " ");
}
return t("jobCard.defaultName", {
camera: cameraName,
});
}, [cameraName, job.name, t]);
const statusLabel =
job.status === "queued" ? t("jobCard.queued") : t("jobCard.running");
return (
<div
className={cn(
"relative flex aspect-video items-center justify-center overflow-hidden rounded-lg border border-dashed border-border bg-secondary/40 md:rounded-2xl",
className,
)}
>
<div className="absolute right-3 top-3 z-30 rounded-full bg-selected/90 px-2 py-1 text-xs text-selected-foreground">
{statusLabel}
</div>
<div className="flex flex-col items-center gap-3 px-6 text-center">
<ActivityIndicator />
<div className="text-sm font-medium text-primary">{displayName}</div>
</div>
</div>
);
}
+1 -1
View File
@@ -81,7 +81,7 @@ export default function ReviewCard({
axios
.post(
`export/${event.camera}/start/${event.start_time + REVIEW_PADDING}/end/${endTime}`,
`export/${event.camera}/start/${event.start_time - REVIEW_PADDING}/end/${endTime}`,
{ playback: "realtime" },
)
.then((response) => {