mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-14 10:42:49 +03:00
Trigger Wizard (#20691)
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
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
* add reusable component for combined name / internal name form field * fix labels * refactor utilities * refactor image picker * lazy loading * don't clear text box * trigger wizard * image picker fixes * use name and ID field in trigger edit dialog * ensure wizard resets when reopening * icon size tweak * multiple triggers can trigger at once * remove scrolling * mobile tweaks * remove duplicated component * fix types * use table on desktop and keep cards on mobile * provide default
This commit is contained in:
@@ -15,25 +15,33 @@ 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"]);
|
||||
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=100`,
|
||||
`events?camera=${camera}&limit=${limit}`,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
},
|
||||
@@ -62,12 +70,77 @@ export default function ImagePicker({
|
||||
if (setSelectedImageId) {
|
||||
setSelectedImageId(id);
|
||||
}
|
||||
setSearchTerm("");
|
||||
setOpen(false);
|
||||
if (!direct) {
|
||||
setOpen(false);
|
||||
}
|
||||
},
|
||||
[setSelectedImageId],
|
||||
[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="text-md 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
|
||||
@@ -87,18 +160,27 @@ export default function ImagePicker({
|
||||
) : (
|
||||
<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-2">
|
||||
<img
|
||||
src={
|
||||
selectedImage
|
||||
? `${apiHost}api/events/${selectedImage.id}/thumbnail.webp`
|
||||
: `${apiHost}clips/triggers/${camera}/${selectedImageId}.webp`
|
||||
}
|
||||
alt={selectedImage?.label || "Selected image"}
|
||||
className="h-8 w-8 rounded object-cover"
|
||||
/>
|
||||
<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 || "")}
|
||||
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 || selectedImageId}
|
||||
{selectedImage?.label || t("imagePicker.unknownLabel")}
|
||||
{selectedImage?.sub_label
|
||||
? ` (${selectedImage.sub_label})`
|
||||
: ""}
|
||||
@@ -122,48 +204,23 @@ export default function ImagePicker({
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"scrollbar-container overflow-y-auto",
|
||||
isDesktop && "max-h-[75dvh] sm:max-w-xl md:max-w-3xl",
|
||||
isMobile && "px-4",
|
||||
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-row items-center justify-between">
|
||||
<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>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t("imagePicker.search.placeholder")}
|
||||
className="text-md mb-3 md:text-sm"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
{renderSearchInput()}
|
||||
<div className="scrollbar-container flex h-full flex-col overflow-y-auto">
|
||||
<div className="grid grid-cols-3 gap-2 pr-1">
|
||||
{images.length === 0 ? (
|
||||
<div className="col-span-3 text-center text-sm text-muted-foreground">
|
||||
{t("imagePicker.noImages")}
|
||||
</div>
|
||||
) : (
|
||||
images.map((image) => (
|
||||
<div
|
||||
key={image.id}
|
||||
className={cn(
|
||||
"flex flex-row items-center justify-center rounded-lg p-1 hover:cursor-pointer",
|
||||
selectedImageId === image.id
|
||||
? "bg-selected text-white"
|
||||
: "hover:bg-secondary-foreground",
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={`${apiHost}api/events/${image.id}/thumbnail.webp`}
|
||||
alt={image.label}
|
||||
className="rounded object-cover"
|
||||
onClick={() => handleImageSelect(image.id)}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{renderImageGrid()}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
Reference in New Issue
Block a user