diff --git a/web-new/src/App.tsx b/web-new/src/App.tsx index babafbd60..294b4cd75 100644 --- a/web-new/src/App.tsx +++ b/web-new/src/App.tsx @@ -32,7 +32,7 @@ function App() {
} /> - } /> + } /> } /> } /> } /> diff --git a/web-new/src/components/camera/CameraImage.tsx b/web-new/src/components/camera/CameraImage.tsx index 61a405352..853c2da09 100644 --- a/web-new/src/components/camera/CameraImage.tsx +++ b/web-new/src/components/camera/CameraImage.tsx @@ -7,8 +7,9 @@ import { useResizeObserver } from "@/hooks/resize-observer"; type CameraImageProps = { camera: string; onload?: (event: Event) => void; - searchParams: {}; - stretch?: boolean; + searchParams?: {}; + stretch?: boolean; // stretch to fit width + fitAspect?: number; // shrink to fit height }; export default function CameraImage({ @@ -16,13 +17,15 @@ export default function CameraImage({ onload, searchParams = "", stretch = false, + fitAspect, }: CameraImageProps) { const { data: config } = useSWR("config"); const apiHost = useApiHost(); const [hasLoaded, setHasLoaded] = useState(false); const containerRef = useRef(null); const canvasRef = useRef(null); - const [{ width: containerWidth }] = useResizeObserver(containerRef); + const [{ width: containerWidth, height: containerHeight }] = + useResizeObserver(containerRef); // Add scrollbar width (when visible) to the available observer width to eliminate screen juddering. // https://github.com/blakeblackshear/frigate/issues/1657 @@ -42,7 +45,10 @@ export default function CameraImage({ const aspectRatio = width / height; const scaledHeight = useMemo(() => { - const scaledHeight = Math.floor(availableWidth / aspectRatio); + const scaledHeight = + aspectRatio < (fitAspect ?? 0) + ? Math.floor(containerHeight) + : Math.floor(availableWidth / aspectRatio); const finalHeight = stretch ? scaledHeight : Math.min(scaledHeight, height); if (finalHeight > 0) { @@ -79,7 +85,12 @@ export default function CameraImage({ }, [apiHost, canvasRef, name, img, searchParams, scaledHeight, config]); return ( -
+
{enabled ? ( void; +}; + +export default function MiniEventCard({ event, onUpdate }: MiniEventCardProps) { + const baseUrl = useApiHost(); + const onSave = useCallback( + async (e: Event) => { + e.stopPropagation(); + let response; + if (!event.retain_indefinitely) { + response = await axios.post(`events/${event.id}/retain`); + } else { + response = await axios.delete(`events/${event.id}/retain`); + } + if (response.status === 200 && onUpdate) { + onUpdate(); + } + }, + [event] + ); + + return ( + +
+
+ onSave(e)} + fill={event.retain_indefinitely ? "currentColor" : "none"} + /> + {event.end_time ? null : ( +
+ In progress +
+ )} +
+
+
+ {event.label.replaceAll("_", " ")} + {event.sub_label + ? `: ${event.sub_label.replaceAll("_", " ")}` + : null} +
+
+
+ +
+ +
+
+
+ + {event.camera.replaceAll("_", " ")} +
+ {event.zones.length ? ( +
+ + {event.zones.join(", ").replaceAll("_", " ")} +
+ ) : null} +
+
+
+
+ ); +} diff --git a/web-new/src/components/dynamic/TimeAgo.tsx b/web-new/src/components/dynamic/TimeAgo.tsx new file mode 100644 index 000000000..3dd510cef --- /dev/null +++ b/web-new/src/components/dynamic/TimeAgo.tsx @@ -0,0 +1,83 @@ +import { FunctionComponent, useEffect, useMemo, useState } from "react"; + +interface IProp { + /** The time to calculate time-ago from */ + time: number; + /** OPTIONAL: overwrite current time */ + currentTime?: Date; + /** OPTIONAL: boolean that determines whether to show the time-ago text in dense format */ + dense?: boolean; + /** OPTIONAL: set custom refresh interval in milliseconds, default 1000 (1 sec) */ + refreshInterval?: number; +} + +type TimeUnit = { + unit: string; + full: string; + value: number; +}; + +const timeAgo = ({ time, currentTime = new Date(), dense = false }: IProp): string => { + if (typeof time !== 'number' || time < 0) return 'Invalid Time Provided'; + + const pastTime: Date = new Date(time); + const elapsedTime: number = currentTime.getTime() - pastTime.getTime(); + + const timeUnits: TimeUnit[] = [ + { unit: 'yr', full: 'year', value: 31536000 }, + { unit: 'mo', full: 'month', value: 0 }, + { unit: 'd', full: 'day', value: 86400 }, + { unit: 'h', full: 'hour', value: 3600 }, + { unit: 'm', full: 'minute', value: 60 }, + { unit: 's', full: 'second', value: 1 }, + ]; + + const elapsed: number = elapsedTime / 1000; + if (elapsed < 10) { + return 'just now'; + } + + for (let i = 0; i < timeUnits.length; i++) { + // if months + if (i === 1) { + // Get the month and year for the time provided + const pastMonth = pastTime.getUTCMonth(); + const pastYear = pastTime.getUTCFullYear(); + + // get current month and year + const currentMonth = currentTime.getUTCMonth(); + const currentYear = currentTime.getUTCFullYear(); + + let monthDiff = (currentYear - pastYear) * 12 + (currentMonth - pastMonth); + + // check if the time provided is the previous month but not exceeded 1 month ago. + if (currentTime.getUTCDate() < pastTime.getUTCDate()) { + monthDiff--; + } + + if (monthDiff > 0) { + const unitAmount = monthDiff; + return `${unitAmount}${dense ? timeUnits[i].unit : ` ${timeUnits[i].full}`}${dense ? '' : 's'} ago`; + } + } else if (elapsed >= timeUnits[i].value) { + const unitAmount: number = Math.floor(elapsed / timeUnits[i].value); + return `${unitAmount}${dense ? timeUnits[i].unit : ` ${timeUnits[i].full}`}${dense ? '' : 's'} ago`; + } + } + return 'Invalid Time'; +}; + +const TimeAgo: FunctionComponent = ({ refreshInterval = 1000, ...rest }): JSX.Element => { + const [currentTime, setCurrentTime] = useState(new Date()); + useEffect(() => { + const intervalId: NodeJS.Timeout = setInterval(() => { + setCurrentTime(new Date()); + }, refreshInterval); + return () => clearInterval(intervalId); + }, [refreshInterval]); + + const timeAgoValue = useMemo(() => timeAgo({ currentTime, ...rest }), [currentTime, rest]); + + return {timeAgoValue}; +}; +export default TimeAgo; diff --git a/web-new/src/components/player/LivePlayer.tsx b/web-new/src/components/player/LivePlayer.tsx index adbeef2b7..a6e61c03c 100644 --- a/web-new/src/components/player/LivePlayer.tsx +++ b/web-new/src/components/player/LivePlayer.tsx @@ -8,7 +8,7 @@ import { useCallback, useMemo, useState } from "react"; import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"; import { Switch } from "../ui/switch"; import { Label } from "../ui/label"; -import { usePersistence } from "@/context/use-persistence"; +import { usePersistence } from "@/hooks/use-persistence"; const emptyObject = Object.freeze({}); diff --git a/web-new/src/pages/Dashboard.tsx b/web-new/src/pages/Dashboard.tsx index 466bd8990..b214aef40 100644 --- a/web-new/src/pages/Dashboard.tsx +++ b/web-new/src/pages/Dashboard.tsx @@ -1,79 +1,169 @@ -import { useState } from "react"; +import { useMemo } from "react"; import ActivityIndicator from "@/components/ui/activity-indicator"; -import { Switch } from "@/components/ui/switch"; -import { Label } from "@/components/ui/label"; import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { useDetectState } from "@/api/ws"; + useAudioState, + useDetectState, + useRecordingsState, + useSnapshotsState, +} from "@/api/ws"; import useSWR from "swr"; -import { FrigateConfig } from "@/types/frigateConfig"; +import { CameraConfig, FrigateConfig } from "@/types/frigateConfig"; import Heading from "@/components/ui/heading"; +import { Card } from "@/components/ui/card"; +import CameraImage from "@/components/camera/CameraImage"; +import { AspectRatio } from "@/components/ui/aspect-ratio"; +import { Button } from "@/components/ui/button"; +import { AiOutlinePicture } from "react-icons/ai"; +import { FaWalking } from "react-icons/fa"; +import { LuEar } from "react-icons/lu"; +import { TbMovie } from "react-icons/tb"; +import MiniEventCard from "@/components/card/MiniEventCard"; +import { Event } from "@/types/event"; +import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; export function Dashboard() { const { data: config } = useSWR("config"); - const [selectedCamera, setSelectedCamera] = useState( - undefined - ); - let cameras; - if (config?.cameras) { - cameras = Object.keys(config.cameras).map((name) => ( -
- setSelectedCamera(name)}> - {name} - -
- )); - } + const recentTimestamp = useMemo(() => { + const now = new Date(); + now.setMinutes(now.getMinutes() - 30); + return now.getTime() / 1000; + }, []); + const { data: events, mutate: updateEvents } = useSWR([ + "events", + { limit: 10, after: recentTimestamp }, + ]); + + const sortedCameras = useMemo(() => { + if (!config) { + return []; + } + + return Object.values(config.cameras) + .filter((conf) => conf.ui.dashboard) + .sort((aConf, bConf) => aConf.ui.order - bConf.ui.order); + }, [config]); return ( <> + Dashboard + {!config && } - Components testing - -
- -
- {selectedCamera && } + {config && ( +
+ {events && events.length > 0 && ( + <> + Recent Events + +
+ {events.map((event) => { + return ( + updateEvents()} + /> + ); + })} +
+ +
+ + )} + Cameras +
+ {sortedCameras.map((camera) => { + return ; + })} +
+
+ )} ); } -function Camera({ cameraName }: { cameraName: string }) { - const { payload: detectValue, send: sendDetect } = useDetectState(cameraName); +function Camera({ camera }: { camera: CameraConfig }) { + const { payload: detectValue, send: sendDetect } = useDetectState( + camera.name + ); + const { payload: recordValue, send: sendRecord } = useRecordingsState( + camera.name + ); + const { payload: snapshotValue, send: sendSnapshot } = useSnapshotsState( + camera.name + ); + const { payload: audioValue, send: sendAudio } = useAudioState(camera.name); return ( <> - - {cameraName} - -
- - sendDetect(detectValue === "ON" ? "OFF" : "ON", true) - } - /> - -
+ + + + + +
+
+ {camera.name.replaceAll("_", " ")} +
+
+ + + + {camera.audio.enabled_in_config && ( + + )} +
+
+
+
); } diff --git a/web-new/src/pages/Live.tsx b/web-new/src/pages/Live.tsx index 66061a0db..264bb2b93 100644 --- a/web-new/src/pages/Live.tsx +++ b/web-new/src/pages/Live.tsx @@ -13,15 +13,27 @@ import Heading from "@/components/ui/heading"; import { usePersistence } from "@/hooks/use-persistence"; import { FrigateConfig } from "@/types/frigateConfig"; import { useMemo, useState } from "react"; +import { useParams } from "react-router-dom"; import useSWR from "swr"; function Live() { const { data: config } = useSWR("config"); + const { camera: openedCamera } = useParams(); - const [camera, setCamera] = useState("Select A Camera"); + const [camera, setCamera] = useState( + openedCamera ?? "Select A Camera" + ); const cameraConfig = useMemo(() => { return config?.cameras[camera]; }, [camera, config]); + const sortedCameras = useMemo(() => { + if (!config) { + return []; + } + + return Object.values(config.cameras) + .sort((aConf, bConf) => aConf.ui.order - bConf.ui.order); + }, [config]); const restreamEnabled = useMemo(() => { return ( config && @@ -62,13 +74,13 @@ function Live() { Select A Camera - {Object.keys(config?.cameras || {}).map((item) => ( + {(sortedCameras).map((item) => ( - {item.replaceAll("_", " ")} + {item.name.replaceAll("_", " ")} ))} diff --git a/web-new/src/types/event.ts b/web-new/src/types/event.ts new file mode 100644 index 000000000..69e09577a --- /dev/null +++ b/web-new/src/types/event.ts @@ -0,0 +1,25 @@ +export interface Event { + id: string; + label: string; + sub_label?: string; + camera: string; + start_time: number; + end_time?: number; + false_positive: boolean; + zones: string[]; + thumbnail: string; + has_clip: boolean; + has_snapshot: boolean; + retain_indefinitely: boolean; + plus_id?: string; + model_hash?: string; + data: { + top_score: number; + score: number; + region: number[]; + box: number[]; + area: number; + ratio: number; + type: "object" | "audio" | "manual"; + } +} \ No newline at end of file diff --git a/web-new/src/types/frigateConfig.ts b/web-new/src/types/frigateConfig.ts index 8b5aa8ab2..a15cc9c18 100644 --- a/web-new/src/types/frigateConfig.ts +++ b/web-new/src/types/frigateConfig.ts @@ -6,6 +6,8 @@ export interface UiConfig { strftime_fmt?: string; live_mode?: string; use_experimental?: boolean; + dashboard: boolean; + order: number; } export interface CameraConfig { diff --git a/web-new/src/utils/dateUtil.ts b/web-new/src/utils/dateUtil.ts index 464c4a342..f6845f023 100644 --- a/web-new/src/utils/dateUtil.ts +++ b/web-new/src/utils/dateUtil.ts @@ -1,6 +1,5 @@ -import strftime from 'strftime'; -import { fromUnixTime, intervalToDuration, formatDuration } from 'date-fns'; -import { UiConfig } from "@/types/frigateConfig"; +import strftime from "strftime"; +import { fromUnixTime, intervalToDuration, formatDuration } from "date-fns"; export const longToDate = (long: number): Date => new Date(long * 1000); export const epochToLong = (date: number): number => date / 1000; export const dateToLong = (date: Date): number => epochToLong(date.getTime()); @@ -40,23 +39,45 @@ export const getNowYesterdayInLong = (): number => { // only used as a fallback if the browser does not support dateStyle/timeStyle in Intl.DateTimeFormat const formatMap: { [k: string]: { - date: { year: 'numeric' | '2-digit'; month: 'long' | 'short' | '2-digit'; day: 'numeric' | '2-digit' }; - time: { hour: 'numeric'; minute: 'numeric'; second?: 'numeric'; timeZoneName?: 'short' | 'long' }; + date: { + year: "numeric" | "2-digit"; + month: "long" | "short" | "2-digit"; + day: "numeric" | "2-digit"; + }; + time: { + hour: "numeric"; + minute: "numeric"; + second?: "numeric"; + timeZoneName?: "short" | "long"; + }; }; } = { full: { - date: { year: 'numeric', month: 'long', day: 'numeric' }, - time: { hour: 'numeric', minute: 'numeric', second: 'numeric', timeZoneName: 'long' }, + date: { year: "numeric", month: "long", day: "numeric" }, + time: { + hour: "numeric", + minute: "numeric", + second: "numeric", + timeZoneName: "long", + }, }, long: { - date: { year: 'numeric', month: 'long', day: 'numeric' }, - time: { hour: 'numeric', minute: 'numeric', second: 'numeric', timeZoneName: 'long' }, + date: { year: "numeric", month: "long", day: "numeric" }, + time: { + hour: "numeric", + minute: "numeric", + second: "numeric", + timeZoneName: "long", + }, }, medium: { - date: { year: 'numeric', month: 'short', day: 'numeric' }, - time: { hour: 'numeric', minute: 'numeric', second: 'numeric' }, + date: { year: "numeric", month: "short", day: "numeric" }, + time: { hour: "numeric", minute: "numeric", second: "numeric" }, + }, + short: { + date: { year: "2-digit", month: "2-digit", day: "2-digit" }, + time: { hour: "numeric", minute: "numeric" }, }, - short: { date: { year: '2-digit', month: '2-digit', day: '2-digit' }, time: { hour: 'numeric', minute: 'numeric' } }, }; /** @@ -79,11 +100,11 @@ const getResolvedTimeZone = () => { return Intl.DateTimeFormat().resolvedOptions().timeZone; } catch (error) { const offsetMinutes = new Date().getTimezoneOffset(); - return `UTC${offsetMinutes < 0 ? '+' : '-'}${Math.abs(offsetMinutes / 60) + return `UTC${offsetMinutes < 0 ? "+" : "-"}${Math.abs(offsetMinutes / 60) .toString() - .padStart(2, '0')}:${Math.abs(offsetMinutes % 60) + .padStart(2, "0")}:${Math.abs(offsetMinutes % 60) .toString() - .padStart(2, '0')}`; + .padStart(2, "0")}`; } }; @@ -103,11 +124,21 @@ const getResolvedTimeZone = () => { * * @throws {Error} If the given unixTimestamp is not a valid number, the function will return 'Invalid time'. */ -export const formatUnixTimestampToDateTime = (unixTimestamp: number, config: UiConfig): string => { - const { timezone, time_format, date_style, time_style, strftime_fmt } = config; - const locale = window.navigator?.language || 'en-US'; +export const formatUnixTimestampToDateTime = ( + unixTimestamp: number, + config: { + timezone?: string; + time_format?: "browser" | "12hour" | "24hour"; + date_style?: "full" | "long" | "medium" | "short"; + time_style?: "full" | "long" | "medium" | "short"; + strftime_fmt?: string; + } +): string => { + const { timezone, time_format, date_style, time_style, strftime_fmt } = + config; + const locale = window.navigator?.language || "en-US"; if (isNaN(unixTimestamp)) { - return 'Invalid time'; + return "Invalid time"; } try { @@ -125,7 +156,7 @@ export const formatUnixTimestampToDateTime = (unixTimestamp: number, config: UiC const options: Intl.DateTimeFormatOptions = { dateStyle: date_style, timeStyle: time_style, - hour12: time_format !== 'browser' ? time_format == '12hour' : undefined, + hour12: time_format !== "browser" ? time_format == "12hour" : undefined, }; // Only set timeZone option when resolvedTimeZone does not match UTC±HH:MM format, or when timezone is set in config @@ -143,15 +174,26 @@ export const formatUnixTimestampToDateTime = (unixTimestamp: number, config: UiC // fallback if the browser does not support dateStyle/timeStyle in Intl.DateTimeFormat // This works even tough the timezone is undefined, it will use the runtime's default time zone if (!containsTime) { - const dateOptions = { ...formatMap[date_style ?? ""]?.date, timeZone: options.timeZone, hour12: options.hour12 }; - const timeOptions = { ...formatMap[time_style ?? ""]?.time, timeZone: options.timeZone, hour12: options.hour12 }; + const dateOptions = { + ...formatMap[date_style ?? ""]?.date, + timeZone: options.timeZone, + hour12: options.hour12, + }; + const timeOptions = { + ...formatMap[time_style ?? ""]?.time, + timeZone: options.timeZone, + hour12: options.hour12, + }; - return `${date.toLocaleDateString(locale, dateOptions)} ${date.toLocaleTimeString(locale, timeOptions)}`; + return `${date.toLocaleDateString( + locale, + dateOptions + )} ${date.toLocaleTimeString(locale, timeOptions)}`; } return formattedDateTime; } catch (error) { - return 'Invalid time'; + return "Invalid time"; } }; @@ -169,28 +211,31 @@ interface DurationToken { * @param end_time: number|null - Unix timestamp for end time * @returns string - duration or 'In Progress' if end time is not provided */ -export const getDurationFromTimestamps = (start_time: number, end_time: number | null): string => { +export const getDurationFromTimestamps = ( + start_time: number, + end_time: number | null +): string => { if (isNaN(start_time)) { - return 'Invalid start time'; + return "Invalid start time"; } - let duration = 'In Progress'; + let duration = "In Progress"; if (end_time !== null) { if (isNaN(end_time)) { - return 'Invalid end time'; + return "Invalid end time"; } const start = fromUnixTime(start_time); const end = fromUnixTime(end_time); const formatDistanceLocale: DurationToken = { - xSeconds: '{{count}}s', - xMinutes: '{{count}}m', - xHours: '{{count}}h', + xSeconds: "{{count}}s", + xMinutes: "{{count}}m", + xHours: "{{count}}h", }; const shortEnLocale = { formatDistance: (token: keyof DurationToken, count: number) => - formatDistanceLocale[token].replace('{{count}}', count.toString()), + formatDistanceLocale[token].replace("{{count}}", count.toString()), }; duration = formatDuration(intervalToDuration({ start, end }), { - format: ['hours', 'minutes', 'seconds'], + format: ["hours", "minutes", "seconds"], locale: shortEnLocale, }); } @@ -209,14 +254,18 @@ const getUTCOffset = (date: Date, timezone: string): number => { if (utcOffsetMatch) { const hours = parseInt(utcOffsetMatch[2], 10); const minutes = parseInt(utcOffsetMatch[3], 10); - return (utcOffsetMatch[1] === '+' ? 1 : -1) * (hours * 60 + minutes); + return (utcOffsetMatch[1] === "+" ? 1 : -1) * (hours * 60 + minutes); } // Otherwise, calculate offset using provided timezone - const utcDate = new Date(date.getTime() - date.getTimezoneOffset() * 60 * 1000); + const utcDate = new Date( + date.getTime() - date.getTimezoneOffset() * 60 * 1000 + ); // locale of en-CA is required for proper locale format - let iso = utcDate.toLocaleString('en-CA', { timeZone: timezone, hour12: false }).replace(', ', 'T'); - iso += `.${utcDate.getMilliseconds().toString().padStart(3, '0')}`; + let iso = utcDate + .toLocaleString("en-CA", { timeZone: timezone, hour12: false }) + .replace(", ", "T"); + iso += `.${utcDate.getMilliseconds().toString().padStart(3, "0")}`; let target = new Date(`${iso}Z`); // safari doesn't like the default format diff --git a/web-new/themes/theme-blue.css b/web-new/themes/theme-blue.css index be2609668..19bc40364 100644 --- a/web-new/themes/theme-blue.css +++ b/web-new/themes/theme-blue.css @@ -25,7 +25,7 @@ .theme-blue.dark { --background: 222.2 84% 4.9%; --foreground: 210 40% 98%; - --card: 222.2 84% 4.9%; + --card: 217.2 32.6% 17.5%; --card-foreground: 210 40% 98%; --popover: 222.2 84% 4.9%; --popover-foreground: 210 40% 98%; @@ -40,7 +40,7 @@ --destructive: 0 62.8% 30.6%; --destructive-foreground: 210 40% 98%; --border: 217.2 32.6% 17.5%; - --input: 217.2 32.6% 17.5%; + --input: 217.2 38.6% 29.5%; --ring: 224.3 76.3% 48%; } }