Compare commits

..

1 Commits

Author SHA1 Message Date
Weblate (bot)
5c3c0be94d
Merge 3072636684 into b2118382cb 2026-03-04 22:54:50 +00:00
50 changed files with 2795 additions and 2480 deletions

4461
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,6 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host", "dev": "vite --host",
"postinstall": "patch-package",
"build": "tsc && vite build --base=/BASE_PATH/", "build": "tsc && vite build --base=/BASE_PATH/",
"lint": "eslint --ext .jsx,.js,.tsx,.ts --ignore-path .gitignore .", "lint": "eslint --ext .jsx,.js,.tsx,.ts --ignore-path .gitignore .",
"lint:fix": "eslint --ext .jsx,.js,.tsx,.ts --ignore-path .gitignore --fix .", "lint:fix": "eslint --ext .jsx,.js,.tsx,.ts --ignore-path .gitignore --fix .",
@ -33,7 +32,7 @@
"@radix-ui/react-select": "^2.1.6", "@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.2.3", "@radix-ui/react-slider": "^1.2.3",
"@radix-ui/react-slot": "1.2.4", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.1.3", "@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-toggle": "^1.1.2", "@radix-ui/react-toggle": "^1.1.2",
@ -51,7 +50,8 @@
"copy-to-clipboard": "^3.3.3", "copy-to-clipboard": "^3.3.3",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"date-fns-tz": "^3.2.0", "date-fns-tz": "^3.2.0",
"framer-motion": "^12.35.0", "embla-carousel-react": "^8.2.0",
"framer-motion": "^11.5.4",
"hls.js": "^1.5.20", "hls.js": "^1.5.20",
"i18next": "^24.2.0", "i18next": "^24.2.0",
"i18next-http-backend": "^3.0.1", "i18next-http-backend": "^3.0.1",
@ -61,28 +61,30 @@
"lodash": "^4.17.23", "lodash": "^4.17.23",
"lucide-react": "^0.477.0", "lucide-react": "^0.477.0",
"monaco-yaml": "^5.3.1", "monaco-yaml": "^5.3.1",
"next-themes": "^0.4.6", "next-themes": "^0.3.0",
"nosleep.js": "^0.12.0", "nosleep.js": "^0.12.0",
"react": "^19.2.4", "react": "^18.3.1",
"react-apexcharts": "^1.4.1", "react-apexcharts": "^1.4.1",
"react-day-picker": "^9.7.0", "react-day-picker": "^9.7.0",
"react-device-detect": "^2.2.3", "react-device-detect": "^2.2.3",
"react-dom": "^19.2.4", "react-dom": "^18.3.1",
"react-dropzone": "^14.3.8", "react-dropzone": "^14.3.8",
"react-grid-layout": "^2.2.2", "react-grid-layout": "^1.5.0",
"react-hook-form": "^7.52.1", "react-hook-form": "^7.52.1",
"react-i18next": "^15.2.0", "react-i18next": "^15.2.0",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-konva": "^19.2.3", "react-konva": "^18.2.10",
"react-markdown": "^9.0.1",
"react-router-dom": "^6.30.3", "react-router-dom": "^6.30.3",
"react-markdown": "^9.0.1",
"remark-gfm": "^4.0.0",
"react-swipeable": "^7.0.2", "react-swipeable": "^7.0.2",
"react-tracked": "^2.0.1", "react-tracked": "^2.0.1",
"react-transition-group": "^4.4.5",
"react-use-websocket": "^4.8.1", "react-use-websocket": "^4.8.1",
"react-zoom-pan-pinch": "^3.7.0", "react-zoom-pan-pinch": "3.4.4",
"remark-gfm": "^4.0.0", "recoil": "^0.7.7",
"scroll-into-view-if-needed": "^3.1.0", "scroll-into-view-if-needed": "^3.1.0",
"sonner": "^2.0.7", "sonner": "^1.5.0",
"sort-by": "^1.2.0", "sort-by": "^1.2.0",
"strftime": "^0.10.3", "strftime": "^0.10.3",
"swr": "^2.3.2", "swr": "^2.3.2",
@ -90,7 +92,7 @@
"tailwind-scrollbar": "^3.1.0", "tailwind-scrollbar": "^3.1.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"use-long-press": "^3.2.0", "use-long-press": "^3.2.0",
"vaul": "^1.1.2", "vaul": "^0.9.1",
"vite-plugin-monaco-editor": "^1.1.0", "vite-plugin-monaco-editor": "^1.1.0",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
@ -99,8 +101,11 @@
"@testing-library/jest-dom": "^6.6.2", "@testing-library/jest-dom": "^6.6.2",
"@types/lodash": "^4.17.12", "@types/lodash": "^4.17.12",
"@types/node": "^20.14.10", "@types/node": "^20.14.10",
"@types/react": "^19.2.14", "@types/react": "^18.3.2",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^18.3.0",
"@types/react-grid-layout": "^1.3.5",
"@types/react-icons": "^3.0.0",
"@types/react-transition-group": "^4.4.10",
"@types/strftime": "^0.9.8", "@types/strftime": "^0.9.8",
"@typescript-eslint/eslint-plugin": "^7.5.0", "@typescript-eslint/eslint-plugin": "^7.5.0",
"@typescript-eslint/parser": "^7.5.0", "@typescript-eslint/parser": "^7.5.0",
@ -111,26 +116,19 @@
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-jest": "^28.2.0", "eslint-plugin-jest": "^28.2.0",
"eslint-plugin-prettier": "^5.0.1", "eslint-plugin-prettier": "^5.0.1",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.8", "eslint-plugin-react-refresh": "^0.4.8",
"eslint-plugin-vitest-globals": "^1.5.0", "eslint-plugin-vitest-globals": "^1.5.0",
"fake-indexeddb": "^6.0.0", "fake-indexeddb": "^6.0.0",
"jest-websocket-mock": "^2.5.0", "jest-websocket-mock": "^2.5.0",
"jsdom": "^24.1.1", "jsdom": "^24.1.1",
"monaco-editor": "^0.52.0",
"msw": "^2.3.5", "msw": "^2.3.5",
"patch-package": "^8.0.1",
"postcss": "^8.4.47", "postcss": "^8.4.47",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.5", "prettier-plugin-tailwindcss": "^0.6.5",
"tailwindcss": "^3.4.9", "tailwindcss": "^3.4.9",
"typescript": "^5.9.3", "typescript": "^5.8.2",
"vite": "^6.4.1", "vite": "^6.4.1",
"vitest": "^3.0.7" "vitest": "^3.0.7"
},
"overrides": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-slot": "1.2.4"
} }
} }

View File

