mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-06-26 06:11:54 +03:00
Some checks failed
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
* remove redundant per-view toasters in settings * add variants to standardize dialog footer button layouts * remove text-md this class name compiles to nothing in tailwind. we used to add it to prevent iOS from zooming when focusing on an input, but that is now solved via the viewport meta in index.html * make wizard footers consistent with dialog footers * consistent destructive button style remove text-white from individual buttons and add it to the variant
242 lines
7.8 KiB
TypeScript
242 lines
7.8 KiB
TypeScript
import { useCallback, useMemo, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import useSWR from "swr";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from "@/components/ui/dialog";
|
|
import { IoClose } from "react-icons/io5";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Button } from "@/components/ui/button";
|
|
import Heading from "@/components/ui/heading";
|
|
import { cn } from "@/lib/utils";
|
|
import { Event } from "@/types/event";
|
|
import { useApiHost } from "@/api";
|
|
import { isDesktop, isMobile } from "react-device-detect";
|
|
import ActivityIndicator from "../indicators/activity-indicator";
|
|
|
|
type ImagePickerProps = {
|
|
selectedImageId?: string;
|
|
setSelectedImageId?: (id: string) => void;
|
|
camera: string;
|
|
limit?: number;
|
|
direct?: boolean;
|
|
className?: string;
|
|
};
|
|
|
|
export default function ImagePicker({
|
|
selectedImageId,
|
|
setSelectedImageId,
|
|
camera,
|
|
limit = 100,
|
|
direct = false,
|
|
className,
|
|
}: ImagePickerProps) {
|
|
const { t } = useTranslation(["components/dialog", "views/settings"]);
|
|
const [open, setOpen] = useState(false);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const [searchTerm, setSearchTerm] = useState("");
|
|
const [loadedImages, setLoadedImages] = useState<Set<string>>(new Set());
|
|
|
|
const { data: events } = useSWR<Event[]>(
|
|
`events?camera=${camera}&limit=${limit}`,
|
|
{
|
|
revalidateOnFocus: false,
|
|
},
|
|
);
|
|
const apiHost = useApiHost();
|
|
|
|
const images = useMemo(() => {
|
|
if (!events) return [];
|
|
return events.filter(
|
|
(event) =>
|
|
(event.label.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
(event.sub_label &&
|
|
event.sub_label.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
|
searchTerm === "") &&
|
|
event.camera === camera,
|
|
);
|
|
}, [events, searchTerm, camera]);
|
|
|
|
const selectedImage = useMemo(
|
|
() => images.find((img) => img.id === selectedImageId),
|
|
[images, selectedImageId],
|
|
);
|
|
|
|
const handleImageSelect = useCallback(
|
|
(id: string) => {
|
|
if (setSelectedImageId) {
|
|
setSelectedImageId(id);
|
|
}
|
|
if (!direct) {
|
|
setOpen(false);
|
|
}
|
|
},
|
|
[setSelectedImageId, direct],
|
|
);
|
|
|
|
const handleImageLoad = useCallback((imageId: string) => {
|
|
setLoadedImages((prev) => new Set(prev).add(imageId));
|
|
}, []);
|
|
|
|
const renderSearchInput = () => (
|
|
<Input
|
|
type="text"
|
|
placeholder={t("imagePicker.search.placeholder")}
|
|
className="mb-3 md:text-sm"
|
|
value={searchTerm}
|
|
onChange={(e) => {
|
|
setSearchTerm(e.target.value);
|
|
// Clear selected image when user starts typing
|
|
if (setSelectedImageId) {
|
|
setSelectedImageId("");
|
|
}
|
|
}}
|
|
/>
|
|
);
|
|
|
|
const renderImageGrid = () => (
|
|
<div className="grid grid-cols-2 gap-4 pr-1 sm:grid-cols-6">
|
|
{images.length === 0 ? (
|
|
<div className="col-span-2 text-center text-sm text-muted-foreground sm:col-span-6">
|
|
{t("imagePicker.noImages")}
|
|
</div>
|
|
) : (
|
|
images.map((image) => (
|
|
<div
|
|
key={image.id}
|
|
className={cn(
|
|
"relative aspect-square cursor-pointer overflow-hidden rounded-lg border-2 bg-background transition-all",
|
|
selectedImageId === image.id &&
|
|
"border-selected ring-2 ring-selected",
|
|
)}
|
|
>
|
|
<img
|
|
src={`${apiHost}api/events/${image.id}/thumbnail.webp`}
|
|
alt={image.label}
|
|
className="h-full w-full object-cover"
|
|
onClick={() => handleImageSelect(image.id)}
|
|
onLoad={() => handleImageLoad(image.id)}
|
|
loading="lazy"
|
|
/>
|
|
{!loadedImages.has(image.id) && (
|
|
<div className="absolute inset-0 flex items-center justify-center">
|
|
<ActivityIndicator />
|
|
</div>
|
|
)}
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
if (direct) {
|
|
return (
|
|
<div ref={containerRef} className={className}>
|
|
{renderSearchInput()}
|
|
{renderImageGrid()}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div ref={containerRef}>
|
|
<Dialog
|
|
open={open}
|
|
onOpenChange={(open) => {
|
|
setOpen(open);
|
|
}}
|
|
>
|
|
<DialogTrigger asChild>
|
|
{!selectedImageId ? (
|
|
<Button
|
|
className="mt-2 w-full text-muted-foreground"
|
|
aria-label={t("imagePicker.selectImage")}
|
|
>
|
|
{t("imagePicker.selectImage")}
|
|
</Button>
|
|
) : (
|
|
<div className="hover:cursor-pointer">
|
|
<div className="my-3 flex w-full flex-row items-center justify-between gap-2">
|
|
<div className="flex flex-row items-center gap-4">
|
|
<div className="relative size-16">
|
|
<img
|
|
src={
|
|
selectedImage
|
|
? `${apiHost}api/events/${selectedImage.id}/thumbnail.webp`
|
|
: `${apiHost}clips/triggers/${camera}/${selectedImageId}.webp`
|
|
}
|
|
alt={selectedImage?.label || "Selected image"}
|
|
className="size-16 rounded object-cover"
|
|
onLoad={() => handleImageLoad(selectedImageId || "")}
|
|
onError={(e) => {
|
|
// If trigger thumbnail fails to load, fall back to event thumbnail
|
|
if (!selectedImage) {
|
|
const target = e.target as HTMLImageElement;
|
|
if (
|
|
target.src.includes("clips/triggers") &&
|
|
selectedImageId
|
|
) {
|
|
target.src = `${apiHost}api/events/${selectedImageId}/thumbnail.webp`;
|
|
}
|
|
}
|
|
}}
|
|
loading="lazy"
|
|
/>
|
|
{selectedImageId && !loadedImages.has(selectedImageId) && (
|
|
<div className="absolute inset-0 flex items-center justify-center">
|
|
<ActivityIndicator />
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="text-sm smart-capitalize">
|
|
{selectedImage?.label || t("imagePicker.unknownLabel")}
|
|
{selectedImage?.sub_label
|
|
? ` (${selectedImage.sub_label})`
|
|
: ""}
|
|
</div>
|
|
</div>
|
|
<IoClose
|
|
className="mx-2 hover:cursor-pointer"
|
|
onClick={() => {
|
|
if (setSelectedImageId) {
|
|
setSelectedImageId("");
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</DialogTrigger>
|
|
<DialogTitle className="sr-only">
|
|
{t("imagePicker.selectImage")}
|
|
</DialogTitle>
|
|
<DialogContent
|
|
className={cn(
|
|
"scrollbar-container overflow-y-auto",
|
|
isDesktop && "max-h-[75dvh] sm:max-w-xl md:max-w-[70%]",
|
|
isMobile && "scrollbar-container max-h-[90%] overflow-y-auto px-4",
|
|
className,
|
|
)}
|
|
>
|
|
<div className="mb-3 flex flex-col items-start justify-start">
|
|
<Heading as="h4">{t("imagePicker.selectImage")}</Heading>
|
|
<div className="text-sm text-muted-foreground">
|
|
{t("triggers.dialog.form.content.imageDesc", {
|
|
ns: "views/settings",
|
|
})}
|
|
</div>
|
|
<span tabIndex={0} className="sr-only" />
|
|
</div>
|
|
{renderSearchInput()}
|
|
<div className="scrollbar-container flex h-full flex-col overflow-y-auto">
|
|
{renderImageGrid()}
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|