mirror of
https://github.com/blakeblackshear/frigate.git
synced 2024-11-21 19:07:46 +01:00
UI tweaks (#10946)
* fix warning * Improve event switching speed * Fix icon colors * Only show frigate+ page when frigate+ is enabled * Add link from reecordings to live as well
This commit is contained in:
parent
7a7ae81d50
commit
13cac082d5
@ -439,7 +439,7 @@ def motion_activity():
|
|||||||
# normalize data
|
# normalize data
|
||||||
motion = (
|
motion = (
|
||||||
df["motion"]
|
df["motion"]
|
||||||
.resample(f"{scale}S")
|
.resample(f"{scale}s")
|
||||||
.apply(lambda x: max(x, key=abs, default=0.0))
|
.apply(lambda x: max(x, key=abs, default=0.0))
|
||||||
.fillna(0.0)
|
.fillna(0.0)
|
||||||
.to_frame()
|
.to_frame()
|
||||||
|
@ -693,7 +693,9 @@ function ShowMotionOnlyButton({
|
|||||||
variant={motionOnlyButton ? "select" : "default"}
|
variant={motionOnlyButton ? "select" : "default"}
|
||||||
onClick={() => setMotionOnlyButton(!motionOnlyButton)}
|
onClick={() => setMotionOnlyButton(!motionOnlyButton)}
|
||||||
>
|
>
|
||||||
<FaRunning />
|
<FaRunning
|
||||||
|
className={`${motionOnlyButton ? "text-selected-foreground" : "text-secondary-foreground"}`}
|
||||||
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import { navbarLinks } from "@/pages/site-navigation";
|
|
||||||
import NavItem from "./NavItem";
|
import NavItem from "./NavItem";
|
||||||
import { IoIosWarning } from "react-icons/io";
|
import { IoIosWarning } from "react-icons/io";
|
||||||
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
|
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
|
||||||
@ -9,20 +8,15 @@ import { useMemo } from "react";
|
|||||||
import useStats from "@/hooks/use-stats";
|
import useStats from "@/hooks/use-stats";
|
||||||
import GeneralSettings from "../settings/GeneralSettings";
|
import GeneralSettings from "../settings/GeneralSettings";
|
||||||
import AccountSettings from "../settings/AccountSettings";
|
import AccountSettings from "../settings/AccountSettings";
|
||||||
|
import useNavigation from "@/hooks/use-navigation";
|
||||||
|
|
||||||
function Bottombar() {
|
function Bottombar() {
|
||||||
|
const navItems = useNavigation("secondary");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute h-16 inset-x-4 bottom-0 flex flex-row items-center justify-between">
|
<div className="absolute h-16 inset-x-4 bottom-0 flex flex-row items-center justify-between">
|
||||||
{navbarLinks.map((item) => (
|
{navItems.map((item) => (
|
||||||
<NavItem
|
<NavItem key={item.id} item={item} Icon={item.icon} />
|
||||||
className=""
|
|
||||||
variant="secondary"
|
|
||||||
key={item.id}
|
|
||||||
Icon={item.icon}
|
|
||||||
title={item.title}
|
|
||||||
url={item.url}
|
|
||||||
dev={item.dev}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
<GeneralSettings />
|
<GeneralSettings />
|
||||||
<AccountSettings />
|
<AccountSettings />
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
import { IconType } from "react-icons";
|
|
||||||
import { NavLink } from "react-router-dom";
|
import { NavLink } from "react-router-dom";
|
||||||
import { ENV } from "@/env";
|
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@ -8,6 +6,8 @@ import {
|
|||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { isDesktop } from "react-device-detect";
|
import { isDesktop } from "react-device-detect";
|
||||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||||
|
import { NavData } from "@/types/navigation";
|
||||||
|
import { IconType } from "react-icons";
|
||||||
|
|
||||||
const variants = {
|
const variants = {
|
||||||
primary: {
|
primary: {
|
||||||
@ -21,37 +21,29 @@ const variants = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type NavItemProps = {
|
type NavItemProps = {
|
||||||
className: string;
|
className?: string;
|
||||||
variant?: "primary" | "secondary";
|
item: NavData;
|
||||||
Icon: IconType;
|
Icon: IconType;
|
||||||
title: string;
|
|
||||||
url: string;
|
|
||||||
dev?: boolean;
|
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function NavItem({
|
export default function NavItem({
|
||||||
className,
|
className,
|
||||||
variant = "primary",
|
item,
|
||||||
Icon,
|
Icon,
|
||||||
title,
|
|
||||||
url,
|
|
||||||
dev,
|
|
||||||
onClick,
|
onClick,
|
||||||
}: NavItemProps) {
|
}: NavItemProps) {
|
||||||
const shouldRender = dev ? ENV !== "production" : true;
|
if (item.enabled == false) {
|
||||||
|
|
||||||
if (!shouldRender) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<NavLink
|
<NavLink
|
||||||
to={url}
|
to={item.url}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`${className} flex flex-col justify-center items-center rounded-lg ${
|
`flex flex-col justify-center items-center rounded-lg ${className ?? ""} ${
|
||||||
variants[variant][isActive ? "active" : "inactive"]
|
variants[item.variant ?? "primary"][isActive ? "active" : "inactive"]
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@ -65,7 +57,7 @@ export default function NavItem({
|
|||||||
<TooltipTrigger>{content}</TooltipTrigger>
|
<TooltipTrigger>{content}</TooltipTrigger>
|
||||||
<TooltipPortal>
|
<TooltipPortal>
|
||||||
<TooltipContent side="right">
|
<TooltipContent side="right">
|
||||||
<p>{title}</p>
|
<p>{item.title}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</TooltipPortal>
|
</TooltipPortal>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
@ -1,14 +1,16 @@
|
|||||||
import Logo from "../Logo";
|
import Logo from "../Logo";
|
||||||
import { navbarLinks } from "@/pages/site-navigation";
|
|
||||||
import NavItem from "./NavItem";
|
import NavItem from "./NavItem";
|
||||||
import { CameraGroupSelector } from "../filter/CameraGroupSelector";
|
import { CameraGroupSelector } from "../filter/CameraGroupSelector";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
import GeneralSettings from "../settings/GeneralSettings";
|
import GeneralSettings from "../settings/GeneralSettings";
|
||||||
import AccountSettings from "../settings/AccountSettings";
|
import AccountSettings from "../settings/AccountSettings";
|
||||||
|
import useNavigation from "@/hooks/use-navigation";
|
||||||
|
|
||||||
function Sidebar() {
|
function Sidebar() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
|
const navbarLinks = useNavigation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="absolute w-[52px] z-10 left-o inset-y-0 overflow-y-auto scrollbar-hidden py-4 flex flex-col justify-between bg-background_alt border-r border-secondary-highlight">
|
<aside className="absolute w-[52px] z-10 left-o inset-y-0 overflow-y-auto scrollbar-hidden py-4 flex flex-col justify-between bg-background_alt border-r border-secondary-highlight">
|
||||||
<span tabIndex={0} className="sr-only" />
|
<span tabIndex={0} className="sr-only" />
|
||||||
@ -22,10 +24,8 @@ function Sidebar() {
|
|||||||
<div key={item.id}>
|
<div key={item.id}>
|
||||||
<NavItem
|
<NavItem
|
||||||
className={`mx-[10px] ${showCameraGroups ? "mb-2" : "mb-4"}`}
|
className={`mx-[10px] ${showCameraGroups ? "mb-2" : "mb-4"}`}
|
||||||
|
item={item}
|
||||||
Icon={item.icon}
|
Icon={item.icon}
|
||||||
title={item.title}
|
|
||||||
url={item.url}
|
|
||||||
dev={item.dev}
|
|
||||||
/>
|
/>
|
||||||
{showCameraGroups && <CameraGroupSelector className="mb-4" />}
|
{showCameraGroups && <CameraGroupSelector className="mb-4" />}
|
||||||
</div>
|
</div>
|
||||||
|
59
web/src/hooks/use-navigation.ts
Normal file
59
web/src/hooks/use-navigation.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import Logo from "@/components/Logo";
|
||||||
|
import { ENV } from "@/env";
|
||||||
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
|
import { NavData } from "@/types/navigation";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { FaCompactDisc, FaVideo } from "react-icons/fa";
|
||||||
|
import { LuConstruction } from "react-icons/lu";
|
||||||
|
import { MdVideoLibrary } from "react-icons/md";
|
||||||
|
import useSWR from "swr";
|
||||||
|
|
||||||
|
export default function useNavigation(
|
||||||
|
variant: "primary" | "secondary" = "primary",
|
||||||
|
) {
|
||||||
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() =>
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
variant,
|
||||||
|
icon: FaVideo,
|
||||||
|
title: "Live",
|
||||||
|
url: "/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
variant,
|
||||||
|
icon: MdVideoLibrary,
|
||||||
|
title: "Review",
|
||||||
|
url: "/review",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
variant,
|
||||||
|
icon: FaCompactDisc,
|
||||||
|
title: "Export",
|
||||||
|
url: "/export",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
variant,
|
||||||
|
icon: Logo,
|
||||||
|
title: "Frigate+",
|
||||||
|
url: "/plus",
|
||||||
|
enabled: config?.plus?.enabled == true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
variant,
|
||||||
|
icon: LuConstruction,
|
||||||
|
title: "UI Playground",
|
||||||
|
url: "/playground",
|
||||||
|
enabled: ENV !== "production",
|
||||||
|
},
|
||||||
|
] as NavData[],
|
||||||
|
[config?.plus.enabled, variant],
|
||||||
|
);
|
||||||
|
}
|
@ -1,38 +0,0 @@
|
|||||||
import Logo from "@/components/Logo";
|
|
||||||
import { FaCompactDisc, FaVideo } from "react-icons/fa";
|
|
||||||
import { LuConstruction } from "react-icons/lu";
|
|
||||||
import { MdVideoLibrary } from "react-icons/md";
|
|
||||||
|
|
||||||
export const navbarLinks = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
icon: FaVideo,
|
|
||||||
title: "Live",
|
|
||||||
url: "/",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
icon: MdVideoLibrary,
|
|
||||||
title: "Review",
|
|
||||||
url: "/review",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
icon: FaCompactDisc,
|
|
||||||
title: "Export",
|
|
||||||
url: "/export",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
icon: Logo,
|
|
||||||
title: "Frigate+",
|
|
||||||
url: "/plus",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
icon: LuConstruction,
|
|
||||||
title: "UI Playground",
|
|
||||||
url: "/playground",
|
|
||||||
dev: true,
|
|
||||||
},
|
|
||||||
];
|
|
10
web/src/types/navigation.ts
Normal file
10
web/src/types/navigation.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { IconType } from "react-icons";
|
||||||
|
|
||||||
|
export type NavData = {
|
||||||
|
id: number;
|
||||||
|
variant?: "primary" | "secondary";
|
||||||
|
icon: IconType;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
};
|
@ -295,6 +295,7 @@ export default function EventView({
|
|||||||
filter={filter}
|
filter={filter}
|
||||||
timeRange={timeRange}
|
timeRange={timeRange}
|
||||||
startTime={startTime}
|
startTime={startTime}
|
||||||
|
loading={severity != severityToggle}
|
||||||
markItemAsReviewed={markItemAsReviewed}
|
markItemAsReviewed={markItemAsReviewed}
|
||||||
markAllItemsAsReviewed={markAllItemsAsReviewed}
|
markAllItemsAsReviewed={markAllItemsAsReviewed}
|
||||||
onSelectReview={onSelectReview}
|
onSelectReview={onSelectReview}
|
||||||
@ -334,6 +335,7 @@ type DetectionReviewProps = {
|
|||||||
filter?: ReviewFilter;
|
filter?: ReviewFilter;
|
||||||
timeRange: { before: number; after: number };
|
timeRange: { before: number; after: number };
|
||||||
startTime?: number;
|
startTime?: number;
|
||||||
|
loading: boolean;
|
||||||
markItemAsReviewed: (review: ReviewSegment) => void;
|
markItemAsReviewed: (review: ReviewSegment) => void;
|
||||||
markAllItemsAsReviewed: (currentItems: ReviewSegment[]) => void;
|
markAllItemsAsReviewed: (currentItems: ReviewSegment[]) => void;
|
||||||
onSelectReview: (review: ReviewSegment, ctrl: boolean) => void;
|
onSelectReview: (review: ReviewSegment, ctrl: boolean) => void;
|
||||||
@ -349,6 +351,7 @@ function DetectionReview({
|
|||||||
filter,
|
filter,
|
||||||
timeRange,
|
timeRange,
|
||||||
startTime,
|
startTime,
|
||||||
|
loading,
|
||||||
markItemAsReviewed,
|
markItemAsReviewed,
|
||||||
markAllItemsAsReviewed,
|
markAllItemsAsReviewed,
|
||||||
onSelectReview,
|
onSelectReview,
|
||||||
@ -600,33 +603,41 @@ function DetectionReview({
|
|||||||
</div>
|
</div>
|
||||||
<div className="w-[65px] md:w-[110px] flex flex-row">
|
<div className="w-[65px] md:w-[110px] flex flex-row">
|
||||||
<div className="w-[55px] md:w-[100px] overflow-y-auto no-scrollbar">
|
<div className="w-[55px] md:w-[100px] overflow-y-auto no-scrollbar">
|
||||||
<EventReviewTimeline
|
{loading ? (
|
||||||
segmentDuration={segmentDuration}
|
<Skeleton className="size-full" />
|
||||||
timestampSpread={15}
|
) : (
|
||||||
timelineStart={timeRange.before}
|
<EventReviewTimeline
|
||||||
timelineEnd={timeRange.after}
|
segmentDuration={segmentDuration}
|
||||||
showMinimap={showMinimap && !previewTime}
|
timestampSpread={15}
|
||||||
minimapStartTime={minimapBounds.start}
|
timelineStart={timeRange.before}
|
||||||
minimapEndTime={minimapBounds.end}
|
timelineEnd={timeRange.after}
|
||||||
showHandlebar={previewTime != undefined}
|
showMinimap={showMinimap && !previewTime}
|
||||||
handlebarTime={previewTime}
|
minimapStartTime={minimapBounds.start}
|
||||||
visibleTimestamps={visibleTimestamps}
|
minimapEndTime={minimapBounds.end}
|
||||||
events={reviewItems?.all ?? []}
|
showHandlebar={previewTime != undefined}
|
||||||
severityType={severity}
|
handlebarTime={previewTime}
|
||||||
contentRef={contentRef}
|
visibleTimestamps={visibleTimestamps}
|
||||||
timelineRef={reviewTimelineRef}
|
events={reviewItems?.all ?? []}
|
||||||
dense={isMobile}
|
severityType={severity}
|
||||||
/>
|
contentRef={contentRef}
|
||||||
|
timelineRef={reviewTimelineRef}
|
||||||
|
dense={isMobile}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="w-[10px]">
|
<div className="w-[10px]">
|
||||||
<SummaryTimeline
|
{loading ? (
|
||||||
reviewTimelineRef={reviewTimelineRef}
|
<Skeleton className="w-full" />
|
||||||
timelineStart={timeRange.before}
|
) : (
|
||||||
timelineEnd={timeRange.after}
|
<SummaryTimeline
|
||||||
segmentDuration={segmentDuration}
|
reviewTimelineRef={reviewTimelineRef}
|
||||||
events={reviewItems?.all ?? []}
|
timelineStart={timeRange.before}
|
||||||
severityType={severity}
|
timelineEnd={timeRange.after}
|
||||||
/>
|
segmentDuration={segmentDuration}
|
||||||
|
events={reviewItems?.all ?? []}
|
||||||
|
severityType={severity}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
@ -39,6 +39,7 @@ import MobileTimelineDrawer from "@/components/overlay/MobileTimelineDrawer";
|
|||||||
import MobileReviewSettingsDrawer from "@/components/overlay/MobileReviewSettingsDrawer";
|
import MobileReviewSettingsDrawer from "@/components/overlay/MobileReviewSettingsDrawer";
|
||||||
import Logo from "@/components/Logo";
|
import Logo from "@/components/Logo";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { FaVideo } from "react-icons/fa";
|
||||||
|
|
||||||
const SEGMENT_DURATION = 30;
|
const SEGMENT_DURATION = 30;
|
||||||
|
|
||||||
@ -251,14 +252,28 @@ export function RecordingView({
|
|||||||
{isMobile && (
|
{isMobile && (
|
||||||
<Logo className="absolute inset-x-1/2 -translate-x-1/2 h-8" />
|
<Logo className="absolute inset-x-1/2 -translate-x-1/2 h-8" />
|
||||||
)}
|
)}
|
||||||
<Button
|
<div
|
||||||
className="flex items-center gap-2 rounded-lg"
|
className={`flex items-center gap-2 ${isMobile ? "landscape:flex-col" : ""}`}
|
||||||
size="sm"
|
|
||||||
onClick={() => navigate(-1)}
|
|
||||||
>
|
>
|
||||||
<IoMdArrowRoundBack className="size-5" size="small" />
|
<Button
|
||||||
{isDesktop && <div className="text-primary">Back</div>}
|
className={`flex items-center gap-2.5 rounded-lg`}
|
||||||
</Button>
|
size="sm"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
>
|
||||||
|
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
|
||||||
|
{isDesktop && <div className="text-primary">Back</div>}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="flex items-center gap-2.5 rounded-lg"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
navigate(`/#${mainCamera}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaVideo className="size-5 text-secondary-foreground" />
|
||||||
|
{isDesktop && <div className="text-primary">Live</div>}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
<MobileCameraDrawer
|
<MobileCameraDrawer
|
||||||
allCameras={allCameras}
|
allCameras={allCameras}
|
||||||
|
@ -228,7 +228,7 @@ export default function LiveCameraView({ camera }: LiveCameraViewProps) {
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate(-1)}
|
||||||
>
|
>
|
||||||
<IoMdArrowRoundBack className="size-5" />
|
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
|
||||||
{isDesktop && <div className="text-primary">Back</div>}
|
{isDesktop && <div className="text-primary">Back</div>}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@ -247,7 +247,7 @@ export default function LiveCameraView({ camera }: LiveCameraViewProps) {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LuHistory className="size-5" />
|
<LuHistory className="size-5 text-secondary-foreground" />
|
||||||
{isDesktop && <div className="text-primary">History</div>}
|
{isDesktop && <div className="text-primary">History</div>}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
Loading…
Reference in New Issue
Block a user