@ -1,75 +0,0 @@
diff --git a/node_modules/@radix-ui/react-compose-refs/dist/index.js b/node_modules/@radix-ui/react-compose-refs/dist/index.js
index 5ba7a95..65aa7be 100644
--- a/node_modules/@radix-ui/react-compose-refs/dist/index.js
+++ b/node_modules/@radix-ui/react-compose-refs/dist/index.js
@@ -69,6 +69,31 @@ function composeRefs(...refs) {
};
}
function useComposedRefs(...refs) {
- return React.useCallback(composeRefs(...refs), refs);
+ const refsRef = React.useRef(refs);
+ React.useLayoutEffect(() => {
+ refsRef.current = refs;
+ });
+ return React.useCallback((node) => {
+ let hasCleanup = false;
+ const cleanups = refsRef.current.map((ref) => {
+ const cleanup = setRef(ref, node);
+ if (!hasCleanup && typeof cleanup === "function") {
+ hasCleanup = true;
+ }
+ return cleanup;
+ });
+ if (hasCleanup) {
+ return () => {
+ for (let i = 0; i < cleanups.length; i++) {
+ const cleanup = cleanups[i];
+ if (typeof cleanup === "function") {
+ cleanup();
+ } else {
+ setRef(refsRef.current[i], null);
+ }
+ }
+ };
+ }
+ }, []);
}
//# sourceMappingURL=index.js.map
diff --git a/node_modules/@radix-ui/react-compose-refs/dist/index.mjs b/node_modules/@radix-ui/react-compose-refs/dist/index.mjs
index 7dd9172..d1b53a5 100644
--- a/node_modules/@radix-ui/react-compose-refs/dist/index.mjs
+++ b/node_modules/@radix-ui/react-compose-refs/dist/index.mjs
@@ -32,7 +32,32 @@ function composeRefs(...refs) {
};
}
function useComposedRefs(...refs) {
- return React.useCallback(composeRefs(...refs), refs);
+ const refsRef = React.useRef(refs);
+ React.useLayoutEffect(() => {
+ refsRef.current = refs;
+ });
+ return React.useCallback((node) => {
+ let hasCleanup = false;
+ const cleanups = refsRef.current.map((ref) => {
+ const cleanup = setRef(ref, node);
+ if (!hasCleanup && typeof cleanup === "function") {
+ hasCleanup = true;
+ }
+ return cleanup;
+ });
+ if (hasCleanup) {
+ return () => {
+ for (let i = 0; i < cleanups.length; i++) {
+ const cleanup = cleanups[i];
+ if (typeof cleanup === "function") {
+ cleanup();
+ } else {
+ setRef(refsRef.current[i], null);
+ }
+ }
+ };
+ }
+ }, []);
}
export {
composeRefs,

View File

@ -1,46 +0,0 @@
diff --git a/node_modules/@radix-ui/react-slot/dist/index.js b/node_modules/@radix-ui/react-slot/dist/index.js
index 3691205..3b62ea8 100644
--- a/node_modules/@radix-ui/react-slot/dist/index.js
+++ b/node_modules/@radix-ui/react-slot/dist/index.js
@@ -85,11 +85,12 @@ function createSlotClone(ownerName) {
if (isLazyComponent(children) && typeof use === "function") {
children = use(children._payload);
}
+ const childrenRef = React.isValidElement(children) ? getElementRef(children) : null;
+ const composedRef = (0, import_react_compose_refs.useComposedRefs)(forwardedRef, childrenRef);
if (React.isValidElement(children)) {
- const childrenRef = getElementRef(children);
const props2 = mergeProps(slotProps, children.props);
if (children.type !== React.Fragment) {
- props2.ref = forwardedRef ? (0, import_react_compose_refs.composeRefs)(forwardedRef, childrenRef) : childrenRef;
+ props2.ref = forwardedRef ? composedRef : childrenRef;
}
return React.cloneElement(children, props2);
}
diff --git a/node_modules/@radix-ui/react-slot/dist/index.mjs b/node_modules/@radix-ui/react-slot/dist/index.mjs
index d7ea374..a990150 100644
--- a/node_modules/@radix-ui/react-slot/dist/index.mjs
+++ b/node_modules/@radix-ui/react-slot/dist/index.mjs
@@ -1,6 +1,6 @@
// src/slot.tsx
import * as React from "react";
-import { composeRefs } from "@radix-ui/react-compose-refs";
+import { composeRefs, useComposedRefs } from "@radix-ui/react-compose-refs";
import { Fragment as Fragment2, jsx } from "react/jsx-runtime";
var REACT_LAZY_TYPE = Symbol.for("react.lazy");
var use = React[" use ".trim().toString()];
@@ -45,11 +45,12 @@ function createSlotClone(ownerName) {
if (isLazyComponent(children) && typeof use === "function") {
children = use(children._payload);
}
+ const childrenRef = React.isValidElement(children) ? getElementRef(children) : null;
+ const composedRef = useComposedRefs(forwardedRef, childrenRef);
if (React.isValidElement(children)) {
- const childrenRef = getElementRef(children);
const props2 = mergeProps(slotProps, children.props);
if (children.type !== React.Fragment) {
- props2.ref = forwardedRef ? composeRefs(forwardedRef, childrenRef) : childrenRef;
+ props2.ref = forwardedRef ? composedRef : childrenRef;
}
return React.cloneElement(children, props2);
}

View File

@ -1,23 +0,0 @@
diff --git a/node_modules/react-use-websocket/dist/lib/use-websocket.js b/node_modules/react-use-websocket/dist/lib/use-websocket.js
index f01db48..b30aff2 100644
--- a/node_modules/react-use-websocket/dist/lib/use-websocket.js
+++ b/node_modules/react-use-websocket/dist/lib/use-websocket.js
@@ -139,15 +139,15 @@ var useWebSocket = function (url, options, connect) {
}
protectedSetLastMessage = function (message) {
if (!expectClose_1) {
- (0, react_dom_1.flushSync)(function () { return setLastMessage(message); });
+ setLastMessage(message);
}
};
protectedSetReadyState = function (state) {
if (!expectClose_1) {
- (0, react_dom_1.flushSync)(function () { return setReadyState(function (prev) {
+ setReadyState(function (prev) {
var _a;
return (__assign(__assign({}, prev), (convertedUrl.current && (_a = {}, _a[convertedUrl.current] = state, _a))));
- }); });
+ });
}
};
if (createOrJoin_1) {

View File

@ -114,17 +114,10 @@ interface PropertyElement {
content: React.ReactElement; content: React.ReactElement;
} }
/** Shape of the props that RJSF injects into each property element. */
interface RjsfElementProps {
schema?: { type?: string | string[] };
uiSchema?: Record<string, unknown> & {
"ui:widget"?: string;
"ui:options"?: Record<string, unknown>;
};
}
function isObjectLikeElement(item: PropertyElement) { function isObjectLikeElement(item: PropertyElement) {
const fieldSchema = (item.content.props as RjsfElementProps)?.schema; const fieldSchema = item.content.props?.schema as
| { type?: string | string[] }
| undefined;
return fieldSchema?.type === "object"; return fieldSchema?.type === "object";
} }
@ -170,21 +163,16 @@ function GridLayoutObjectFieldTemplate(
// Override the properties rendering with grid layout // Override the properties rendering with grid layout
const isHiddenProp = (prop: (typeof properties)[number]) => const isHiddenProp = (prop: (typeof properties)[number]) =>
(prop.content.props as RjsfElementProps).uiSchema?.["ui:widget"] === prop.content.props.uiSchema?.["ui:widget"] === "hidden";
"hidden";
const visibleProps = properties.filter((prop) => !isHiddenProp(prop)); const visibleProps = properties.filter((prop) => !isHiddenProp(prop));
// Separate regular and advanced properties // Separate regular and advanced properties
const advancedProps = visibleProps.filter( const advancedProps = visibleProps.filter(
(p) => (p) => p.content.props.uiSchema?.["ui:options"]?.advanced === true,
(p.content.props as RjsfElementProps).uiSchema?.["ui:options"]
?.advanced === true,
); );
const regularProps = visibleProps.filter( const regularProps = visibleProps.filter(
(p) => (p) => p.content.props.uiSchema?.["ui:options"]?.advanced !== true,
(p.content.props as RjsfElementProps).uiSchema?.["ui:options"]
?.advanced !== true,
); );
const hasModifiedAdvanced = advancedProps.some((prop) => const hasModifiedAdvanced = advancedProps.some((prop) =>
isPathModified([...fieldPath, prop.name]), isPathModified([...fieldPath, prop.name]),

View File

@ -448,9 +448,6 @@ export function FieldTemplate(props: FieldTemplateProps) {
); );
}; };
const errorsProps = errors?.props as { errors?: unknown[] } | undefined;
const hasFieldErrors = !!errors && (errorsProps?.errors?.length ?? 0) > 0;
const renderStandardLabel = () => { const renderStandardLabel = () => {
if (!shouldRenderStandardLabel) { if (!shouldRenderStandardLabel) {
return null; return null;
@ -462,7 +459,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
className={cn( className={cn(
"text-sm font-medium", "text-sm font-medium",
isModified && "text-danger", isModified && "text-danger",
hasFieldErrors && "text-destructive", errors && errors.props?.errors?.length > 0 && "text-destructive",
)} )}
> >
{finalLabel} {finalLabel}
@ -500,7 +497,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
className={cn( className={cn(
"text-sm font-medium", "text-sm font-medium",
isModified && "text-danger", isModified && "text-danger",
hasFieldErrors && "text-destructive", errors && errors.props?.errors?.length > 0 && "text-destructive",
)} )}
> >
{finalLabel} {finalLabel}

View File

@ -1,7 +1,6 @@
// Custom MultiSchemaFieldTemplate to handle anyOf [Type, null] fields // Custom MultiSchemaFieldTemplate to handle anyOf [Type, null] fields
// Renders simple nullable types as single inputs instead of dropdowns // Renders simple nullable types as single inputs instead of dropdowns
import type { JSX } from "react";
import { import {
MultiSchemaFieldTemplateProps, MultiSchemaFieldTemplateProps,
StrictRJSFSchema, StrictRJSFSchema,

View File

@ -25,15 +25,6 @@ import {
import get from "lodash/get"; import get from "lodash/get";
import { AddPropertyButton, AdvancedCollapsible } from "../components"; import { AddPropertyButton, AdvancedCollapsible } from "../components";
/** Shape of the props that RJSF injects into each property element. */
interface RjsfElementProps {
schema?: { type?: string | string[] };
uiSchema?: Record<string, unknown> & {
"ui:widget"?: string;
"ui:options"?: Record<string, unknown>;
};
}
export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) { export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
const { const {
title, title,
@ -191,21 +182,16 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
uiSchema?.["ui:options"]?.disableNestedCard === true; uiSchema?.["ui:options"]?.disableNestedCard === true;
const isHiddenProp = (prop: (typeof properties)[number]) => const isHiddenProp = (prop: (typeof properties)[number]) =>
(prop.content.props as RjsfElementProps).uiSchema?.["ui:widget"] === prop.content.props.uiSchema?.["ui:widget"] === "hidden";
"hidden";
const visibleProps = properties.filter((prop) => !isHiddenProp(prop)); const visibleProps = properties.filter((prop) => !isHiddenProp(prop));
// Check for advanced section grouping // Check for advanced section grouping
const advancedProps = visibleProps.filter( const advancedProps = visibleProps.filter(
(p) => (p) => p.content.props.uiSchema?.["ui:options"]?.advanced === true,
(p.content.props as RjsfElementProps).uiSchema?.["ui:options"]
?.advanced === true,
); );
const regularProps = visibleProps.filter( const regularProps = visibleProps.filter(
(p) => (p) => p.content.props.uiSchema?.["ui:options"]?.advanced !== true,
(p.content.props as RjsfElementProps).uiSchema?.["ui:options"]
?.advanced !== true,
); );
const hasModifiedAdvanced = advancedProps.some((prop) => const hasModifiedAdvanced = advancedProps.some((prop) =>
checkSubtreeModified([...fieldPath, prop.name]), checkSubtreeModified([...fieldPath, prop.name]),
@ -347,7 +333,9 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
const ungrouped = items.filter((item) => !grouped.has(item.name)); const ungrouped = items.filter((item) => !grouped.has(item.name));
const isObjectLikeField = (item: (typeof properties)[number]) => { const isObjectLikeField = (item: (typeof properties)[number]) => {
const fieldSchema = (item.content.props as RjsfElementProps)?.schema; const fieldSchema = item.content.props.schema as
| { type?: string | string[] }
| undefined;
return fieldSchema?.type === "object"; return fieldSchema?.type === "object";
}; };

View File

@ -1,5 +1,4 @@
import { t } from "i18next"; import { t } from "i18next";
import type { JSX } from "react";
import { FunctionComponent, useEffect, useMemo, useState } from "react"; import { FunctionComponent, useEffect, useMemo, useState } from "react";
interface IProp { interface IProp {

View File

@ -1,8 +1,8 @@
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { LogSeverity } from "@/types/log"; import { LogSeverity } from "@/types/log";
import { ReactNode, useMemo } from "react"; import { ReactNode, useMemo, useRef } from "react";
import { isIOS } from "react-device-detect"; import { isIOS } from "react-device-detect";
import { AnimatePresence, motion } from "framer-motion"; import { CSSTransition } from "react-transition-group";
type ChipProps = { type ChipProps = {
className?: string; className?: string;
@ -17,31 +17,39 @@ export default function Chip({
in: inProp = true, in: inProp = true,
onClick, onClick,
}: ChipProps) { }: ChipProps) {
return ( const nodeRef = useRef(null);
<AnimatePresence>
{inProp && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.5, ease: "easeInOut" }}
className={cn(
"flex items-center rounded-2xl px-2 py-1.5",
className,
!isIOS && "z-10",
)}
onClick={(e) => {
e.stopPropagation();
if (onClick) { return (
onClick(); <CSSTransition
} in={inProp}
}} nodeRef={nodeRef}
> timeout={500}
{children} classNames={{
</motion.div> enter: "opacity-0",
)} enterActive: "opacity-100 transition-opacity duration-500 ease-in-out",
</AnimatePresence> exit: "opacity-100",
exitActive: "opacity-0 transition-opacity duration-500 ease-in-out",
}}
unmountOnExit
>
<div
ref={nodeRef}
className={cn(
"flex items-center rounded-2xl px-2 py-1.5",
className,
!isIOS && "z-10",
)}
onClick={(e) => {
e.stopPropagation();
if (onClick) {
onClick();
}
}}
>
{children}
</div>
</CSSTransition>
); );
} }

View File

@ -55,9 +55,9 @@ export function MobilePage({
}); });
return ( return (
<MobilePageContext value={{ open, onOpenChange: setOpen }}> <MobilePageContext.Provider value={{ open, onOpenChange: setOpen }}>
{children} {children}
</MobilePageContext> </MobilePageContext.Provider>
); );
} }
@ -102,7 +102,7 @@ export function MobilePagePortal({
type MobilePageContentProps = { type MobilePageContentProps = {
children: React.ReactNode; children: React.ReactNode;
className?: string; className?: string;
scrollerRef?: React.RefObject<HTMLDivElement | null>; scrollerRef?: React.RefObject<HTMLDivElement>;
}; };
export function MobilePageContent({ export function MobilePageContent({

View File

@ -10,7 +10,7 @@ import Konva from "konva";
import { useResizeObserver } from "@/hooks/resize-observer"; import { useResizeObserver } from "@/hooks/resize-observer";
type DebugDrawingLayerProps = { type DebugDrawingLayerProps = {
containerRef: React.RefObject<HTMLDivElement | null>; containerRef: React.RefObject<HTMLDivElement>;
cameraWidth: number; cameraWidth: number;
cameraHeight: number; cameraHeight: number;
}; };

View File

@ -17,7 +17,7 @@ type ObjectPathProps = {
color?: number[]; color?: number[];
width?: number; width?: number;
pointRadius?: number; pointRadius?: number;
imgRef: React.RefObject<HTMLImageElement | null>; imgRef: React.RefObject<HTMLImageElement>;
onPointClick?: (index: number) => void; onPointClick?: (index: number) => void;
visible?: boolean; visible?: boolean;
}; };

View File

@ -22,7 +22,6 @@ import {
} from "@/components/ui/sheet"; } from "@/components/ui/sheet";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { isMobile } from "react-device-detect"; import { isMobile } from "react-device-detect";
import type { JSX } from "react";
import { useRef } from "react"; import { useRef } from "react";
type PlatformAwareDialogProps = { type PlatformAwareDialogProps = {

View File

@ -91,7 +91,7 @@ export default function HlsVideoPlayer({
// playback // playback
const hlsRef = useRef<Hls>(undefined); const hlsRef = useRef<Hls>();
const [useHlsCompat, setUseHlsCompat] = useState(false); const [useHlsCompat, setUseHlsCompat] = useState(false);
const [loadedMetadata, setLoadedMetadata] = useState(false); const [loadedMetadata, setLoadedMetadata] = useState(false);
const [bufferTimeout, setBufferTimeout] = useState<NodeJS.Timeout>(); const [bufferTimeout, setBufferTimeout] = useState<NodeJS.Timeout>();

View File

@ -51,10 +51,10 @@ export default function WebRtcPlayer({
// camera states // camera states
const pcRef = useRef<RTCPeerConnection | undefined>(undefined); const pcRef = useRef<RTCPeerConnection | undefined>();
const videoRef = useRef<HTMLVideoElement | null>(null); const videoRef = useRef<HTMLVideoElement | null>(null);
const [bufferTimeout, setBufferTimeout] = useState<NodeJS.Timeout>(); const [bufferTimeout, setBufferTimeout] = useState<NodeJS.Timeout>();
const videoLoadTimeoutRef = useRef<NodeJS.Timeout>(undefined); const videoLoadTimeoutRef = useRef<NodeJS.Timeout>();
const PeerConnection = useCallback( const PeerConnection = useCallback(
async (media: string) => { async (media: string) => {

View File

@ -10,7 +10,7 @@ import { snapPointToLines } from "@/utils/canvasUtil";
import { usePolygonStates } from "@/hooks/use-polygon-states"; import { usePolygonStates } from "@/hooks/use-polygon-states";
type PolygonCanvasProps = { type PolygonCanvasProps = {
containerRef: RefObject<HTMLDivElement | null>; containerRef: RefObject<HTMLDivElement>;
camera: string; camera: string;
width: number; width: number;
height: number; height: number;

View File

@ -18,7 +18,7 @@ import Konva from "konva";
import { Vector2d } from "konva/lib/types"; import { Vector2d } from "konva/lib/types";
type PolygonDrawerProps = { type PolygonDrawerProps = {
stageRef: RefObject<Konva.Stage | null>; stageRef: RefObject<Konva.Stage>;
points: number[][]; points: number[][];
distances: number[]; distances: number[];
isActive: boolean; isActive: boolean;

View File

@ -37,8 +37,8 @@ export type EventReviewTimelineProps = {
events: ReviewSegment[]; events: ReviewSegment[];
visibleTimestamps?: number[]; visibleTimestamps?: number[];
severityType: ReviewSeverity; severityType: ReviewSeverity;
timelineRef?: RefObject<HTMLDivElement | null>; timelineRef?: RefObject<HTMLDivElement>;
contentRef: RefObject<HTMLDivElement | null>; contentRef: RefObject<HTMLDivElement>;
onHandlebarDraggingChange?: (isDragging: boolean) => void; onHandlebarDraggingChange?: (isDragging: boolean) => void;
isZooming: boolean; isZooming: boolean;
zoomDirection: TimelineZoomDirection; zoomDirection: TimelineZoomDirection;

View File

@ -28,7 +28,7 @@ type EventSegmentProps = {
minimapStartTime?: number; minimapStartTime?: number;
minimapEndTime?: number; minimapEndTime?: number;
severityType: ReviewSeverity; severityType: ReviewSeverity;
contentRef: RefObject<HTMLDivElement | null>; contentRef: RefObject<HTMLDivElement>;
setHandlebarTime?: React.Dispatch<React.SetStateAction<number>>; setHandlebarTime?: React.Dispatch<React.SetStateAction<number>>;
scrollToSegment: (segmentTime: number, ifNeeded?: boolean) => void; scrollToSegment: (segmentTime: number, ifNeeded?: boolean) => void;
dense: boolean; dense: boolean;

View File

@ -41,8 +41,8 @@ export type MotionReviewTimelineProps = {
events: ReviewSegment[]; events: ReviewSegment[];
motion_events: MotionData[]; motion_events: MotionData[];
noRecordingRanges?: RecordingSegment[]; noRecordingRanges?: RecordingSegment[];
contentRef: RefObject<HTMLDivElement | null>; contentRef: RefObject<HTMLDivElement>;
timelineRef?: RefObject<HTMLDivElement | null>; timelineRef?: RefObject<HTMLDivElement>;
onHandlebarDraggingChange?: (isDragging: boolean) => void; onHandlebarDraggingChange?: (isDragging: boolean) => void;
dense?: boolean; dense?: boolean;
isZooming: boolean; isZooming: boolean;

View File

@ -20,8 +20,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { TooltipPortal } from "@radix-ui/react-tooltip"; import { TooltipPortal } from "@radix-ui/react-tooltip";
export type ReviewTimelineProps = { export type ReviewTimelineProps = {
timelineRef: RefObject<HTMLDivElement | null>; timelineRef: RefObject<HTMLDivElement>;
contentRef: RefObject<HTMLDivElement | null>; contentRef: RefObject<HTMLDivElement>;
segmentDuration: number; segmentDuration: number;
timelineDuration: number; timelineDuration: number;
timelineStartAligned: number; timelineStartAligned: number;

View File

@ -14,7 +14,7 @@ import {
import { useTimelineUtils } from "@/hooks/use-timeline-utils"; import { useTimelineUtils } from "@/hooks/use-timeline-utils";
export type SummaryTimelineProps = { export type SummaryTimelineProps = {
reviewTimelineRef: React.RefObject<HTMLDivElement | null>; reviewTimelineRef: React.RefObject<HTMLDivElement>;
timelineStart: number; timelineStart: number;
timelineEnd: number; timelineEnd: number;
segmentDuration: number; segmentDuration: number;

View File

@ -10,7 +10,7 @@ import { EventSegment } from "./EventSegment";
import { ReviewSegment, ReviewSeverity } from "@/types/review"; import { ReviewSegment, ReviewSeverity } from "@/types/review";
type VirtualizedEventSegmentsProps = { type VirtualizedEventSegmentsProps = {
timelineRef: React.RefObject<HTMLDivElement | null>; timelineRef: React.RefObject<HTMLDivElement>;
segments: number[]; segments: number[];
events: ReviewSegment[]; events: ReviewSegment[];
segmentDuration: number; segmentDuration: number;
@ -19,7 +19,7 @@ type VirtualizedEventSegmentsProps = {
minimapStartTime?: number; minimapStartTime?: number;
minimapEndTime?: number; minimapEndTime?: number;
severityType: ReviewSeverity; severityType: ReviewSeverity;
contentRef: React.RefObject<HTMLDivElement | null>; contentRef: React.RefObject<HTMLDivElement>;
setHandlebarTime?: React.Dispatch<React.SetStateAction<number>>; setHandlebarTime?: React.Dispatch<React.SetStateAction<number>>;
dense: boolean; dense: boolean;
alignStartDateToTimeline: (timestamp: number) => number; alignStartDateToTimeline: (timestamp: number) => number;

View File

@ -10,7 +10,7 @@ import MotionSegment from "./MotionSegment";
import { ReviewSegment, MotionData } from "@/types/review"; import { ReviewSegment, MotionData } from "@/types/review";
type VirtualizedMotionSegmentsProps = { type VirtualizedMotionSegmentsProps = {
timelineRef: React.RefObject<HTMLDivElement | null>; timelineRef: React.RefObject<HTMLDivElement>;
segments: number[]; segments: number[];
events: ReviewSegment[]; events: ReviewSegment[];
motion_events: MotionData[]; motion_events: MotionData[];
@ -19,7 +19,7 @@ type VirtualizedMotionSegmentsProps = {
showMinimap: boolean; showMinimap: boolean;
minimapStartTime?: number; minimapStartTime?: number;
minimapEndTime?: number; minimapEndTime?: number;
contentRef: React.RefObject<HTMLDivElement | null>; contentRef: React.RefObject<HTMLDivElement>;
setHandlebarTime?: React.Dispatch<React.SetStateAction<number>>; setHandlebarTime?: React.Dispatch<React.SetStateAction<number>>;
dense: boolean; dense: boolean;
motionOnly: boolean; motionOnly: boolean;

View File

@ -1,4 +1,3 @@
import type { JSX } from "react";
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { Button } from "./button"; import { Button } from "./button";
import { Calendar } from "./calendar"; import { Calendar } from "./calendar";
@ -125,8 +124,8 @@ export function DateRangePicker({
); );
// Refs to store the values of range and rangeCompare when the date picker is opened // Refs to store the values of range and rangeCompare when the date picker is opened
const openedRangeRef = useRef<DateRange | undefined>(undefined); const openedRangeRef = useRef<DateRange | undefined>();
const openedRangeCompareRef = useRef<DateRange | undefined>(undefined); const openedRangeCompareRef = useRef<DateRange | undefined>();
const [selectedPreset, setSelectedPreset] = useState<string | undefined>( const [selectedPreset, setSelectedPreset] = useState<string | undefined>(
undefined, undefined,

View File

@ -0,0 +1,265 @@
import * as React from "react";
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { useTranslation } from "react-i18next";
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: "horizontal" | "vertical";
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />");
}
return context;
}
const Carousel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & CarouselProps
>(
(
{
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
},
ref,
) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins,
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return;
}
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
scrollPrev();
} else if (event.key === "ArrowRight") {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext],
);
React.useEffect(() => {
if (!api || !setApi) {
return;
}
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) {
return;
}
onSelect(api);
api.on("reInit", onSelect);
api.on("select", onSelect);
return () => {
api?.off("select", onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
},
);
Carousel.displayName = "Carousel";
const CarouselContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel();
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className,
)}
{...props}
/>
</div>
);
});
CarouselContent.displayName = "CarouselContent";
const CarouselItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { orientation } = useCarousel();
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className,
)}
{...props}
/>
);
});
CarouselItem.displayName = "CarouselItem";
const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { t } = useTranslation(["views/explore"]);
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
aria-label={t("trackingDetails.carousel.previous")}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">{t("trackingDetails.carousel.previous")}</span>
</Button>
);
});
CarouselPrevious.displayName = "CarouselPrevious";
const CarouselNext = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { t } = useTranslation(["views/explore"]);
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
aria-label={t("trackingDetails.carousel.next")}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">{t("trackingDetails.carousel.next")}</span>
</Button>
);
});
CarouselNext.displayName = "CarouselNext";
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
};

View File

@ -33,9 +33,9 @@ const FormField = <
...props ...props
}: ControllerProps<TFieldValues, TName>) => { }: ControllerProps<TFieldValues, TName>) => {
return ( return (
<FormFieldContext value={{ name: props.name }}> <FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} /> <Controller {...props} />
</FormFieldContext> </FormFieldContext.Provider>
); );
}; };
@ -77,9 +77,9 @@ const FormItem = React.forwardRef<
const id = React.useId(); const id = React.useId();
return ( return (
<FormItemContext value={{ id }}> <FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-1", className)} {...props} /> <div ref={ref} className={cn("space-y-1", className)} {...props} />
</FormItemContext> </FormItemContext.Provider>
); );
}); });
FormItem.displayName = "FormItem"; FormItem.displayName = "FormItem";

View File

@ -1,10 +1,10 @@
import { ForwardedRef, forwardRef } from "react"; import { ForwardedRef, forwardRef } from "react";
import { IconType } from "react-icons"; import { IconType } from "react-icons";
interface IconWrapperProps extends React.HTMLAttributes<HTMLDivElement> { interface IconWrapperProps {
icon: IconType; icon: IconType;
className?: string; className?: string;
disabled?: boolean; [key: string]: any;
} }
const IconWrapper = forwardRef( const IconWrapper = forwardRef(

View File

@ -141,7 +141,7 @@ const SidebarProvider = React.forwardRef<
); );
return ( return (
<SidebarContext value={contextValue}> <SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}> <TooltipProvider delayDuration={0}>
<div <div
style={ style={
@ -161,7 +161,7 @@ const SidebarProvider = React.forwardRef<
{children} {children}
</div> </div>
</TooltipProvider> </TooltipProvider>
</SidebarContext> </SidebarContext.Provider>
); );
}, },
); );

View File

@ -22,9 +22,9 @@ const ToggleGroup = React.forwardRef<
className={cn("flex items-center justify-center gap-1", className)} className={cn("flex items-center justify-center gap-1", className)}
{...props} {...props}
> >
<ToggleGroupContext value={{ variant, size }}> <ToggleGroupContext.Provider value={{ variant, size }}>
{children} {children}
</ToggleGroupContext> </ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root> </ToggleGroupPrimitive.Root>
)) ))

View File

@ -102,5 +102,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
axios.get("/logout", { withCredentials: true }); axios.get("/logout", { withCredentials: true });
}; };
return <AuthContext value={{ auth, login, logout }}>{children}</AuthContext>; return (
<AuthContext.Provider value={{ auth, login, logout }}>
{children}
</AuthContext.Provider>
);
} }

View File

@ -125,7 +125,11 @@ export function DetailStreamProvider({
isDetailMode, isDetailMode,
}; };
return <DetailStreamContext value={value}>{children}</DetailStreamContext>; return (
<DetailStreamContext.Provider value={value}>
{children}
</DetailStreamContext.Provider>
);
} }
// eslint-disable-next-line react-refresh/only-export-components // eslint-disable-next-line react-refresh/only-export-components

View File

@ -77,9 +77,9 @@ export function LanguageProvider({
}; };
return ( return (
<LanguageProviderContext {...props} value={value}> <LanguageProviderContext.Provider {...props} value={value}>
{children} {children}
</LanguageProviderContext> </LanguageProviderContext.Provider>
); );
} }

