mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-07-30 13:48:07 +02:00
UI tweaks (#13705)
* mobile page component * object lifecycle pane tweaks * use mobile page component for review and search detail * fix frigate+ dialog when using mobile page component * small tweaks
This commit is contained in:
parent
87ab4e7c9b
commit
644ea7be4a
26
web/package-lock.json
generated
26
web/package-lock.json
generated
@ -37,6 +37,7 @@
|
|||||||
"copy-to-clipboard": "^3.3.3",
|
"copy-to-clipboard": "^3.3.3",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"embla-carousel-react": "^8.2.0",
|
"embla-carousel-react": "^8.2.0",
|
||||||
|
"framer-motion": "^11.5.4",
|
||||||
"hls.js": "^1.5.14",
|
"hls.js": "^1.5.14",
|
||||||
"idb-keyval": "^6.2.1",
|
"idb-keyval": "^6.2.1",
|
||||||
"immer": "^10.1.1",
|
"immer": "^10.1.1",
|
||||||
@ -4717,6 +4718,31 @@
|
|||||||
"url": "https://github.com/sponsors/rawify"
|
"url": "https://github.com/sponsors/rawify"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/framer-motion": {
|
||||||
|
"version": "11.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.5.4.tgz",
|
||||||
|
"integrity": "sha512-E+tb3/G6SO69POkdJT+3EpdMuhmtCh9EWuK4I1DnIC23L7tFPrl8vxP+LSovwaw6uUr73rUbpb4FgK011wbRJQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@emotion/is-prop-valid": "*",
|
||||||
|
"react": "^18.0.0",
|
||||||
|
"react-dom": "^18.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@emotion/is-prop-valid": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fs.realpath": {
|
"node_modules/fs.realpath": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||||
|
@ -43,6 +43,7 @@
|
|||||||
"copy-to-clipboard": "^3.3.3",
|
"copy-to-clipboard": "^3.3.3",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"embla-carousel-react": "^8.2.0",
|
"embla-carousel-react": "^8.2.0",
|
||||||
|
"framer-motion": "^11.5.4",
|
||||||
"hls.js": "^1.5.14",
|
"hls.js": "^1.5.14",
|
||||||
"idb-keyval": "^6.2.1",
|
"idb-keyval": "^6.2.1",
|
||||||
"immer": "^10.1.1",
|
"immer": "^10.1.1",
|
||||||
|
121
web/src/components/mobile/MobilePage.tsx
Normal file
121
web/src/components/mobile/MobilePage.tsx
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { isPWA } from "@/utils/isPWA";
|
||||||
|
import { ReactNode, useEffect, useState } from "react";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { IoMdArrowRoundBack } from "react-icons/io";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
|
||||||
|
type MobilePageProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MobilePage({ children, open, onOpenChange }: MobilePageProps) {
|
||||||
|
const [isVisible, setIsVisible] = useState(open);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setIsVisible(true);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const handleAnimationComplete = () => {
|
||||||
|
if (!open) {
|
||||||
|
setIsVisible(false);
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isVisible && (
|
||||||
|
<motion.div
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-[100] mb-12 bg-background",
|
||||||
|
isPWA && "mb-16",
|
||||||
|
"landscape:mb-14 landscape:md:mb-16",
|
||||||
|
)}
|
||||||
|
initial={{ x: "100%" }}
|
||||||
|
animate={{ x: open ? 0 : "100%" }}
|
||||||
|
exit={{ x: "100%" }}
|
||||||
|
transition={{ type: "spring", damping: 25, stiffness: 200 }}
|
||||||
|
onAnimationComplete={handleAnimationComplete}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type MobileComponentProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MobilePageContent({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: MobileComponentProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn("size-full", className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobilePageDescription({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: MobileComponentProps) {
|
||||||
|
return (
|
||||||
|
<p className={cn("text-sm text-muted-foreground", className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MobilePageHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobilePageHeader({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
onClose,
|
||||||
|
...props
|
||||||
|
}: MobilePageHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"sticky top-0 z-50 mb-2 flex items-center justify-center bg-background p-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
className="absolute left-0 rounded-lg"
|
||||||
|
size="sm"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
|
||||||
|
</Button>
|
||||||
|
<div className="flex flex-row text-center">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobilePageTitle({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: MobileComponentProps) {
|
||||||
|
return (
|
||||||
|
<h2 className={cn("text-lg font-semibold", className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</h2>
|
||||||
|
);
|
||||||
|
}
|
@ -230,7 +230,7 @@ export default function ObjectLifecycle({
|
|||||||
{!fullscreen && (
|
{!fullscreen && (
|
||||||
<div className={cn("flex items-center gap-2")}>
|
<div className={cn("flex items-center gap-2")}>
|
||||||
<Button
|
<Button
|
||||||
className="flex items-center gap-2.5 rounded-lg"
|
className="mb-2 mt-3 flex items-center gap-2.5 rounded-lg md:mt-0"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setPane("overview")}
|
onClick={() => setPane("overview")}
|
||||||
>
|
>
|
||||||
@ -240,7 +240,7 @@ export default function ObjectLifecycle({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="relative mx-auto">
|
<div className="relative flex flex-row justify-center">
|
||||||
<ImageLoadingIndicator
|
<ImageLoadingIndicator
|
||||||
className="absolute inset-0"
|
className="absolute inset-0"
|
||||||
imgLoaded={imgLoaded}
|
imgLoaded={imgLoaded}
|
||||||
@ -253,7 +253,12 @@ export default function ObjectLifecycle({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={cn(imgLoaded ? "visible" : "invisible")}>
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative inline-block",
|
||||||
|
imgLoaded ? "visible" : "invisible",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
key={event.id}
|
key={event.id}
|
||||||
ref={imgRef}
|
ref={imgRef}
|
||||||
@ -278,7 +283,7 @@ export default function ObjectLifecycle({
|
|||||||
{showZones &&
|
{showZones &&
|
||||||
lifecycleZones?.map((zone) => (
|
lifecycleZones?.map((zone) => (
|
||||||
<div
|
<div
|
||||||
className="absolute left-0 top-0"
|
className="absolute inset-0 flex items-center justify-center"
|
||||||
style={{
|
style={{
|
||||||
width: imgRef.current?.clientWidth,
|
width: imgRef.current?.clientWidth,
|
||||||
height: imgRef.current?.clientHeight,
|
height: imgRef.current?.clientHeight,
|
||||||
@ -287,6 +292,7 @@ export default function ObjectLifecycle({
|
|||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
viewBox={`0 0 ${imgRef.current?.width} ${imgRef.current?.height}`}
|
viewBox={`0 0 ${imgRef.current?.width} ${imgRef.current?.height}`}
|
||||||
|
className="absolute inset-0"
|
||||||
>
|
>
|
||||||
<polygon
|
<polygon
|
||||||
points={getZonePolygon(zone)}
|
points={getZonePolygon(zone)}
|
||||||
|
@ -6,13 +6,6 @@ import {
|
|||||||
SheetHeader,
|
SheetHeader,
|
||||||
SheetTitle,
|
SheetTitle,
|
||||||
} from "../../ui/sheet";
|
} from "../../ui/sheet";
|
||||||
import {
|
|
||||||
Drawer,
|
|
||||||
DrawerContent,
|
|
||||||
DrawerDescription,
|
|
||||||
DrawerHeader,
|
|
||||||
DrawerTitle,
|
|
||||||
} from "../../ui/drawer";
|
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
|
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
|
||||||
@ -20,7 +13,7 @@ import { getIconForLabel } from "@/utils/iconUtil";
|
|||||||
import { useApiHost } from "@/api";
|
import { useApiHost } from "@/api";
|
||||||
import { ReviewDetailPaneType, ReviewSegment } from "@/types/review";
|
import { ReviewDetailPaneType, ReviewSegment } from "@/types/review";
|
||||||
import { Event } from "@/types/event";
|
import { Event } from "@/types/event";
|
||||||
import { useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { FrigatePlusDialog } from "../dialog/FrigatePlusDialog";
|
import { FrigatePlusDialog } from "../dialog/FrigatePlusDialog";
|
||||||
import ObjectLifecycle from "./ObjectLifecycle";
|
import ObjectLifecycle from "./ObjectLifecycle";
|
||||||
@ -37,6 +30,13 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { baseUrl } from "@/api/baseUrl";
|
import { baseUrl } from "@/api/baseUrl";
|
||||||
import { shareOrCopy } from "@/utils/browserUtil";
|
import { shareOrCopy } from "@/utils/browserUtil";
|
||||||
|
import {
|
||||||
|
MobilePage,
|
||||||
|
MobilePageContent,
|
||||||
|
MobilePageDescription,
|
||||||
|
MobilePageHeader,
|
||||||
|
MobilePageTitle,
|
||||||
|
} from "@/components/mobile/MobilePage";
|
||||||
|
|
||||||
type ReviewDetailDialogProps = {
|
type ReviewDetailDialogProps = {
|
||||||
review?: ReviewSegment;
|
review?: ReviewSegment;
|
||||||
@ -81,11 +81,19 @@ export default function ReviewDetailDialog({
|
|||||||
const [selectedEvent, setSelectedEvent] = useState<Event>();
|
const [selectedEvent, setSelectedEvent] = useState<Event>();
|
||||||
const [pane, setPane] = useState<ReviewDetailPaneType>("overview");
|
const [pane, setPane] = useState<ReviewDetailPaneType>("overview");
|
||||||
|
|
||||||
const Overlay = isDesktop ? Sheet : Drawer;
|
// dialog and mobile page
|
||||||
const Content = isDesktop ? SheetContent : DrawerContent;
|
|
||||||
const Header = isDesktop ? SheetHeader : DrawerHeader;
|
const [isOpen, setIsOpen] = useState(review != undefined);
|
||||||
const Title = isDesktop ? SheetTitle : DrawerTitle;
|
|
||||||
const Description = isDesktop ? SheetDescription : DrawerDescription;
|
useEffect(() => {
|
||||||
|
setIsOpen(review != undefined);
|
||||||
|
}, [review]);
|
||||||
|
|
||||||
|
const Overlay = isDesktop ? Sheet : MobilePage;
|
||||||
|
const Content = isDesktop ? SheetContent : MobilePageContent;
|
||||||
|
const Header = isDesktop ? SheetHeader : MobilePageHeader;
|
||||||
|
const Title = isDesktop ? SheetTitle : MobilePageTitle;
|
||||||
|
const Description = isDesktop ? SheetDescription : MobilePageDescription;
|
||||||
|
|
||||||
if (!review) {
|
if (!review) {
|
||||||
return;
|
return;
|
||||||
@ -94,7 +102,7 @@ export default function ReviewDetailDialog({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Overlay
|
<Overlay
|
||||||
open={review != undefined}
|
open={isOpen}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setReview(undefined);
|
setReview(undefined);
|
||||||
@ -115,19 +123,43 @@ export default function ReviewDetailDialog({
|
|||||||
|
|
||||||
<Content
|
<Content
|
||||||
className={cn(
|
className={cn(
|
||||||
isDesktop
|
"scrollbar-container overflow-y-auto",
|
||||||
? pane == "overview"
|
isDesktop && pane == "overview"
|
||||||
? "sm:max-w-xl"
|
? "sm:max-w-xl"
|
||||||
: "pt-2 sm:max-w-4xl"
|
: "pt-2 sm:max-w-4xl",
|
||||||
: "max-h-[80dvh] overflow-hidden p-2 pb-4",
|
isMobile && "px-4",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Header className="sr-only">
|
<span tabIndex={0} className="sr-only" />
|
||||||
<Title>Review Item Details</Title>
|
|
||||||
<Description>Review item details</Description>
|
|
||||||
</Header>
|
|
||||||
{pane == "overview" && (
|
{pane == "overview" && (
|
||||||
<div className="scrollbar-container mt-3 flex size-full flex-col gap-5 overflow-y-auto">
|
<Header className="justify-center" onClose={() => setIsOpen(false)}>
|
||||||
|
<Title>Review Item Details</Title>
|
||||||
|
<Description className="sr-only">Review item details</Description>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute",
|
||||||
|
isDesktop && "right-1 top-8",
|
||||||
|
isMobile && "right-0 top-3",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
shareOrCopy(`${baseUrl}review?id=${review.id}`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FaShareAlt className="size-4 text-secondary-foreground" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Share this review item</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</Header>
|
||||||
|
)}
|
||||||
|
{pane == "overview" && (
|
||||||
|
<div className="flex flex-col gap-5 md:mt-3">
|
||||||
<div className="flex w-full flex-row">
|
<div className="flex w-full flex-row">
|
||||||
<div className="flex w-full flex-col gap-3">
|
<div className="flex w-full flex-col gap-3">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
@ -140,21 +172,11 @@ 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 items-center gap-2">
|
<div className="flex w-full flex-col items-center gap-2">
|
||||||
<div className="flex w-full 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="scrollbar-container flex max-h-32 flex-col items-start gap-2 overflow-y-scroll text-sm capitalize">
|
<div className="scrollbar-container flex max-h-32 flex-col items-start gap-2 overflow-y-auto text-sm capitalize">
|
||||||
{events?.map((event) => {
|
{events?.map((event) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
@ -1,11 +1,4 @@
|
|||||||
import { isDesktop, isIOS, isMobile } from "react-device-detect";
|
import { isDesktop, isIOS, isMobile } from "react-device-detect";
|
||||||
import {
|
|
||||||
Drawer,
|
|
||||||
DrawerContent,
|
|
||||||
DrawerDescription,
|
|
||||||
DrawerHeader,
|
|
||||||
DrawerTitle,
|
|
||||||
} from "../../ui/drawer";
|
|
||||||
import { SearchResult } from "@/types/search";
|
import { SearchResult } from "@/types/search";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
@ -37,6 +30,13 @@ import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record";
|
|||||||
import { FaImage, FaRegListAlt, FaVideo } from "react-icons/fa";
|
import { FaImage, FaRegListAlt, FaVideo } from "react-icons/fa";
|
||||||
import { FaRotate } from "react-icons/fa6";
|
import { FaRotate } from "react-icons/fa6";
|
||||||
import ObjectLifecycle from "./ObjectLifecycle";
|
import ObjectLifecycle from "./ObjectLifecycle";
|
||||||
|
import {
|
||||||
|
MobilePage,
|
||||||
|
MobilePageContent,
|
||||||
|
MobilePageDescription,
|
||||||
|
MobilePageHeader,
|
||||||
|
MobilePageTitle,
|
||||||
|
} from "@/components/mobile/MobilePage";
|
||||||
|
|
||||||
const SEARCH_TABS = [
|
const SEARCH_TABS = [
|
||||||
"details",
|
"details",
|
||||||
@ -65,6 +65,14 @@ export default function SearchDetailDialog({
|
|||||||
const [page, setPage] = useState<SearchTab>("details");
|
const [page, setPage] = useState<SearchTab>("details");
|
||||||
const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100);
|
const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100);
|
||||||
|
|
||||||
|
// dialog and mobile page
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(search != undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsOpen(search != undefined);
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
const searchTabs = useMemo(() => {
|
const searchTabs = useMemo(() => {
|
||||||
if (!config || !search) {
|
if (!config || !search) {
|
||||||
return [];
|
return [];
|
||||||
@ -102,15 +110,15 @@ export default function SearchDetailDialog({
|
|||||||
|
|
||||||
// content
|
// content
|
||||||
|
|
||||||
const Overlay = isDesktop ? Dialog : Drawer;
|
const Overlay = isDesktop ? Dialog : MobilePage;
|
||||||
const Content = isDesktop ? DialogContent : DrawerContent;
|
const Content = isDesktop ? DialogContent : MobilePageContent;
|
||||||
const Header = isDesktop ? DialogHeader : DrawerHeader;
|
const Header = isDesktop ? DialogHeader : MobilePageHeader;
|
||||||
const Title = isDesktop ? DialogTitle : DrawerTitle;
|
const Title = isDesktop ? DialogTitle : MobilePageTitle;
|
||||||
const Description = isDesktop ? DialogDescription : DrawerDescription;
|
const Description = isDesktop ? DialogDescription : MobilePageDescription;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Overlay
|
<Overlay
|
||||||
open={search != undefined}
|
open={isOpen}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setSearch(undefined);
|
setSearch(undefined);
|
||||||
@ -118,15 +126,16 @@ export default function SearchDetailDialog({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Content
|
<Content
|
||||||
className={
|
className={cn(
|
||||||
isDesktop
|
"scrollbar-container overflow-y-auto",
|
||||||
? "sm:max-w-xl md:max-w-4xl lg:max-w-4xl xl:max-w-7xl"
|
isDesktop &&
|
||||||
: "max-h-[75dvh] overflow-hidden px-2 pb-4"
|
"max-h-[95dvh] sm:max-w-xl md:max-w-4xl lg:max-w-4xl xl:max-w-7xl",
|
||||||
}
|
isMobile && "px-4",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Header className="sr-only">
|
<Header onClose={() => setIsOpen(false)}>
|
||||||
<Title>Tracked Object Details</Title>
|
<Title>Tracked Object Details</Title>
|
||||||
<Description>Tracked object details</Description>
|
<Description className="sr-only">Tracked object details</Description>
|
||||||
</Header>
|
</Header>
|
||||||
<ScrollArea
|
<ScrollArea
|
||||||
className={cn("w-full whitespace-nowrap", isMobile && "my-2")}
|
className={cn("w-full whitespace-nowrap", isMobile && "my-2")}
|
||||||
@ -275,7 +284,7 @@ function ObjectDetailsTab({
|
|||||||
}, [desc, search]);
|
}, [desc, search]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 flex size-full flex-col gap-5 md:mt-0">
|
<div className="flex flex-col gap-5">
|
||||||
<div className="flex w-full flex-row">
|
<div className="flex w-full flex-row">
|
||||||
<div className="flex w-full flex-col gap-3">
|
<div className="flex w-full flex-col gap-3">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
@ -303,7 +312,7 @@ function ObjectDetailsTab({
|
|||||||
<div className="text-sm">{formattedDate}</div>
|
<div className="text-sm">{formattedDate}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full flex-col gap-2 px-6">
|
<div className="flex w-full flex-col gap-2 pl-6">
|
||||||
<img
|
<img
|
||||||
className="aspect-video select-none rounded-lg object-contain transition-opacity"
|
className="aspect-video select-none rounded-lg object-contain transition-opacity"
|
||||||
style={
|
style={
|
||||||
|
@ -13,6 +13,7 @@ import { Event } from "@/types/event";
|
|||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { isDesktop } from "react-device-detect";
|
||||||
import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch";
|
import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
|
||||||
@ -34,6 +35,9 @@ export function FrigatePlusDialog({
|
|||||||
|
|
||||||
// layout
|
// layout
|
||||||
|
|
||||||
|
const Title = isDesktop ? DialogTitle : "div";
|
||||||
|
const Description = isDesktop ? DialogDescription : "div";
|
||||||
|
|
||||||
const grow = useMemo(() => {
|
const grow = useMemo(() => {
|
||||||
if (!config || !upload) {
|
if (!config || !upload) {
|
||||||
return "";
|
return "";
|
||||||
@ -79,60 +83,74 @@ export function FrigatePlusDialog({
|
|||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<TransformWrapper minScale={1.0} wheel={{ smoothStep: 0.005 }}>
|
<TransformWrapper minScale={1.0} wheel={{ smoothStep: 0.005 }}>
|
||||||
<DialogHeader className={state == "submitted" ? "sr-only" : ""}>
|
<div className="flex flex-col space-y-3">
|
||||||
<DialogTitle>Submit To Frigate+</DialogTitle>
|
<DialogHeader
|
||||||
<DialogDescription>
|
className={state == "submitted" ? "sr-only" : "text-left"}
|
||||||
Objects in locations you want to avoid are not false positives.
|
>
|
||||||
Submitting them as false positives will confuse the model.
|
<Title
|
||||||
</DialogDescription>
|
className={
|
||||||
</DialogHeader>
|
!isDesktop
|
||||||
<TransformComponent
|
? "text-lg font-semibold leading-none tracking-tight"
|
||||||
wrapperStyle={{
|
: undefined
|
||||||
width: "100%",
|
}
|
||||||
height: "100%",
|
>
|
||||||
}}
|
Submit To Frigate+
|
||||||
contentStyle={{
|
</Title>
|
||||||
position: "relative",
|
<Description
|
||||||
width: "100%",
|
className={!isDesktop ? "text-sm text-muted-foreground" : undefined}
|
||||||
height: "100%",
|
>
|
||||||
}}
|
Objects in locations you want to avoid are not false positives.
|
||||||
>
|
Submitting them as false positives will confuse the model.
|
||||||
{upload?.id && (
|
</Description>
|
||||||
<img
|
</DialogHeader>
|
||||||
className={`w-full ${grow} bg-black`}
|
<TransformComponent
|
||||||
src={`${baseUrl}api/events/${upload?.id}/snapshot.jpg`}
|
wrapperStyle={{
|
||||||
alt={`${upload?.label}`}
|
width: "100%",
|
||||||
/>
|
height: "100%",
|
||||||
)}
|
}}
|
||||||
</TransformComponent>
|
contentStyle={{
|
||||||
|
position: "relative",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{upload?.id && (
|
||||||
|
<img
|
||||||
|
className={`w-full ${grow} bg-black`}
|
||||||
|
src={`${baseUrl}api/events/${upload?.id}/snapshot.jpg`}
|
||||||
|
alt={`${upload?.label}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TransformComponent>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter className="flex flex-row justify-end gap-2">
|
||||||
{state == "reviewing" && (
|
{state == "reviewing" && (
|
||||||
<>
|
<>
|
||||||
{dialog && <Button onClick={onClose}>Cancel</Button>}
|
{dialog && <Button onClick={onClose}>Cancel</Button>}
|
||||||
<Button
|
<Button
|
||||||
className="bg-success"
|
className="bg-success"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setState("uploading");
|
setState("uploading");
|
||||||
onSubmitToPlus(false);
|
onSubmitToPlus(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
This is a {upload?.label}
|
This is a {upload?.label}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="text-white"
|
className="text-white"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setState("uploading");
|
setState("uploading");
|
||||||
onSubmitToPlus(true);
|
onSubmitToPlus(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
This is not a {upload?.label}
|
This is not a {upload?.label}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{state == "uploading" && <ActivityIndicator />}
|
{state == "uploading" && <ActivityIndicator />}
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
</TransformWrapper>
|
</TransformWrapper>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -9,7 +9,7 @@ export function shareOrCopy(url: string, title?: string) {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
copy(url);
|
copy(url);
|
||||||
toast.success("Copied to clipboard.", {
|
toast.success("Copied URL to clipboard.", {
|
||||||
position: "top-center",
|
position: "top-center",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -60,7 +60,7 @@ export default function ExploreView({ onSelectSearch }: ExploreViewProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="scrollbar-container mx-2 space-y-4 overflow-x-hidden">
|
<div className="mx-2 space-y-4">
|
||||||
{Object.entries(eventsByLabel).map(([label, filteredEvents]) => (
|
{Object.entries(eventsByLabel).map(([label, filteredEvents]) => (
|
||||||
<ThumbnailRow
|
<ThumbnailRow
|
||||||
key={label}
|
key={label}
|
||||||
|
@ -215,7 +215,7 @@ export default function SearchView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!uniqueResults && !isLoading && (
|
{!uniqueResults && !isLoading && (
|
||||||
<div className="flex size-full flex-col">
|
<div className="scrollbar-container flex size-full flex-col overflow-y-auto">
|
||||||
<ExploreView onSelectSearch={onSelectSearch} />
|
<ExploreView onSelectSearch={onSelectSearch} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
Loading…
Reference in New Issue
Block a user