* 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,12 +83,25 @@ 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"}
>
<Title
className={
!isDesktop
? "text-lg font-semibold leading-none tracking-tight"
: undefined
}
>
Submit To Frigate+
</Title>
<Description
className={!isDesktop ? "text-sm text-muted-foreground" : undefined}
>
Objects in locations you want to avoid are not false positives. Objects in locations you want to avoid are not false positives.
Submitting them as false positives will confuse the model. Submitting them as false positives will confuse the model.
</DialogDescription> </Description>
</DialogHeader> </DialogHeader>
<TransformComponent <TransformComponent
wrapperStyle={{ wrapperStyle={{
@ -106,7 +123,7 @@ export function FrigatePlusDialog({
)} )}
</TransformComponent> </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>}
@ -133,6 +150,7 @@ export function FrigatePlusDialog({
)} )}
{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>
)} )}