View File

@ -1,5 +1,6 @@
import { ReactNode } from "react"; import { ReactNode } from "react";
import { ThemeProvider } from "@/context/theme-provider"; import { ThemeProvider } from "@/context/theme-provider";
import { RecoilRoot } from "recoil";
import { ApiProvider } from "@/api"; import { ApiProvider } from "@/api";
import { IconContext } from "react-icons"; import { IconContext } from "react-icons";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
@ -14,23 +15,25 @@ type TProvidersProps = {
function providers({ children }: TProvidersProps) { function providers({ children }: TProvidersProps) {
return ( return (
<AuthProvider> <RecoilRoot>
<ApiProvider> <AuthProvider>
<ThemeProvider defaultTheme="system" storageKey="frigate-ui-theme"> <ApiProvider>
<LanguageProvider> <ThemeProvider defaultTheme="system" storageKey="frigate-ui-theme">
<TooltipProvider> <LanguageProvider>
<IconContext.Provider value={{ size: "20" }}> <TooltipProvider>
<StatusBarMessagesProvider> <IconContext.Provider value={{ size: "20" }}>
<StreamingSettingsProvider> <StatusBarMessagesProvider>
{children} <StreamingSettingsProvider>
</StreamingSettingsProvider> {children}
</StatusBarMessagesProvider> </StreamingSettingsProvider>
</IconContext.Provider> </StatusBarMessagesProvider>
</TooltipProvider> </IconContext.Provider>
</LanguageProvider> </TooltipProvider>
</ThemeProvider> </LanguageProvider>
</ApiProvider> </ThemeProvider>
</AuthProvider> </ApiProvider>
</AuthProvider>
</RecoilRoot>
); );
} }

View File

@ -107,10 +107,10 @@ export function StatusBarMessagesProvider({
}, []); }, []);
return ( return (
<StatusBarMessagesContext <StatusBarMessagesContext.Provider
value={{ messages, addMessage, removeMessage, clearMessages }} value={{ messages, addMessage, removeMessage, clearMessages }}
> >
{children} {children}
</StatusBarMessagesContext> </StatusBarMessagesContext.Provider>
); );
} }

