mirror of
https://github.com/blakeblackshear/frigate.git
synced 2024-11-21 19:07:46 +01:00
UI Improvements and Tweaks (#13689)
* Improve image loading by not loading when off screen * Add share menu to export * Add share button and tidy up review detail lists * Fix missing key * Use query args for review filter * Add object lifecycle to explore dialog * Adjust sizing * Simplify share button * Always show snapshot but hide buttons for frigate+ if not applicable * Handle case when user switches to element missing the previously selected tab * Handle cases where share is not available * Fix logic
This commit is contained in:
parent
b4acf4f341
commit
d84e3cacca
@ -93,6 +93,7 @@ export default function CameraImage({
|
|||||||
"rounded-lg md:rounded-2xl",
|
"rounded-lg md:rounded-2xl",
|
||||||
)}
|
)}
|
||||||
onLoad={handleImageLoad}
|
onLoad={handleImageLoad}
|
||||||
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="pt-6 text-center">
|
<div className="pt-6 text-center">
|
||||||
|
@ -3,7 +3,7 @@ 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 } from "react-device-detect";
|
import { isDesktop } from "react-device-detect";
|
||||||
import { FaDownload, FaPlay } from "react-icons/fa";
|
import { FaDownload, FaPlay, FaShareAlt } from "react-icons/fa";
|
||||||
import Chip from "../indicators/Chip";
|
import Chip from "../indicators/Chip";
|
||||||
import { Skeleton } from "../ui/skeleton";
|
import { Skeleton } from "../ui/skeleton";
|
||||||
import {
|
import {
|
||||||
@ -19,6 +19,7 @@ import { DeleteClipType, Export } from "@/types/export";
|
|||||||
import { MdEditSquare } from "react-icons/md";
|
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";
|
||||||
|
|
||||||
type ExportProps = {
|
type ExportProps = {
|
||||||
className: string;
|
className: string;
|
||||||
@ -147,6 +148,19 @@ export default function ExportCard({
|
|||||||
<div>
|
<div>
|
||||||
<div className="absolute inset-0 rounded-lg bg-black bg-opacity-60 md:rounded-2xl" />
|
<div className="absolute inset-0 rounded-lg bg-black bg-opacity-60 md:rounded-2xl" />
|
||||||
<div className="absolute right-1 top-1 flex items-center gap-2">
|
<div className="absolute right-1 top-1 flex items-center gap-2">
|
||||||
|
{!exportedRecording.in_progress && (
|
||||||
|
<Chip
|
||||||
|
className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500"
|
||||||
|
onClick={() =>
|
||||||
|
shareOrCopy(
|
||||||
|
`${baseUrl}exports?id=${exportedRecording.id}`,
|
||||||
|
exportedRecording.name.replaceAll("_", " "),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FaShareAlt className="size-4 text-white" />
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
{!exportedRecording.in_progress && (
|
{!exportedRecording.in_progress && (
|
||||||
<a
|
<a
|
||||||
download
|
download
|
||||||
|
@ -470,6 +470,7 @@ export function GeneralFilterContent({
|
|||||||
<div className="my-2.5 flex flex-col gap-2.5">
|
<div className="my-2.5 flex flex-col gap-2.5">
|
||||||
{allLabels.map((item) => (
|
{allLabels.map((item) => (
|
||||||
<FilterSwitch
|
<FilterSwitch
|
||||||
|
key={item}
|
||||||
label={item.replaceAll("_", " ")}
|
label={item.replaceAll("_", " ")}
|
||||||
isChecked={currentLabels?.includes(item) ?? false}
|
isChecked={currentLabels?.includes(item) ?? false}
|
||||||
onCheckedChange={(isChecked) => {
|
onCheckedChange={(isChecked) => {
|
||||||
@ -516,6 +517,7 @@ export function GeneralFilterContent({
|
|||||||
<div className="my-2.5 flex flex-col gap-2.5">
|
<div className="my-2.5 flex flex-col gap-2.5">
|
||||||
{allZones.map((item) => (
|
{allZones.map((item) => (
|
||||||
<FilterSwitch
|
<FilterSwitch
|
||||||
|
key={item}
|
||||||
label={item.replaceAll("_", " ")}
|
label={item.replaceAll("_", " ")}
|
||||||
isChecked={currentZones?.includes(item) ?? false}
|
isChecked={currentZones?.includes(item) ?? false}
|
||||||
onCheckedChange={(isChecked) => {
|
onCheckedChange={(isChecked) => {
|
||||||
|
@ -13,7 +13,7 @@ import {
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ObjectLifecycleSequence } from "@/types/timeline";
|
import { ObjectLifecycleSequence } from "@/types/timeline";
|
||||||
import Heading from "@/components/ui/heading";
|
import Heading from "@/components/ui/heading";
|
||||||
import { ReviewDetailPaneType, ReviewSegment } from "@/types/review";
|
import { ReviewDetailPaneType } from "@/types/review";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||||
import { getIconForLabel } from "@/utils/iconUtil";
|
import { getIconForLabel } from "@/utils/iconUtil";
|
||||||
@ -47,14 +47,16 @@ import { AnnotationSettingsPane } from "./AnnotationSettingsPane";
|
|||||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||||
|
|
||||||
type ObjectLifecycleProps = {
|
type ObjectLifecycleProps = {
|
||||||
review: ReviewSegment;
|
className?: string;
|
||||||
event: Event;
|
event: Event;
|
||||||
|
fullscreen?: boolean;
|
||||||
setPane: React.Dispatch<React.SetStateAction<ReviewDetailPaneType>>;
|
setPane: React.Dispatch<React.SetStateAction<ReviewDetailPaneType>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ObjectLifecycle({
|
export default function ObjectLifecycle({
|
||||||
review,
|
className,
|
||||||
event,
|
event,
|
||||||
|
fullscreen = false,
|
||||||
setPane,
|
setPane,
|
||||||
}: ObjectLifecycleProps) {
|
}: ObjectLifecycleProps) {
|
||||||
const { data: eventSequence } = useSWR<ObjectLifecycleSequence[]>([
|
const { data: eventSequence } = useSWR<ObjectLifecycleSequence[]>([
|
||||||
@ -78,13 +80,13 @@ export default function ObjectLifecycle({
|
|||||||
const getZoneColor = useCallback(
|
const getZoneColor = useCallback(
|
||||||
(zoneName: string) => {
|
(zoneName: string) => {
|
||||||
const zoneColor =
|
const zoneColor =
|
||||||
config?.cameras?.[review.camera]?.zones?.[zoneName]?.color;
|
config?.cameras?.[event.camera]?.zones?.[zoneName]?.color;
|
||||||
if (zoneColor) {
|
if (zoneColor) {
|
||||||
const reversed = [...zoneColor].reverse();
|
const reversed = [...zoneColor].reverse();
|
||||||
return reversed;
|
return reversed;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[config, review],
|
[config, event],
|
||||||
);
|
);
|
||||||
|
|
||||||
const getZonePolygon = useCallback(
|
const getZonePolygon = useCallback(
|
||||||
@ -93,7 +95,7 @@ export default function ObjectLifecycle({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const zonePoints =
|
const zonePoints =
|
||||||
config?.cameras[review.camera].zones[zoneName].coordinates;
|
config?.cameras[event.camera].zones[zoneName].coordinates;
|
||||||
const imgElement = imgRef.current;
|
const imgElement = imgRef.current;
|
||||||
const imgRect = imgElement.getBoundingClientRect();
|
const imgRect = imgElement.getBoundingClientRect();
|
||||||
|
|
||||||
@ -110,7 +112,7 @@ export default function ObjectLifecycle({
|
|||||||
}, [] as number[])
|
}, [] as number[])
|
||||||
.join(",");
|
.join(",");
|
||||||
},
|
},
|
||||||
[config, imgRef, review],
|
[config, imgRef, event],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [boxStyle, setBoxStyle] = useState<React.CSSProperties | null>(null);
|
const [boxStyle, setBoxStyle] = useState<React.CSSProperties | null>(null);
|
||||||
@ -224,17 +226,19 @@ export default function ObjectLifecycle({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className={className}>
|
||||||
<div className={cn("flex items-center gap-2")}>
|
{!fullscreen && (
|
||||||
<Button
|
<div className={cn("flex items-center gap-2")}>
|
||||||
className="flex items-center gap-2.5 rounded-lg"
|
<Button
|
||||||
size="sm"
|
className="flex items-center gap-2.5 rounded-lg"
|
||||||
onClick={() => setPane("overview")}
|
size="sm"
|
||||||
>
|
onClick={() => setPane("overview")}
|
||||||
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
|
>
|
||||||
{isDesktop && <div className="text-primary">Back</div>}
|
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
|
||||||
</Button>
|
{isDesktop && <div className="text-primary">Back</div>}
|
||||||
</div>
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="relative mx-auto">
|
<div className="relative mx-auto">
|
||||||
<ImageLoadingIndicator
|
<ImageLoadingIndicator
|
||||||
@ -347,7 +351,10 @@ export default function ObjectLifecycle({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="relative flex flex-col items-center justify-center">
|
<div className="relative flex flex-col items-center justify-center">
|
||||||
<Carousel className="m-0 w-full" setApi={setMainApi}>
|
<Carousel
|
||||||
|
className={cn("m-0 w-full", fullscreen && isDesktop && "w-[75%]")}
|
||||||
|
setApi={setMainApi}
|
||||||
|
>
|
||||||
<CarouselContent>
|
<CarouselContent>
|
||||||
{eventSequence.map((item, index) => (
|
{eventSequence.map((item, index) => (
|
||||||
<CarouselItem key={index}>
|
<CarouselItem key={index}>
|
||||||
@ -455,7 +462,7 @@ export default function ObjectLifecycle({
|
|||||||
</CarouselContent>
|
</CarouselContent>
|
||||||
</Carousel>
|
</Carousel>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative flex flex-col items-center justify-center">
|
<div className="relative mt-4 flex flex-col items-center justify-center">
|
||||||
<Carousel
|
<Carousel
|
||||||
opts={{
|
opts={{
|
||||||
align: "center",
|
align: "center",
|
||||||
@ -474,7 +481,10 @@ export default function ObjectLifecycle({
|
|||||||
{eventSequence.map((item, index) => (
|
{eventSequence.map((item, index) => (
|
||||||
<CarouselItem
|
<CarouselItem
|
||||||
key={index}
|
key={index}
|
||||||
className={cn("basis-1/4 cursor-pointer pl-1 md:basis-[10%]")}
|
className={cn(
|
||||||
|
"basis-1/4 cursor-pointer pl-1 md:basis-[10%]",
|
||||||
|
fullscreen && "md:basis-16",
|
||||||
|
)}
|
||||||
onClick={() => handleThumbnailClick(index)}
|
onClick={() => handleThumbnailClick(index)}
|
||||||
>
|
>
|
||||||
<div className="p-1">
|
<div className="p-1">
|
||||||
@ -513,7 +523,7 @@ export default function ObjectLifecycle({
|
|||||||
<CarouselNext />
|
<CarouselNext />
|
||||||
</Carousel>
|
</Carousel>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -25,7 +25,7 @@ import { cn } from "@/lib/utils";
|
|||||||
import { FrigatePlusDialog } from "../dialog/FrigatePlusDialog";
|
import { FrigatePlusDialog } from "../dialog/FrigatePlusDialog";
|
||||||
import ObjectLifecycle from "./ObjectLifecycle";
|
import ObjectLifecycle from "./ObjectLifecycle";
|
||||||
import Chip from "@/components/indicators/Chip";
|
import Chip from "@/components/indicators/Chip";
|
||||||
import { FaDownload, FaImages } from "react-icons/fa";
|
import { FaDownload, FaImages, FaShareAlt } from "react-icons/fa";
|
||||||
import FrigatePlusIcon from "@/components/icons/FrigatePlusIcon";
|
import FrigatePlusIcon from "@/components/icons/FrigatePlusIcon";
|
||||||
import { FaArrowsRotate } from "react-icons/fa6";
|
import { FaArrowsRotate } from "react-icons/fa6";
|
||||||
import {
|
import {
|
||||||
@ -34,6 +34,9 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { baseUrl } from "@/api/baseUrl";
|
||||||
|
import { shareOrCopy } from "@/utils/browserUtil";
|
||||||
|
|
||||||
type ReviewDetailDialogProps = {
|
type ReviewDetailDialogProps = {
|
||||||
review?: ReviewSegment;
|
review?: ReviewSegment;
|
||||||
@ -136,11 +139,21 @@ export default function ReviewDetailDialog({
|
|||||||
<div className="text-sm text-primary/40">Timestamp</div>
|
<div className="text-sm text-primary/40">Timestamp</div>
|
||||||
<div className="text-sm">{formattedDate}</div>
|
<div className="text-sm">{formattedDate}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
className="flex max-w-24 gap-2"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
shareOrCopy(`${baseUrl}review?id=${review.id}`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FaShareAlt className="size-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full flex-col gap-2">
|
<div className="flex w-full flex-col items-center gap-2">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex w-full flex-col gap-1.5">
|
||||||
<div className="text-sm text-primary/40">Objects</div>
|
<div className="text-sm text-primary/40">Objects</div>
|
||||||
<div className="flex flex-col items-start gap-2 text-sm capitalize">
|
<div className="scrollbar-container flex max-h-32 flex-col items-start gap-2 overflow-y-scroll text-sm capitalize">
|
||||||
{events?.map((event) => {
|
{events?.map((event) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -159,7 +172,7 @@ export default function ReviewDetailDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{review.data.zones.length > 0 && (
|
{review.data.zones.length > 0 && (
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="scrollbar-container flex max-h-32 w-full flex-col gap-1.5">
|
||||||
<div className="text-sm text-primary/40">Zones</div>
|
<div className="text-sm text-primary/40">Zones</div>
|
||||||
<div className="flex flex-col items-start gap-2 text-sm capitalize">
|
<div className="flex flex-col items-start gap-2 text-sm capitalize">
|
||||||
{review.data.zones.map((zone) => {
|
{review.data.zones.map((zone) => {
|
||||||
@ -199,11 +212,7 @@ export default function ReviewDetailDialog({
|
|||||||
|
|
||||||
{pane == "details" && selectedEvent && (
|
{pane == "details" && selectedEvent && (
|
||||||
<div className="scrollbar-container overflow-x-none mt-0 flex size-full flex-col gap-2 overflow-y-auto overflow-x-hidden">
|
<div className="scrollbar-container overflow-x-none mt-0 flex size-full flex-col gap-2 overflow-y-auto overflow-x-hidden">
|
||||||
<ObjectLifecycle
|
<ObjectLifecycle event={selectedEvent} setPane={setPane} />
|
||||||
review={review}
|
|
||||||
event={selectedEvent}
|
|
||||||
setPane={setPane}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Content>
|
</Content>
|
||||||
|
@ -34,10 +34,16 @@ import { baseUrl } from "@/api/baseUrl";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record";
|
import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record";
|
||||||
import { FaRegListAlt, FaVideo } from "react-icons/fa";
|
import { FaImage, FaRegListAlt, FaVideo } from "react-icons/fa";
|
||||||
import FrigatePlusIcon from "@/components/icons/FrigatePlusIcon";
|
import { FaRotate } from "react-icons/fa6";
|
||||||
|
import ObjectLifecycle from "./ObjectLifecycle";
|
||||||
|
|
||||||
const SEARCH_TABS = ["details", "frigate+", "video"] as const;
|
const SEARCH_TABS = [
|
||||||
|
"details",
|
||||||
|
"snapshot",
|
||||||
|
"video",
|
||||||
|
"object lifecycle",
|
||||||
|
] as const;
|
||||||
type SearchTab = (typeof SEARCH_TABS)[number];
|
type SearchTab = (typeof SEARCH_TABS)[number];
|
||||||
|
|
||||||
type SearchDetailDialogProps = {
|
type SearchDetailDialogProps = {
|
||||||
@ -66,8 +72,8 @@ export default function SearchDetailDialog({
|
|||||||
|
|
||||||
const views = [...SEARCH_TABS];
|
const views = [...SEARCH_TABS];
|
||||||
|
|
||||||
if (!config.plus.enabled || !search.has_snapshot) {
|
if (!search.has_snapshot) {
|
||||||
const index = views.indexOf("frigate+");
|
const index = views.indexOf("snapshot");
|
||||||
views.splice(index, 1);
|
views.splice(index, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -80,6 +86,16 @@ export default function SearchDetailDialog({
|
|||||||
return views;
|
return views;
|
||||||
}, [config, search]);
|
}, [config, search]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchTabs.length == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!searchTabs.includes(pageToggle)) {
|
||||||
|
setPage("details");
|
||||||
|
}
|
||||||
|
}, [pageToggle, searchTabs]);
|
||||||
|
|
||||||
if (!search) {
|
if (!search) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -104,7 +120,7 @@ export default function SearchDetailDialog({
|
|||||||
<Content
|
<Content
|
||||||
className={
|
className={
|
||||||
isDesktop
|
isDesktop
|
||||||
? "sm:max-w-xl md:max-w-3xl lg:max-w-4xl xl:max-w-7xl"
|
? "sm:max-w-xl md:max-w-4xl lg:max-w-4xl xl:max-w-7xl"
|
||||||
: "max-h-[75dvh] overflow-hidden px-2 pb-4"
|
: "max-h-[75dvh] overflow-hidden px-2 pb-4"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@ -136,8 +152,11 @@ export default function SearchDetailDialog({
|
|||||||
aria-label={`Select ${item}`}
|
aria-label={`Select ${item}`}
|
||||||
>
|
>
|
||||||
{item == "details" && <FaRegListAlt className="size-4" />}
|
{item == "details" && <FaRegListAlt className="size-4" />}
|
||||||
{item == "frigate+" && <FrigatePlusIcon className="size-4" />}
|
{item == "snapshot" && <FaImage className="size-4" />}
|
||||||
{item == "video" && <FaVideo className="size-4" />}
|
{item == "video" && <FaVideo className="size-4" />}
|
||||||
|
{item == "object lifecycle" && (
|
||||||
|
<FaRotate className="size-4" />
|
||||||
|
)}
|
||||||
<div className="capitalize">{item}</div>
|
<div className="capitalize">{item}</div>
|
||||||
</ToggleGroupItem>
|
</ToggleGroupItem>
|
||||||
))}
|
))}
|
||||||
@ -153,9 +172,14 @@ export default function SearchDetailDialog({
|
|||||||
setSimilarity={setSimilarity}
|
setSimilarity={setSimilarity}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{page == "frigate+" && (
|
{page == "snapshot" && (
|
||||||
<FrigatePlusDialog
|
<FrigatePlusDialog
|
||||||
upload={search as unknown as Event}
|
upload={
|
||||||
|
{
|
||||||
|
...search,
|
||||||
|
plus_id: config?.plus?.enabled ? search.plus_id : "not_enabled",
|
||||||
|
} as unknown as Event
|
||||||
|
}
|
||||||
dialog={false}
|
dialog={false}
|
||||||
onClose={() => {}}
|
onClose={() => {}}
|
||||||
onEventUploaded={() => {
|
onEventUploaded={() => {
|
||||||
@ -164,6 +188,14 @@ export default function SearchDetailDialog({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{page == "video" && <VideoTab search={search} config={config} />}
|
{page == "video" && <VideoTab search={search} config={config} />}
|
||||||
|
{page == "object lifecycle" && (
|
||||||
|
<ObjectLifecycle
|
||||||
|
className="w-full"
|
||||||
|
event={search as unknown as Event}
|
||||||
|
fullscreen={true}
|
||||||
|
setPane={() => {}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Content>
|
</Content>
|
||||||
</Overlay>
|
</Overlay>
|
||||||
);
|
);
|
||||||
|
@ -79,7 +79,7 @@ export function FrigatePlusDialog({
|
|||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<TransformWrapper minScale={1.0} wheel={{ smoothStep: 0.005 }}>
|
<TransformWrapper minScale={1.0} wheel={{ smoothStep: 0.005 }}>
|
||||||
<DialogHeader>
|
<DialogHeader className={state == "submitted" ? "sr-only" : ""}>
|
||||||
<DialogTitle>Submit To Frigate+</DialogTitle>
|
<DialogTitle>Submit To Frigate+</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Objects in locations you want to avoid are not false positives.
|
Objects in locations you want to avoid are not false positives.
|
||||||
|
@ -539,6 +539,7 @@ function PreviewFramesPlayer({
|
|||||||
<img
|
<img
|
||||||
ref={imgRef}
|
ref={imgRef}
|
||||||
className={`size-full rounded-lg bg-black object-contain md:rounded-2xl`}
|
className={`size-full rounded-lg bg-black object-contain md:rounded-2xl`}
|
||||||
|
loading="lazy"
|
||||||
onLoad={onImageLoaded}
|
onLoad={onImageLoaded}
|
||||||
/>
|
/>
|
||||||
{previewFrames?.length === 0 && (
|
{previewFrames?.length === 0 && (
|
||||||
|
@ -65,11 +65,11 @@ export function useApiFilterArgs<
|
|||||||
const filter: { [key: string]: unknown } = {};
|
const filter: { [key: string]: unknown } = {};
|
||||||
|
|
||||||
rawParams.forEach((value, key) => {
|
rawParams.forEach((value, key) => {
|
||||||
if (isNaN(parseFloat(value))) {
|
if (value != "true" && value != "false" && isNaN(parseFloat(value))) {
|
||||||
filter[key] = value.includes(",") ? value.split(",") : [value];
|
filter[key] = value.includes(",") ? value.split(",") : [value];
|
||||||
} else {
|
} else {
|
||||||
if (value != undefined) {
|
if (value != undefined) {
|
||||||
filter[key] = `${value}`;
|
filter[key] = JSON.parse(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo } from "react";
|
import { useCallback, useEffect, useMemo } from "react";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { usePersistence } from "./use-persistence";
|
import { usePersistence } from "./use-persistence";
|
||||||
|
|
||||||
export function useOverlayState<S>(
|
export function useOverlayState<S>(
|
||||||
@ -103,33 +103,29 @@ export function useHashState<S extends string>(): [
|
|||||||
|
|
||||||
export function useSearchEffect(
|
export function useSearchEffect(
|
||||||
key: string,
|
key: string,
|
||||||
callback: (value: string) => void,
|
callback: (value: string) => boolean,
|
||||||
) {
|
) {
|
||||||
const location = useLocation();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
const param = useMemo(() => {
|
const param = useMemo(() => {
|
||||||
if (!location || !location.search || location.search.length == 0) {
|
const param = searchParams.get(key);
|
||||||
|
|
||||||
|
if (!param) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = location.search.substring(1).split("&");
|
return [key, decodeURIComponent(param)];
|
||||||
|
}, [searchParams, key]);
|
||||||
const foundParam = params
|
|
||||||
.find((p) => p.includes("=") && p.split("=")[0] == key)
|
|
||||||
?.split("=");
|
|
||||||
|
|
||||||
if (foundParam && foundParam.length === 2) {
|
|
||||||
return [foundParam[0], decodeURIComponent(foundParam[1])];
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}, [location, key]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!param) {
|
if (!param) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
callback(param[1]);
|
const remove = callback(param[1]);
|
||||||
}, [param, callback]);
|
|
||||||
|
if (remove) {
|
||||||
|
setSearchParams();
|
||||||
|
}
|
||||||
|
}, [param, callback, setSearchParams]);
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
import useApiFilter from "@/hooks/use-api-filter";
|
import { useApiFilterArgs } from "@/hooks/use-api-filter";
|
||||||
import { useCameraPreviews } from "@/hooks/use-camera-previews";
|
import { useCameraPreviews } from "@/hooks/use-camera-previews";
|
||||||
import { useTimezone } from "@/hooks/use-date-utils";
|
import { useTimezone } from "@/hooks/use-date-utils";
|
||||||
import { useOverlayState, useSearchEffect } from "@/hooks/use-overlay-state";
|
import { useOverlayState, useSearchEffect } from "@/hooks/use-overlay-state";
|
||||||
@ -54,6 +54,8 @@ export default function Events() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
const [startTime, setStartTime] = useState<number>();
|
const [startTime, setStartTime] = useState<number>();
|
||||||
@ -69,7 +71,7 @@ export default function Events() {
|
|||||||
// review filter
|
// review filter
|
||||||
|
|
||||||
const [reviewFilter, setReviewFilter, reviewSearchParams] =
|
const [reviewFilter, setReviewFilter, reviewSearchParams] =
|
||||||
useApiFilter<ReviewFilter>();
|
useApiFilterArgs<ReviewFilter>();
|
||||||
|
|
||||||
useSearchEffect("group", (reviewGroup) => {
|
useSearchEffect("group", (reviewGroup) => {
|
||||||
if (config && reviewGroup && reviewGroup != "default") {
|
if (config && reviewGroup && reviewGroup != "default") {
|
||||||
@ -83,7 +85,11 @@ export default function Events() {
|
|||||||
cameras: group.cameras,
|
cameras: group.cameras,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
const onUpdateFilter = useCallback(
|
const onUpdateFilter = useCallback(
|
||||||
|
@ -43,6 +43,7 @@ export default function Explore() {
|
|||||||
setSearch(`similarity:${similarityId}`);
|
setSearch(`similarity:${similarityId}`);
|
||||||
// @ts-expect-error we want to clear this
|
// @ts-expect-error we want to clear this
|
||||||
setSearchFilter({ ...searchFilter, similarity_search_id: undefined });
|
setSearchFilter({ ...searchFilter, similarity_search_id: undefined });
|
||||||
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -13,6 +13,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||||
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 { 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 } from "@/types/export";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
@ -46,6 +47,20 @@ function Exports() {
|
|||||||
);
|
);
|
||||||
}, [exports, search]);
|
}, [exports, search]);
|
||||||
|
|
||||||
|
// Viewing
|
||||||
|
|
||||||
|
const [selected, setSelected] = useState<Export>();
|
||||||
|
const [selectedAspect, setSelectedAspect] = useState(0.0);
|
||||||
|
|
||||||
|
useSearchEffect("id", (id) => {
|
||||||
|
if (!exports) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelected(exports.find((exp) => exp.id == id));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
// Deleting
|
// Deleting
|
||||||
|
|
||||||
const [deleteClip, setDeleteClip] = useState<DeleteClipType | undefined>();
|
const [deleteClip, setDeleteClip] = useState<DeleteClipType | undefined>();
|
||||||
@ -91,11 +106,6 @@ function Exports() {
|
|||||||
[mutate],
|
[mutate],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Viewing
|
|
||||||
|
|
||||||
const [selected, setSelected] = useState<Export>();
|
|
||||||
const [selectedAspect, setSelectedAspect] = useState(0.0);
|
|
||||||
|
|
||||||
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} />
|
||||||
|
@ -29,7 +29,11 @@ function Live() {
|
|||||||
if (group) {
|
if (group) {
|
||||||
setCameraGroup(cameraGroup);
|
setCameraGroup(cameraGroup);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
// fullscreen
|
// fullscreen
|
||||||
|
16
web/src/utils/browserUtil.ts
Normal file
16
web/src/utils/browserUtil.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import copy from "copy-to-clipboard";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export function shareOrCopy(url: string, title?: string) {
|
||||||
|
if (window.isSecureContext && "share" in navigator) {
|
||||||
|
navigator.share({
|
||||||
|
url: url,
|
||||||
|
title: title,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
copy(url);
|
||||||
|
toast.success("Copied to clipboard.", {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user