* 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:
Josh Hawkins 2024-09-12 14:39:35 -05:00 committed by GitHub
parent 87ab4e7c9b
commit 644ea7be4a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 320 additions and 117 deletions

26
web/package-lock.json generated
View File

@ -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",

View File

@ -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",

View 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>
);
}

View File

@ -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)}

View File

@ -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

View File

@ -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={

View File

@ -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>
); );

View File

@ -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",
}); });
} }

View File

@ -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}

View File

@ -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>
)} )}