View File

@ -44,7 +44,7 @@ export function StreamingSettingsProvider({
}, [allGroupsStreamingSettings, setPersistedGroupStreamingSettings]); }, [allGroupsStreamingSettings, setPersistedGroupStreamingSettings]);
return ( return (
<StreamingSettingsContext <StreamingSettingsContext.Provider
value={{ value={{
allGroupsStreamingSettings, allGroupsStreamingSettings,
setAllGroupsStreamingSettings, setAllGroupsStreamingSettings,
@ -52,7 +52,7 @@ export function StreamingSettingsProvider({
}} }}
> >
{children} {children}
</StreamingSettingsContext> </StreamingSettingsContext.Provider>
); );
} }

View File

@ -124,9 +124,9 @@ export function ThemeProvider({
}; };
return ( return (
<ThemeProviderContext {...props} value={value}> <ThemeProviderContext.Provider {...props} value={value}>
{children} {children}
</ThemeProviderContext> </ThemeProviderContext.Provider>
); );
} }

View File

@ -31,24 +31,25 @@ export function useResizeObserver(...refs: RefType[]) {
[], [],
); );
// Resolve refs to actual DOM elements for use as stable effect dependencies.
// Rest params create a new array each call, but the underlying elements are
// stable DOM nodes, so spreading them into the dep array avoids re-running
// the effect on every render.
const elements = refs.map((ref) =>
ref instanceof Window ? document.body : ref.current,
);
useEffect(() => { useEffect(() => {
elements.forEach((el) => { refs.forEach((ref) => {
if (el) resizeObserver.observe(el); if (ref instanceof Window) {
resizeObserver.observe(document.body);
} else if (ref.current) {
resizeObserver.observe(ref.current);
}
}); });
return () => { return () => {
resizeObserver.disconnect(); refs.forEach((ref) => {
if (ref instanceof Window) {
resizeObserver.unobserve(document.body);
} else if (ref.current) {
resizeObserver.unobserve(ref.current);
}
});
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps }, [refs, resizeObserver]);
}, [...elements, resizeObserver]);
if (dimensions.length == refs.length) { if (dimensions.length == refs.length) {
return dimensions; return dimensions;

View File

@ -5,7 +5,6 @@ import { LiveStreamMetadata } from "@/types/live";
const FETCH_TIMEOUT_MS = 10000; const FETCH_TIMEOUT_MS = 10000;
const DEFER_DELAY_MS = 2000; const DEFER_DELAY_MS = 2000;
const EMPTY_METADATA: { [key: string]: LiveStreamMetadata } = {};
/** /**
* Hook that fetches go2rtc stream metadata with deferred loading. * Hook that fetches go2rtc stream metadata with deferred loading.
@ -78,7 +77,7 @@ export default function useDeferredStreamMetadata(streamNames: string[]) {
return metadata; return metadata;
}, []); }, []);
const { data: metadata = EMPTY_METADATA } = useSWR<{ const { data: metadata = {} } = useSWR<{
[key: string]: LiveStreamMetadata; [key: string]: LiveStreamMetadata;
}>(swrKey, fetcher, { }>(swrKey, fetcher, {
revalidateOnFocus: false, revalidateOnFocus: false,

View File

@ -8,10 +8,10 @@ import { useTranslation } from "react-i18next";
import useUserInteraction from "./use-user-interaction"; import useUserInteraction from "./use-user-interaction";
type DraggableElementProps = { type DraggableElementProps = {
contentRef: React.RefObject<HTMLElement | null>; contentRef: React.RefObject<HTMLElement>;
timelineRef: React.RefObject<HTMLDivElement | null>; timelineRef: React.RefObject<HTMLDivElement>;
segmentsRef: React.RefObject<HTMLDivElement | null>; segmentsRef: React.RefObject<HTMLDivElement>;
draggableElementRef: React.RefObject<HTMLDivElement | null>; draggableElementRef: React.RefObject<HTMLDivElement>;
segmentDuration: number; segmentDuration: number;
showDraggableElement: boolean; showDraggableElement: boolean;
draggableElementTime?: number; draggableElementTime?: number;

View File

@ -78,7 +78,7 @@ function removeEventListeners(
} }
export function useFullscreen<T extends HTMLElement = HTMLElement>( export function useFullscreen<T extends HTMLElement = HTMLElement>(
elementRef: RefObject<T | null>, elementRef: RefObject<T>,
) { ) {
const [fullscreen, setFullscreen] = useState<boolean>(false); const [fullscreen, setFullscreen] = useState<boolean>(false);
const [error, setError] = useState<Error | null>(null); const [error, setError] = useState<Error | null>(null);

View File

@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
const useImageLoaded = (): [ const useImageLoaded = (): [
React.RefObject<HTMLImageElement | null>, React.RefObject<HTMLImageElement>,
boolean, boolean,
() => void, () => void,
] => { ] => {

View File

@ -3,7 +3,7 @@ import { useCallback } from "react";
export type TimelineUtilsProps = { export type TimelineUtilsProps = {
segmentDuration: number; segmentDuration: number;
timelineDuration?: number; timelineDuration?: number;
timelineRef?: React.RefObject<HTMLElement | null>; timelineRef?: React.RefObject<HTMLElement>;
}; };
export function useTimelineUtils({ export function useTimelineUtils({

View File

@ -11,7 +11,7 @@ type UseTimelineZoomProps = {
zoomLevels: ZoomSettings[]; zoomLevels: ZoomSettings[];
onZoomChange: (newZoomLevel: number) => void; onZoomChange: (newZoomLevel: number) => void;
pinchThresholdPercent?: number; pinchThresholdPercent?: number;
timelineRef: React.RefObject<HTMLDivElement | null>; timelineRef: React.RefObject<HTMLDivElement>;
timelineDuration: number; timelineDuration: number;
}; };

View File

@ -1,12 +1,12 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
type UseUserInteractionProps = { type UseUserInteractionProps = {
elementRef: React.RefObject<HTMLElement | null>; elementRef: React.RefObject<HTMLElement>;
}; };
function useUserInteraction({ elementRef }: UseUserInteractionProps) { function useUserInteraction({ elementRef }: UseUserInteractionProps) {
const [userInteracting, setUserInteracting] = useState(false); const [userInteracting, setUserInteracting] = useState(false);
const interactionTimeout = useRef<NodeJS.Timeout>(undefined); const interactionTimeout = useRef<NodeJS.Timeout>();
const isProgrammaticScroll = useRef(false); const isProgrammaticScroll = useRef(false);
const setProgrammaticScroll = useCallback(() => { const setProgrammaticScroll = useCallback(() => {

View File

@ -7,7 +7,7 @@ export type VideoResolutionType = {
}; };
export function useVideoDimensions( export function useVideoDimensions(
containerRef: React.RefObject<HTMLDivElement | null>, containerRef: React.RefObject<HTMLDivElement>,
) { ) {
const [{ width: containerWidth, height: containerHeight }] = const [{ width: containerWidth, height: containerHeight }] =
useResizeObserver(containerRef); useResizeObserver(containerRef);

View File

@ -14,9 +14,10 @@ import React, {
useState, useState,
} from "react"; } from "react";
import { import {
ItemCallback,
Layout, Layout,
LayoutItem, Responsive,
ResponsiveGridLayout as Responsive, WidthProvider,
} from "react-grid-layout"; } from "react-grid-layout";
import "react-grid-layout/css/styles.css"; import "react-grid-layout/css/styles.css";
import "react-resizable/css/styles.css"; import "react-resizable/css/styles.css";
@ -55,7 +56,7 @@ type DraggableGridLayoutProps = {
cameras: CameraConfig[]; cameras: CameraConfig[];
cameraGroup: string; cameraGroup: string;
cameraRef: (node: HTMLElement | null) => void; cameraRef: (node: HTMLElement | null) => void;
containerRef: React.RefObject<HTMLDivElement | null>; containerRef: React.RefObject<HTMLDivElement>;
includeBirdseye: boolean; includeBirdseye: boolean;
onSelectCamera: (camera: string) => void; onSelectCamera: (camera: string) => void;
windowVisible: boolean; windowVisible: boolean;
@ -115,8 +116,11 @@ export default function DraggableGridLayout({
// grid layout // grid layout
const [gridLayout, setGridLayout, isGridLayoutLoaded] = const ResponsiveGridLayout = useMemo(() => WidthProvider(Responsive), []);
useUserPersistence<Layout>(`${cameraGroup}-draggable-layout`);
const [gridLayout, setGridLayout, isGridLayoutLoaded] = useUserPersistence<
Layout[]
>(`${cameraGroup}-draggable-layout`);
const [group] = useUserPersistedOverlayState( const [group] = useUserPersistedOverlayState(
"cameraGroup", "cameraGroup",
@ -154,11 +158,11 @@ export default function DraggableGridLayout({
const [currentIncludeBirdseye, setCurrentIncludeBirdseye] = const [currentIncludeBirdseye, setCurrentIncludeBirdseye] =
useState<boolean>(); useState<boolean>();
const [currentGridLayout, setCurrentGridLayout] = useState< const [currentGridLayout, setCurrentGridLayout] = useState<
Layout | undefined Layout[] | undefined
>(); >();
const handleLayoutChange = useCallback( const handleLayoutChange = useCallback(
(currentLayout: Layout) => { (currentLayout: Layout[]) => {
if (!isGridLayoutLoaded || !isEqual(gridLayout, currentGridLayout)) { if (!isGridLayoutLoaded || !isEqual(gridLayout, currentGridLayout)) {
return; return;
} }
@ -170,7 +174,7 @@ export default function DraggableGridLayout({
); );
const generateLayout = useCallback( const generateLayout = useCallback(
(baseLayout: Layout | undefined) => { (baseLayout: Layout[] | undefined) => {
if (!isGridLayoutLoaded) { if (!isGridLayoutLoaded) {
return; return;
} }
@ -180,7 +184,7 @@ export default function DraggableGridLayout({
? ["birdseye", ...cameras.map((camera) => camera?.name || "")] ? ["birdseye", ...cameras.map((camera) => camera?.name || "")]
: cameras.map((camera) => camera?.name || ""); : cameras.map((camera) => camera?.name || "");
const optionsMap: LayoutItem[] = baseLayout const optionsMap: Layout[] = baseLayout
? baseLayout.filter((layout) => cameraNames?.includes(layout.i)) ? baseLayout.filter((layout) => cameraNames?.includes(layout.i))
: []; : [];
@ -359,14 +363,12 @@ export default function DraggableGridLayout({
); );
}, [availableWidth, marginValue]); }, [availableWidth, marginValue]);
const handleResize = ( const handleResize: ItemCallback = (
_layout: Layout, _: Layout[],
oldLayoutItem: LayoutItem | null, oldLayoutItem: Layout,
layoutItem: LayoutItem | null, layoutItem: Layout,
placeholder: LayoutItem | null, placeholder: Layout,
) => { ) => {
if (!oldLayoutItem || !layoutItem || !placeholder) return;
const heightDiff = layoutItem.h - oldLayoutItem.h; const heightDiff = layoutItem.h - oldLayoutItem.h;
const widthDiff = layoutItem.w - oldLayoutItem.w; const widthDiff = layoutItem.w - oldLayoutItem.w;
const changeCoef = oldLayoutItem.w / oldLayoutItem.h; const changeCoef = oldLayoutItem.w / oldLayoutItem.h;
@ -535,9 +537,8 @@ export default function DraggableGridLayout({
currentGroups={groups} currentGroups={groups}
activeGroup={group} activeGroup={group}
/> />
<Responsive <ResponsiveGridLayout
className="grid-layout" className="grid-layout"
width={availableWidth ?? window.innerWidth}
layouts={{ layouts={{
lg: currentGridLayout, lg: currentGridLayout,
md: currentGridLayout, md: currentGridLayout,
@ -550,17 +551,13 @@ export default function DraggableGridLayout({
cols={{ lg: 12, md: 12, sm: 12, xs: 12, xxs: 12 }} cols={{ lg: 12, md: 12, sm: 12, xs: 12, xxs: 12 }}
margin={[marginValue, marginValue]} margin={[marginValue, marginValue]}
containerPadding={[0, isEditMode ? 6 : 3]} containerPadding={[0, isEditMode ? 6 : 3]}
resizeConfig={{ resizeHandles={isEditMode ? ["sw", "nw", "se", "ne"] : []}
enabled: isEditMode,
handles: isEditMode ? ["sw", "nw", "se", "ne"] : [],
}}
dragConfig={{
enabled: isEditMode,
}}
onDragStop={handleLayoutChange} onDragStop={handleLayoutChange}
onResize={handleResize} onResize={handleResize}
onResizeStart={() => setShowCircles(false)} onResizeStart={() => setShowCircles(false)}
onResizeStop={handleLayoutChange} onResizeStop={handleLayoutChange}
isDraggable={isEditMode}
isResizable={isEditMode}
> >
{includeBirdseye && birdseyeConfig?.enabled && ( {includeBirdseye && birdseyeConfig?.enabled && (
<BirdseyeLivePlayerGridItem <BirdseyeLivePlayerGridItem
@ -688,7 +685,7 @@ export default function DraggableGridLayout({
</GridLiveContextMenu> </GridLiveContextMenu>
); );
})} })}
</Responsive> </ResponsiveGridLayout>
{isDesktop && ( {isDesktop && (
<div <div
className={cn( className={cn(

View File

@ -419,7 +419,7 @@ export default function SearchView({
>(); >();
// keep track of previous ref to outline thumbnail when dialog closes // keep track of previous ref to outline thumbnail when dialog closes
const prevSearchDetailRef = useRef<SearchResult | undefined>(undefined); const prevSearchDetailRef = useRef<SearchResult | undefined>();
useEffect(() => { useEffect(() => {
if (searchDetail === undefined && prevSearchDetailRef.current) { if (searchDetail === undefined && prevSearchDetailRef.current) {
@ -619,9 +619,7 @@ export default function SearchView({
return ( return (
<div <div
key={value.id} key={value.id}
ref={(item) => { ref={(item) => (itemRefs.current[index] = item)}
itemRefs.current[index] = item;
}}
data-start={value.start_time} data-start={value.start_time}
className="relative flex flex-col rounded-lg" className="relative flex flex-col rounded-lg"
> >