mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-06-21 03:41:55 +03:00
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
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>
|
|
);
|
|
}
|