mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-01-22 20:18:30 +03:00
Case management UI (#21299)
* Refactor export cards to match existing cards in other UI pages * Show cases separately from exports * Add proper filtering and display of cases * Add ability to edit and select cases for exports * Cleanup typing * Hide if no unassigned * Cleanup hiding logic * fix scrolling * Improve layout
This commit is contained in:
parent
a08c044144
commit
08311a6ee2
@ -2,6 +2,10 @@
|
|||||||
"documentTitle": "Export - Frigate",
|
"documentTitle": "Export - Frigate",
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
"noExports": "No exports found",
|
"noExports": "No exports found",
|
||||||
|
"headings": {
|
||||||
|
"cases": "Cases",
|
||||||
|
"uncategorizedExports": "Uncategorized Exports"
|
||||||
|
},
|
||||||
"deleteExport": "Delete Export",
|
"deleteExport": "Delete Export",
|
||||||
"deleteExport.desc": "Are you sure you want to delete {{exportName}}?",
|
"deleteExport.desc": "Are you sure you want to delete {{exportName}}?",
|
||||||
"editExport": {
|
"editExport": {
|
||||||
@ -13,11 +17,21 @@
|
|||||||
"shareExport": "Share export",
|
"shareExport": "Share export",
|
||||||
"downloadVideo": "Download video",
|
"downloadVideo": "Download video",
|
||||||
"editName": "Edit name",
|
"editName": "Edit name",
|
||||||
"deleteExport": "Delete export"
|
"deleteExport": "Delete export",
|
||||||
|
"assignToCase": "Add to case"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"error": {
|
"error": {
|
||||||
"renameExportFailed": "Failed to rename export: {{errorMessage}}"
|
"renameExportFailed": "Failed to rename export: {{errorMessage}}",
|
||||||
|
"assignCaseFailed": "Failed to update case assignment: {{errorMessage}}"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"caseDialog": {
|
||||||
|
"title": "Add to case",
|
||||||
|
"description": "Choose an existing case or create a new one.",
|
||||||
|
"selectLabel": "Case",
|
||||||
|
"newCaseOption": "Create new case",
|
||||||
|
"nameLabel": "Case name",
|
||||||
|
"descriptionLabel": "Description"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
import ActivityIndicator from "../indicators/activity-indicator";
|
import ActivityIndicator from "../indicators/activity-indicator";
|
||||||
import { LuTrash } from "react-icons/lu";
|
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { isDesktop, isMobile } from "react-device-detect";
|
import { isMobile } from "react-device-detect";
|
||||||
import { FaDownload, FaPlay, FaShareAlt } from "react-icons/fa";
|
import { FiMoreVertical } from "react-icons/fi";
|
||||||
import { Skeleton } from "../ui/skeleton";
|
import { Skeleton } from "../ui/skeleton";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@ -14,35 +13,62 @@ import {
|
|||||||
} from "../ui/dialog";
|
} from "../ui/dialog";
|
||||||
import { Input } from "../ui/input";
|
import { Input } from "../ui/input";
|
||||||
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
||||||
import { DeleteClipType, Export } from "@/types/export";
|
import { DeleteClipType, Export, ExportCase } from "@/types/export";
|
||||||
import { MdEditSquare } from "react-icons/md";
|
|
||||||
import { baseUrl } from "@/api/baseUrl";
|
import { baseUrl } from "@/api/baseUrl";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { shareOrCopy } from "@/utils/browserUtil";
|
import { shareOrCopy } from "@/utils/browserUtil";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ImageShadowOverlay } from "../overlay/ImageShadowOverlay";
|
import { ImageShadowOverlay } from "../overlay/ImageShadowOverlay";
|
||||||
import BlurredIconButton from "../button/BlurredIconButton";
|
import BlurredIconButton from "../button/BlurredIconButton";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
|
||||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "../ui/dropdown-menu";
|
||||||
|
import { FaFolder } from "react-icons/fa";
|
||||||
|
|
||||||
type ExportProps = {
|
type CaseCardProps = {
|
||||||
|
className: string;
|
||||||
|
exportCase: ExportCase;
|
||||||
|
onSelect: () => void;
|
||||||
|
};
|
||||||
|
export function CaseCard({ className, exportCase, onSelect }: CaseCardProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative flex aspect-video size-full cursor-pointer items-center justify-center rounded-lg bg-secondary md:rounded-2xl",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
onClick={() => onSelect()}
|
||||||
|
>
|
||||||
|
<div className="absolute bottom-2 left-2 flex items-center justify-start gap-2">
|
||||||
|
<FaFolder />
|
||||||
|
<div className="capitalize">{exportCase.name}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExportCardProps = {
|
||||||
className: string;
|
className: string;
|
||||||
exportedRecording: Export;
|
exportedRecording: Export;
|
||||||
onSelect: (selected: Export) => void;
|
onSelect: (selected: Export) => void;
|
||||||
onRename: (original: string, update: string) => void;
|
onRename: (original: string, update: string) => void;
|
||||||
onDelete: ({ file, exportName }: DeleteClipType) => void;
|
onDelete: ({ file, exportName }: DeleteClipType) => void;
|
||||||
|
onAssignToCase?: (selected: Export) => void;
|
||||||
};
|
};
|
||||||
|
export function ExportCard({
|
||||||
export default function ExportCard({
|
|
||||||
className,
|
className,
|
||||||
exportedRecording,
|
exportedRecording,
|
||||||
onSelect,
|
onSelect,
|
||||||
onRename,
|
onRename,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: ExportProps) {
|
onAssignToCase,
|
||||||
|
}: ExportCardProps) {
|
||||||
const { t } = useTranslation(["views/exports"]);
|
const { t } = useTranslation(["views/exports"]);
|
||||||
const isAdmin = useIsAdmin();
|
const isAdmin = useIsAdmin();
|
||||||
const [hovered, setHovered] = useState(false);
|
|
||||||
const [loading, setLoading] = useState(
|
const [loading, setLoading] = useState(
|
||||||
exportedRecording.thumb_path.length > 0,
|
exportedRecording.thumb_path.length > 0,
|
||||||
);
|
);
|
||||||
@ -136,12 +162,14 @@ export default function ExportCard({
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex aspect-video items-center justify-center rounded-lg bg-black md:rounded-2xl",
|
"relative flex aspect-video cursor-pointer items-center justify-center rounded-lg bg-black md:rounded-2xl",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
onMouseEnter={isDesktop ? () => setHovered(true) : undefined}
|
onClick={() => {
|
||||||
onMouseLeave={isDesktop ? () => setHovered(false) : undefined}
|
if (!exportedRecording.in_progress) {
|
||||||
onClick={isDesktop ? undefined : () => setHovered(!hovered)}
|
onSelect(exportedRecording);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{exportedRecording.in_progress ? (
|
{exportedRecording.in_progress ? (
|
||||||
<ActivityIndicator />
|
<ActivityIndicator />
|
||||||
@ -158,95 +186,88 @@ export default function ExportCard({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{hovered && (
|
{!exportedRecording.in_progress && (
|
||||||
<>
|
<div className="absolute bottom-2 right-3 z-40">
|
||||||
<div className="absolute inset-0 rounded-lg bg-black bg-opacity-60 md:rounded-2xl" />
|
<DropdownMenu modal={false}>
|
||||||
<div className="absolute right-3 top-2">
|
<DropdownMenuTrigger>
|
||||||
<div className="flex items-center justify-center gap-4">
|
<BlurredIconButton
|
||||||
{!exportedRecording.in_progress && (
|
aria-label={t("tooltip.editName")}
|
||||||
<Tooltip>
|
onClick={(e) => e.stopPropagation()}
|
||||||
<TooltipTrigger asChild>
|
>
|
||||||
<BlurredIconButton
|
<FiMoreVertical className="size-5" />
|
||||||
onClick={() =>
|
</BlurredIconButton>
|
||||||
shareOrCopy(
|
</DropdownMenuTrigger>
|
||||||
`${baseUrl}export?id=${exportedRecording.id}`,
|
<DropdownMenuContent align="end">
|
||||||
exportedRecording.name.replaceAll("_", " "),
|
<DropdownMenuItem
|
||||||
)
|
className="cursor-pointer"
|
||||||
}
|
aria-label={t("tooltip.shareExport")}
|
||||||
>
|
onClick={(e) => {
|
||||||
<FaShareAlt className="size-4" />
|
e.stopPropagation();
|
||||||
</BlurredIconButton>
|
shareOrCopy(
|
||||||
</TooltipTrigger>
|
`${baseUrl}export?id=${exportedRecording.id}`,
|
||||||
<TooltipContent>{t("tooltip.shareExport")}</TooltipContent>
|
exportedRecording.name.replaceAll("_", " "),
|
||||||
</Tooltip>
|
);
|
||||||
)}
|
}}
|
||||||
{!exportedRecording.in_progress && (
|
>
|
||||||
|
{t("tooltip.shareExport")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="cursor-pointer"
|
||||||
|
aria-label={t("tooltip.downloadVideo")}
|
||||||
|
>
|
||||||
<a
|
<a
|
||||||
download
|
download
|
||||||
href={`${baseUrl}${exportedRecording.video_path.replace("/media/frigate/", "")}`}
|
href={`${baseUrl}${exportedRecording.video_path.replace("/media/frigate/", "")}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<Tooltip>
|
{t("tooltip.downloadVideo")}
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<BlurredIconButton>
|
|
||||||
<FaDownload className="size-4" />
|
|
||||||
</BlurredIconButton>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
{t("tooltip.downloadVideo")}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</a>
|
</a>
|
||||||
)}
|
</DropdownMenuItem>
|
||||||
{isAdmin && !exportedRecording.in_progress && (
|
{isAdmin && onAssignToCase && (
|
||||||
<Tooltip>
|
<DropdownMenuItem
|
||||||
<TooltipTrigger asChild>
|
className="cursor-pointer"
|
||||||
<BlurredIconButton
|
aria-label={t("tooltip.assignToCase")}
|
||||||
onClick={() =>
|
onClick={(e) => {
|
||||||
setEditName({
|
e.stopPropagation();
|
||||||
original: exportedRecording.name,
|
onAssignToCase(exportedRecording);
|
||||||
update: undefined,
|
}}
|
||||||
})
|
>
|
||||||
}
|
{t("tooltip.assignToCase")}
|
||||||
>
|
</DropdownMenuItem>
|
||||||
<MdEditSquare className="size-4" />
|
|
||||||
</BlurredIconButton>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>{t("tooltip.editName")}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Tooltip>
|
<DropdownMenuItem
|
||||||
<TooltipTrigger asChild>
|
className="cursor-pointer"
|
||||||
<BlurredIconButton
|
aria-label={t("tooltip.editName")}
|
||||||
onClick={() =>
|
onClick={(e) => {
|
||||||
onDelete({
|
e.stopPropagation();
|
||||||
file: exportedRecording.id,
|
setEditName({
|
||||||
exportName: exportedRecording.name,
|
original: exportedRecording.name,
|
||||||
})
|
update: undefined,
|
||||||
}
|
});
|
||||||
>
|
}}
|
||||||
<LuTrash className="size-4 fill-destructive text-destructive hover:text-white" />
|
>
|
||||||
</BlurredIconButton>
|
{t("tooltip.editName")}
|
||||||
</TooltipTrigger>
|
</DropdownMenuItem>
|
||||||
<TooltipContent>{t("tooltip.deleteExport")}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
{isAdmin && (
|
||||||
</div>
|
<DropdownMenuItem
|
||||||
|
className="cursor-pointer"
|
||||||
{!exportedRecording.in_progress && (
|
aria-label={t("tooltip.deleteExport")}
|
||||||
<Button
|
onClick={(e) => {
|
||||||
className="absolute left-1/2 top-1/2 h-20 w-20 -translate-x-1/2 -translate-y-1/2 cursor-pointer text-white hover:bg-transparent hover:text-white"
|
e.stopPropagation();
|
||||||
aria-label={t("button.play", { ns: "common" })}
|
onDelete({
|
||||||
variant="ghost"
|
file: exportedRecording.id,
|
||||||
onClick={() => {
|
exportName: exportedRecording.name,
|
||||||
onSelect(exportedRecording);
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FaPlay />
|
{t("tooltip.deleteExport")}
|
||||||
</Button>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
</>
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{loading && (
|
{loading && (
|
||||||
<Skeleton className="absolute inset-0 aspect-video rounded-lg md:rounded-2xl" />
|
<Skeleton className="absolute inset-0 aspect-video rounded-lg md:rounded-2xl" />
|
||||||
|
|||||||
166
web/src/components/overlay/dialog/OptionAndInputDialog.tsx
Normal file
166
web/src/components/overlay/dialog/OptionAndInputDialog.tsx
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { isMobile } from "react-device-detect";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
type Option = {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OptionAndInputDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
options: Option[];
|
||||||
|
newValueKey: string;
|
||||||
|
initialValue?: string;
|
||||||
|
nameLabel: string;
|
||||||
|
descriptionLabel: string;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
onSave: (value: string) => void;
|
||||||
|
onCreateNew: (name: string, description: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function OptionAndInputDialog({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
options,
|
||||||
|
newValueKey,
|
||||||
|
initialValue,
|
||||||
|
nameLabel,
|
||||||
|
descriptionLabel,
|
||||||
|
setOpen,
|
||||||
|
onSave,
|
||||||
|
onCreateNew,
|
||||||
|
}: OptionAndInputDialogProps) {
|
||||||
|
const { t } = useTranslation("common");
|
||||||
|
const firstOption = useMemo(() => options[0]?.value, [options]);
|
||||||
|
|
||||||
|
const [selectedValue, setSelectedValue] = useState<string | undefined>(
|
||||||
|
initialValue ?? firstOption,
|
||||||
|
);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [descriptionValue, setDescriptionValue] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setSelectedValue(initialValue ?? firstOption);
|
||||||
|
setName("");
|
||||||
|
setDescriptionValue("");
|
||||||
|
}
|
||||||
|
}, [open, initialValue, firstOption]);
|
||||||
|
|
||||||
|
const isNew = selectedValue === newValueKey;
|
||||||
|
const disableSave = !selectedValue || (isNew && name.trim().length === 0);
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!selectedValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedName = name.trim();
|
||||||
|
const trimmedDescription = descriptionValue.trim();
|
||||||
|
|
||||||
|
if (isNew) {
|
||||||
|
onCreateNew(trimmedName, trimmedDescription);
|
||||||
|
} else {
|
||||||
|
onSave(selectedValue);
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} defaultOpen={false} onOpenChange={setOpen}>
|
||||||
|
<DialogContent
|
||||||
|
className={cn("space-y-4", isMobile && "px-4")}
|
||||||
|
onOpenAutoFocus={(e) => {
|
||||||
|
if (isMobile) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
{description && <DialogDescription>{description}</DialogDescription>}
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Select
|
||||||
|
value={selectedValue}
|
||||||
|
onValueChange={(val) => setSelectedValue(val)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{options.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isNew && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm font-medium text-secondary-foreground">
|
||||||
|
{nameLabel}
|
||||||
|
</label>
|
||||||
|
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm font-medium text-secondary-foreground">
|
||||||
|
{descriptionLabel}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={descriptionValue}
|
||||||
|
onChange={(e) => setDescriptionValue(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter className={cn("pt-2", isMobile && "gap-2")}>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("button.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="select"
|
||||||
|
disabled={disableSave}
|
||||||
|
onClick={handleSave}
|
||||||
|
>
|
||||||
|
{t("button.save")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { baseUrl } from "@/api/baseUrl";
|
import { baseUrl } from "@/api/baseUrl";
|
||||||
import ExportCard from "@/components/card/ExportCard";
|
import { CaseCard, ExportCard } from "@/components/card/ExportCard";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogCancel,
|
AlertDialogCancel,
|
||||||
@ -11,15 +11,24 @@ import {
|
|||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
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 { useSearchEffect } from "@/hooks/use-overlay-state";
|
import { useOverlayState, useSearchEffect } from "@/hooks/use-overlay-state";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { DeleteClipType, Export } from "@/types/export";
|
import { DeleteClipType, Export, ExportCase } from "@/types/export";
|
||||||
|
import OptionAndInputDialog from "@/components/overlay/dialog/OptionAndInputDialog";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import {
|
||||||
|
MutableRefObject,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { isMobile } from "react-device-detect";
|
import { isMobile } from "react-device-detect";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@ -29,32 +38,37 @@ import useSWR from "swr";
|
|||||||
|
|
||||||
function Exports() {
|
function Exports() {
|
||||||
const { t } = useTranslation(["views/exports"]);
|
const { t } = useTranslation(["views/exports"]);
|
||||||
const { data: exports, mutate } = useSWR<Export[]>("exports");
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = t("documentTitle");
|
document.title = t("documentTitle");
|
||||||
}, [t]);
|
}, [t]);
|
||||||
|
|
||||||
|
// Data
|
||||||
|
|
||||||
|
const { data: cases, mutate: updateCases } = useSWR<ExportCase[]>("cases");
|
||||||
|
const { data: rawExports, mutate: updateExports } =
|
||||||
|
useSWR<Export[]>("exports");
|
||||||
|
|
||||||
|
const exports = useMemo<Export[]>(
|
||||||
|
() => (rawExports ?? []).filter((e) => !e.export_case),
|
||||||
|
[rawExports],
|
||||||
|
);
|
||||||
|
|
||||||
|
const mutate = useCallback(() => {
|
||||||
|
updateExports();
|
||||||
|
updateCases();
|
||||||
|
}, [updateExports, updateCases]);
|
||||||
|
|
||||||
// Search
|
// Search
|
||||||
|
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
const filteredExports = useMemo(() => {
|
|
||||||
if (!search || !exports) {
|
|
||||||
return exports;
|
|
||||||
}
|
|
||||||
|
|
||||||
return exports.filter((exp) =>
|
|
||||||
exp.name
|
|
||||||
.toLowerCase()
|
|
||||||
.replaceAll("_", " ")
|
|
||||||
.includes(search.toLowerCase()),
|
|
||||||
);
|
|
||||||
}, [exports, search]);
|
|
||||||
|
|
||||||
// Viewing
|
// Viewing
|
||||||
|
|
||||||
const [selected, setSelected] = useState<Export>();
|
const [selected, setSelected] = useState<Export>();
|
||||||
|
const [selectedCaseId, setSelectedCaseId] = useOverlayState<
|
||||||
|
string | undefined
|
||||||
|
>("caseId", undefined);
|
||||||
const [selectedAspect, setSelectedAspect] = useState(0.0);
|
const [selectedAspect, setSelectedAspect] = useState(0.0);
|
||||||
|
|
||||||
useSearchEffect("id", (id) => {
|
useSearchEffect("id", (id) => {
|
||||||
@ -66,9 +80,25 @@ function Exports() {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Deleting
|
useSearchEffect("caseId", (caseId: string) => {
|
||||||
|
if (!cases) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exists = cases.some((c) => c.id === caseId);
|
||||||
|
|
||||||
|
if (!exists) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedCaseId(caseId);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Modifying
|
||||||
|
|
||||||
const [deleteClip, setDeleteClip] = useState<DeleteClipType | undefined>();
|
const [deleteClip, setDeleteClip] = useState<DeleteClipType | undefined>();
|
||||||
|
const [exportToAssign, setExportToAssign] = useState<Export | undefined>();
|
||||||
|
|
||||||
const onHandleDelete = useCallback(() => {
|
const onHandleDelete = useCallback(() => {
|
||||||
if (!deleteClip) {
|
if (!deleteClip) {
|
||||||
@ -83,8 +113,6 @@ function Exports() {
|
|||||||
});
|
});
|
||||||
}, [deleteClip, mutate]);
|
}, [deleteClip, mutate]);
|
||||||
|
|
||||||
// Renaming
|
|
||||||
|
|
||||||
const onHandleRename = useCallback(
|
const onHandleRename = useCallback(
|
||||||
(id: string, update: string) => {
|
(id: string, update: string) => {
|
||||||
axios
|
axios
|
||||||
@ -107,7 +135,7 @@ function Exports() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[mutate, t],
|
[mutate, setDeleteClip, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Keyboard Listener
|
// Keyboard Listener
|
||||||
@ -115,10 +143,27 @@ function Exports() {
|
|||||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||||
useKeyboardListener([], undefined, contentRef);
|
useKeyboardListener([], undefined, contentRef);
|
||||||
|
|
||||||
|
const selectedCase = useMemo(
|
||||||
|
() => cases?.find((c) => c.id === selectedCaseId),
|
||||||
|
[cases, selectedCaseId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const resetCaseDialog = useCallback(() => {
|
||||||
|
setExportToAssign(undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex size-full flex-col gap-2 overflow-hidden px-1 pt-2 md:p-2">
|
<div className="flex size-full flex-col gap-2 overflow-hidden px-1 pt-2 md:p-2">
|
||||||
<Toaster closeButton={true} />
|
<Toaster closeButton={true} />
|
||||||
|
|
||||||
|
<CaseAssignmentDialog
|
||||||
|
exportToAssign={exportToAssign}
|
||||||
|
cases={cases}
|
||||||
|
selectedCaseId={selectedCaseId}
|
||||||
|
onClose={resetCaseDialog}
|
||||||
|
mutate={mutate}
|
||||||
|
/>
|
||||||
|
|
||||||
<AlertDialog
|
<AlertDialog
|
||||||
open={deleteClip != undefined}
|
open={deleteClip != undefined}
|
||||||
onOpenChange={() => setDeleteClip(undefined)}
|
onOpenChange={() => setDeleteClip(undefined)}
|
||||||
@ -187,7 +232,7 @@ function Exports() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{exports && (
|
{(exports?.length || cases?.length) && (
|
||||||
<div className="flex w-full items-center justify-center p-2">
|
<div className="flex w-full items-center justify-center p-2">
|
||||||
<Input
|
<Input
|
||||||
className="text-md w-full bg-muted md:w-1/3"
|
className="text-md w-full bg-muted md:w-1/3"
|
||||||
@ -198,36 +243,338 @@ function Exports() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="w-full overflow-hidden">
|
{selectedCase ? (
|
||||||
{exports && filteredExports && filteredExports.length > 0 ? (
|
<CaseView
|
||||||
<div
|
contentRef={contentRef}
|
||||||
ref={contentRef}
|
selectedCase={selectedCase}
|
||||||
className="scrollbar-container grid size-full gap-2 overflow-y-auto sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
exports={rawExports}
|
||||||
>
|
search={search}
|
||||||
{Object.values(exports).map((item) => (
|
setSelected={setSelected}
|
||||||
<ExportCard
|
renameClip={onHandleRename}
|
||||||
key={item.name}
|
setDeleteClip={setDeleteClip}
|
||||||
className={
|
onAssignToCase={setExportToAssign}
|
||||||
search == "" || filteredExports.includes(item) ? "" : "hidden"
|
/>
|
||||||
}
|
) : (
|
||||||
exportedRecording={item}
|
<AllExportsView
|
||||||
onSelect={setSelected}
|
contentRef={contentRef}
|
||||||
onRename={onHandleRename}
|
search={search}
|
||||||
onDelete={({ file, exportName }) =>
|
cases={cases}
|
||||||
setDeleteClip({ file, exportName })
|
exports={exports}
|
||||||
}
|
setSelectedCaseId={setSelectedCaseId}
|
||||||
/>
|
setSelected={setSelected}
|
||||||
))}
|
renameClip={onHandleRename}
|
||||||
</div>
|
setDeleteClip={setDeleteClip}
|
||||||
) : (
|
onAssignToCase={setExportToAssign}
|
||||||
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center text-center">
|
/>
|
||||||
<LuFolderX className="size-16" />
|
)}
|
||||||
{t("noExports")}
|
</div>
|
||||||
</div>
|
);
|
||||||
)}
|
}
|
||||||
|
|
||||||
|
type AllExportsViewProps = {
|
||||||
|
contentRef: MutableRefObject<HTMLDivElement | null>;
|
||||||
|
search: string;
|
||||||
|
cases?: ExportCase[];
|
||||||
|
exports: Export[];
|
||||||
|
setSelectedCaseId: (id: string) => void;
|
||||||
|
setSelected: (e: Export) => void;
|
||||||
|
renameClip: (id: string, update: string) => void;
|
||||||
|
setDeleteClip: (d: DeleteClipType | undefined) => void;
|
||||||
|
onAssignToCase: (e: Export) => void;
|
||||||
|
};
|
||||||
|
function AllExportsView({
|
||||||
|
contentRef,
|
||||||
|
search,
|
||||||
|
cases,
|
||||||
|
exports,
|
||||||
|
setSelectedCaseId,
|
||||||
|
setSelected,
|
||||||
|
renameClip,
|
||||||
|
setDeleteClip,
|
||||||
|
onAssignToCase,
|
||||||
|
}: AllExportsViewProps) {
|
||||||
|
const { t } = useTranslation(["views/exports"]);
|
||||||
|
|
||||||
|
// Filter
|
||||||
|
|
||||||
|
const filteredCases = useMemo(() => {
|
||||||
|
if (!search || !cases) {
|
||||||
|
return cases || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return cases.filter(
|
||||||
|
(caseItem) =>
|
||||||
|
caseItem.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
(caseItem.description &&
|
||||||
|
caseItem.description.toLowerCase().includes(search.toLowerCase())),
|
||||||
|
);
|
||||||
|
}, [search, cases]);
|
||||||
|
|
||||||
|
const filteredExports = useMemo<Export[]>(() => {
|
||||||
|
if (!search) {
|
||||||
|
return exports;
|
||||||
|
}
|
||||||
|
|
||||||
|
return exports.filter((exp) =>
|
||||||
|
exp.name
|
||||||
|
.toLowerCase()
|
||||||
|
.replaceAll("_", " ")
|
||||||
|
.includes(search.toLowerCase()),
|
||||||
|
);
|
||||||
|
}, [exports, search]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full overflow-hidden">
|
||||||
|
{filteredCases?.length || filteredExports.length ? (
|
||||||
|
<div
|
||||||
|
ref={contentRef}
|
||||||
|
className="scrollbar-container flex size-full flex-col gap-4 overflow-y-auto"
|
||||||
|
>
|
||||||
|
{filteredCases.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Heading as="h4">{t("headings.cases")}</Heading>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{cases?.map((item) => (
|
||||||
|
<CaseCard
|
||||||
|
key={item.name}
|
||||||
|
className={
|
||||||
|
search == "" || filteredCases?.includes(item)
|
||||||
|
? ""
|
||||||
|
: "hidden"
|
||||||
|
}
|
||||||
|
exportCase={item}
|
||||||
|
onSelect={() => {
|
||||||
|
setSelectedCaseId(item.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filteredExports.length > 0 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Heading as="h4">{t("headings.uncategorizedExports")}</Heading>
|
||||||
|
<div
|
||||||
|
ref={contentRef}
|
||||||
|
className="scrollbar-container grid gap-2 overflow-y-auto sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||||
|
>
|
||||||
|
{exports.map((item) => (
|
||||||
|
<ExportCard
|
||||||
|
key={item.name}
|
||||||
|
className={
|
||||||
|
search == "" || filteredExports.includes(item)
|
||||||
|
? ""
|
||||||
|
: "hidden"
|
||||||
|
}
|
||||||
|
exportedRecording={item}
|
||||||
|
onSelect={setSelected}
|
||||||
|
onRename={renameClip}
|
||||||
|
onDelete={({ file, exportName }) =>
|
||||||
|
setDeleteClip({ file, exportName })
|
||||||
|
}
|
||||||
|
onAssignToCase={onAssignToCase}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center text-center">
|
||||||
|
<LuFolderX className="size-16" />
|
||||||
|
{t("noExports")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type CaseViewProps = {
|
||||||
|
contentRef: MutableRefObject<HTMLDivElement | null>;
|
||||||
|
selectedCase: ExportCase;
|
||||||
|
exports?: Export[];
|
||||||
|
search: string;
|
||||||
|
setSelected: (e: Export) => void;
|
||||||
|
renameClip: (id: string, update: string) => void;
|
||||||
|
setDeleteClip: (d: DeleteClipType | undefined) => void;
|
||||||
|
onAssignToCase: (e: Export) => void;
|
||||||
|
};
|
||||||
|
function CaseView({
|
||||||
|
contentRef,
|
||||||
|
selectedCase,
|
||||||
|
exports,
|
||||||
|
search,
|
||||||
|
setSelected,
|
||||||
|
renameClip,
|
||||||
|
setDeleteClip,
|
||||||
|
onAssignToCase,
|
||||||
|
}: CaseViewProps) {
|
||||||
|
const filteredExports = useMemo<Export[]>(() => {
|
||||||
|
const caseExports = (exports || []).filter(
|
||||||
|
(e) => e.export_case == selectedCase.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!search) {
|
||||||
|
return caseExports;
|
||||||
|
}
|
||||||
|
|
||||||
|
return caseExports.filter((exp) =>
|
||||||
|
exp.name
|
||||||
|
.toLowerCase()
|
||||||
|
.replaceAll("_", " ")
|
||||||
|
.includes(search.toLowerCase()),
|
||||||
|
);
|
||||||
|
}, [selectedCase, exports, search]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex size-full flex-col gap-8">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Heading className="capitalize" as="h2">
|
||||||
|
{selectedCase.name}
|
||||||
|
</Heading>
|
||||||
|
<div className="text-secondary-foreground">
|
||||||
|
{selectedCase.description}
|
||||||
|
</div>
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
{exports?.map((item) => (
|
||||||
|
<ExportCard
|
||||||
|
key={item.name}
|
||||||
|
className={filteredExports.includes(item) ? "" : "hidden"}
|
||||||
|
exportedRecording={item}
|
||||||
|
onSelect={setSelected}
|
||||||
|
onRename={renameClip}
|
||||||
|
onDelete={({ file, exportName }) =>
|
||||||
|
setDeleteClip({ file, exportName })
|
||||||
|
}
|
||||||
|
onAssignToCase={onAssignToCase}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CaseAssignmentDialogProps = {
|
||||||
|
exportToAssign?: Export;
|
||||||
|
cases?: ExportCase[];
|
||||||
|
selectedCaseId?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
mutate: () => void;
|
||||||
|
};
|
||||||
|
function CaseAssignmentDialog({
|
||||||
|
exportToAssign,
|
||||||
|
cases,
|
||||||
|
selectedCaseId,
|
||||||
|
onClose,
|
||||||
|
mutate,
|
||||||
|
}: CaseAssignmentDialogProps) {
|
||||||
|
const { t } = useTranslation(["views/exports"]);
|
||||||
|
const caseOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
...(cases ?? [])
|
||||||
|
.map((c) => ({
|
||||||
|
value: c.id,
|
||||||
|
label: c.name,
|
||||||
|
}))
|
||||||
|
.sort((cA, cB) => cA.label.localeCompare(cB.label)),
|
||||||
|
{
|
||||||
|
value: "new",
|
||||||
|
label: t("caseDialog.newCaseOption"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[cases, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSave = useCallback(
|
||||||
|
async (caseId: string) => {
|
||||||
|
if (!exportToAssign) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axios.patch(`export/${exportToAssign.id}/case`, {
|
||||||
|
export_case_id: caseId,
|
||||||
|
});
|
||||||
|
mutate();
|
||||||
|
onClose();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const apiError = error as {
|
||||||
|
response?: { data?: { message?: string; detail?: string } };
|
||||||
|
};
|
||||||
|
const errorMessage =
|
||||||
|
apiError.response?.data?.message ||
|
||||||
|
apiError.response?.data?.detail ||
|
||||||
|
"Unknown error";
|
||||||
|
toast.error(t("toast.error.assignCaseFailed", { errorMessage }), {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[exportToAssign, mutate, onClose, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreateNew = useCallback(
|
||||||
|
async (name: string, description: string) => {
|
||||||
|
if (!exportToAssign) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const createResp = await axios.post("cases", {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
});
|
||||||
|
|
||||||
|
const newCaseId: string | undefined = createResp.data?.id;
|
||||||
|
|
||||||
|
if (newCaseId) {
|
||||||
|
await axios.patch(`export/${exportToAssign.id}/case`, {
|
||||||
|
export_case_id: newCaseId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
mutate();
|
||||||
|
onClose();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const apiError = error as {
|
||||||
|
response?: { data?: { message?: string; detail?: string } };
|
||||||
|
};
|
||||||
|
const errorMessage =
|
||||||
|
apiError.response?.data?.message ||
|
||||||
|
apiError.response?.data?.detail ||
|
||||||
|
"Unknown error";
|
||||||
|
toast.error(t("toast.error.assignCaseFailed", { errorMessage }), {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[exportToAssign, mutate, onClose, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!exportToAssign) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OptionAndInputDialog
|
||||||
|
open={!!exportToAssign}
|
||||||
|
title={t("caseDialog.title")}
|
||||||
|
description={t("caseDialog.description")}
|
||||||
|
setOpen={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
options={caseOptions}
|
||||||
|
nameLabel={t("caseDialog.nameLabel")}
|
||||||
|
descriptionLabel={t("caseDialog.descriptionLabel")}
|
||||||
|
initialValue={selectedCaseId}
|
||||||
|
newValueKey="new"
|
||||||
|
onSave={handleSave}
|
||||||
|
onCreateNew={handleCreateNew}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default Exports;
|
export default Exports;
|
||||||
|
|||||||
@ -6,6 +6,15 @@ export type Export = {
|
|||||||
video_path: string;
|
video_path: string;
|
||||||
thumb_path: string;
|
thumb_path: string;
|
||||||
in_progress: boolean;
|
in_progress: boolean;
|
||||||
|
export_case?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExportCase = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DeleteClipType = {
|
export type DeleteClipType = {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user