import React, { useEffect, useMemo, useRef, useState } from "react";
import Translate, { translate } from "@docusaurus/Translate";
import { FaCompactDisc, FaVideo } from "react-icons/fa";
import { FaDrawPolygon, FaObjectGroup } from "react-icons/fa";
import { BsPersonBoundingBox } from "react-icons/bs";
import { IoSearch } from "react-icons/io5";
import { MdCategory, MdVideoLibrary } from "react-icons/md";
import { TbFaceId } from "react-icons/tb";
import {
LuActivity,
LuChevronDown,
LuChevronLeft,
LuChevronRight,
LuExternalLink,
LuGithub,
LuInfo,
LuLanguages,
LuLifeBuoy,
LuList,
LuPause,
LuPlay,
LuPlus,
LuRotateCw,
LuSettings,
LuSquarePen,
LuSunMoon,
LuTrash2,
} from "react-icons/lu";
import manifest from "./manifest.json";
import styles from "./styles.module.css";
const formatValue = (value) => {
if (value === null || value === undefined || value === "") {
return translate({ id: "configMock.notSet", message: "Not set" });
}
if (Array.isArray(value)) return value.join(", ");
return String(value);
};
function MockControl({ field, value }) {
const effectiveValue = value ?? field.default;
if (field.widget === "switch") {
return (
);
}
if (field.widget === "range") {
const min = field.minimum ?? 0;
const max = field.maximum ?? 100;
const numericValue = Number(effectiveValue ?? min);
const progress = Math.max(
0,
Math.min(100, ((numericValue - min) / (max - min)) * 100),
);
return (
{formatValue(effectiveValue)}
);
}
if (field.widget === "tags") {
const values = Array.isArray(effectiveValue)
? effectiveValue
: [effectiveValue];
return (
{values.filter(Boolean).map((item) => (
{String(item)}
))}
);
}
return (
{formatValue(effectiveValue)}
{field.widget === "select" && (
{"\u2304"}
)}
);
}
function normalizeStep(base, step) {
return {
level: step.level ?? base.level ?? "global",
section: step.section ?? base.section,
fields: step.fields ?? base.fields,
values: { ...(base.values ?? {}), ...(step.values ?? {}) },
focus: step.focus ?? base.focus,
hint: step.hint ?? base.hint,
label: step.label ?? base.label,
cameraImage: step.cameraImage ?? base.cameraImage,
title: step.title,
};
}
function IconRail({ phase }) {
return (
);
}
function SettingsNavigation({ step }) {
const navigationRef = useRef(null);
const activeItemRef = useRef(null);
const groups = manifest.navigation?.groups ?? [];
const activeGroup = groups.find((group) =>
group.items.some(
(item) => item.section === step.section && item.level === step.level,
),
);
useEffect(() => {
const navigation = navigationRef.current;
if (!navigation) return undefined;
if (step.guidePhase === "settings") {
navigation.scrollTop = 0;
return undefined;
}
if (step.guidePhase !== "menu" || !activeItemRef.current) {
return undefined;
}
const timer = window.setTimeout(() => {
const target = activeItemRef.current;
if (!target) return;
const reduceMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
navigation.scrollTo({
top:
target.offsetTop -
navigation.clientHeight / 2 +
target.offsetHeight / 2,
behavior: reduceMotion ? "auto" : "smooth",
});
}, 40);
return () => window.clearTimeout(timer);
}, [step.guidePhase, step.level, step.section]);
return (
);
}
function SystemMenu({ visible }) {
const label = (id, message) => translate({ id, message });
const groups = [
{
label: label("configMock.systemMenu.system", "System"),
items: [
[
LuActivity,
label("configMock.systemMenu.systemMetrics", "System metrics"),
],
[LuList, label("configMock.systemMenu.systemLogs", "System logs")],
],
},
{
label: label("configMock.systemMenu.configuration", "Configuration"),
items: [
[LuSettings, label("configMock.settings", "Settings"), true],
[
LuSquarePen,
label(
"configMock.systemMenu.configurationEditor",
"Configuration Editor",
),
],
],
},
{
label: label("configMock.systemMenu.appearance", "Appearance"),
items: [
[
LuLanguages,
label("configMock.systemMenu.languages", "Languages"),
false,
true,
],
[
LuSunMoon,
label("configMock.systemMenu.darkMode", "Dark Mode"),
false,
true,
],
[LuSunMoon, label("configMock.systemMenu.theme", "Theme"), false, true],
],
},
{
label: label("configMock.systemMenu.help", "Help"),
items: [
[
LuLifeBuoy,
label("configMock.systemMenu.documentation", "Documentation"),
],
[LuGithub, "GitHub"],
],
},
];
return (
{groups.map((group) => (
{group.label}
{group.items.map(([Icon, label, target, submenu]) => (
{label}
{submenu && (
)}
))}
))}
{label("configMock.systemMenu.restart", "Restart Frigate")}
);
}
function HintNavigation({ navigation }) {
return (
{navigation.current + 1} / {navigation.total}
);
}
function FieldHint({ navigation, title, text }) {
const hintRef = useRef(null);
const [placement, setPlacement] = useState(null);
const [revealed, setRevealed] = useState(false);
useEffect(() => {
const hint = hintRef.current;
const target = hint?.parentElement;
const viewport = target?.closest(`.${styles.contentViewport}`);
if (!hint || !target || !viewport) return undefined;
const measure = () => {
const targetRect = target.getBoundingClientRect();
const viewportRect = viewport.getBoundingClientRect();
const hintHeight = hint.getBoundingClientRect().height;
const spaceAbove = targetRect.top - viewportRect.top;
const spaceBelow = viewportRect.bottom - targetRect.bottom;
const requiredSpace = hintHeight + 18;
if (spaceBelow >= requiredSpace) {
setPlacement("below");
} else if (spaceAbove >= requiredSpace) {
setPlacement("above");
} else {
setPlacement("overlay");
}
};
const initialFrame = window.requestAnimationFrame(measure);
const settledTimer = window.setTimeout(measure, 200);
const revealTimer = window.setTimeout(() => setRevealed(true), 240);
viewport.addEventListener("scroll", measure, { passive: true });
window.addEventListener("resize", measure);
return () => {
window.cancelAnimationFrame(initialFrame);
window.clearTimeout(settledTimer);
window.clearTimeout(revealTimer);
viewport.removeEventListener("scroll", measure);
window.removeEventListener("resize", measure);
};
}, [text]);
return (
);
}
function NavigationHint({ navigation, step }) {
const hintRef = useRef(null);
const [position, setPosition] = useState(null);
const section = manifest.levels[step.level]?.[step.section];
const text =
step.guidePhase === "settings"
? translate({
id: "configMock.navigation.openSettingsHint",
message: "Open the system menu and select Settings.",
})
: translate(
{
id: "configMock.navigation.findSectionHint",
message: "Select {section} from {group}.",
},
{
section: section?.label ?? step.section,
group:
step.guideDetail ??
translate({
id: "configMock.settings",
message: "Settings",
}),
},
);
useEffect(() => {
const hint = hintRef.current;
const container = hint?.closest(`.${styles.appBody}`);
if (!hint || !container) return undefined;
const measure = () => {
const selector =
step.guidePhase === "settings"
? `.${styles.systemMenuTarget}`
: `.${styles.menuItem}.${styles.navigationTarget}`;
const target = container.querySelector(selector);
if (!target) return;
const containerRect = container.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
const hintRect = hint.getBoundingClientRect();
const gap = 12;
let left = targetRect.right - containerRect.left + gap;
if (left + hintRect.width > containerRect.width - gap) {
left = targetRect.left - containerRect.left - hintRect.width - gap;
}
const top = Math.max(
gap,
Math.min(
containerRect.height - hintRect.height - gap,
targetRect.top -
containerRect.top +
targetRect.height / 2 -
hintRect.height / 2,
),
);
setPosition({ left, top });
};
const timer = window.setTimeout(measure, 240);
window.addEventListener("resize", measure);
return () => {
window.clearTimeout(timer);
window.removeEventListener("resize", measure);
};
}, [step.guidePhase, step.level, step.section]);
return (
);
}
function ObjectFieldRow({ field, fieldKey, focusRef, navigation, step }) {
const focused = step.guidePhase === "field" && step.focus === fieldKey;
const objectValue = step.values[fieldKey] ?? field.default ?? {};
const entries = Object.entries(objectValue);
const displayedEntries = entries.length ? entries : [["", [""]]];
return (
{field.label}
{field.description &&
{field.description}
}
{focused ?
:
}
{focused && (
{displayedEntries.map(([key, rawValues], entryIndex) => {
const objectValues = Array.isArray(rawValues)
? rawValues
: [JSON.stringify(rawValues)];
return (
{key ||
translate({
id: "configMock.knownPlates.namePlaceholder",
message: "e.g., Wife's Car",
})}
{objectValues.map((item, valueIndex) => (
{item ||
translate({
id: "configMock.knownPlates.platePlaceholder",
message: "Plate number or regex",
})}
))}
Add
);
})}
Add
)}
{focused && (
)}
);
}
function RulesFieldRow({ field, fieldKey, focusRef, navigation, step }) {
const focused = step.guidePhase === "field" && step.focus === fieldKey;
const rules = step.values[fieldKey] ?? field.default ?? [];
return (
{field.label}
{field.description &&
{field.description}
}
{focused ?
:
}
{focused && (
Regex pattern
Replacement string
{rules.map((rule, index) => (
{rule.pattern}
{rule.replacement}
))}
Add
)}
{focused && (
)}
);
}
const objectLabelOptions = [
"bicycle",
"bus",
"car",
"cat",
"dog",
"license_plate",
"motorcycle",
"person",
];
const reviewLabelOptions = [
"bark",
"bicycle",
"bird",
"car",
"cat",
"dog",
"face",
"fire_alarm",
"license_plate",
"motorcycle",
"person",
];
const humanizeKey = (value) =>
value
.replaceAll("_", " ")
.replace(/\b\w/g, (character) => character.toUpperCase());
function TrackFieldRow({ field, fieldKey, focusRef, navigation, step }) {
const focused = step.guidePhase === "field" && step.focus === fieldKey;
const selected = step.values[fieldKey] ?? field.default ?? [];
const options = [...new Set([...objectLabelOptions, ...selected])].sort();
return (
{field.label}
{selected.length}{" "}
object types selected
Search...
{options.map((option) => (
{humanizeKey(option)}
))}
{field.description && (
{field.description}
)}
{focused && (
)}
);
}
function LabelSwitchesFieldRow({
field,
fieldKey,
focusRef,
navigation,
step,
}) {
const focused = step.guidePhase === "field" && step.focus === fieldKey;
const selected = step.values[fieldKey] ?? field.default ?? [];
const options = [...new Set([...reviewLabelOptions, ...selected])].sort();
return (
{field.label}
{selected.length}{" "}
labels selected
{options.map((option) => (
{humanizeKey(option)}
))}
Add custom label...
{field.description && (
{field.description}
)}
{focused && (
)}
);
}
function FiltersFieldRow({ field, fieldKey, focusRef, navigation, step }) {
const focused = step.guidePhase === "field" && step.focus === fieldKey;
const filters = step.values[fieldKey] ?? field.default ?? {};
return (
{field.label}
{field.description &&
{field.description}
}
{focused ?
:
}
{focused && (
{Object.entries(filters).map(([label, values]) => (
{humanizeKey(label)}
{Object.entries(values).map(([key, value]) => (
{humanizeKey(key)}
{formatValue(value)}
))}
))}
)}
{focused && (
)}
);
}
function FieldRow({ fieldKey, field, navigation, step, focusRef }) {
const focused = step.guidePhase === "field" && step.focus === fieldKey;
const fieldValue = step.values[fieldKey] ?? field.default;
if (
["alerts.labels", "detections.labels"].includes(fieldKey) &&
Array.isArray(fieldValue)
) {
return (
);
}
if (fieldKey === "track" && Array.isArray(fieldValue)) {
return (
);
}
if (fieldKey === "filters" && fieldValue && typeof fieldValue === "object") {
return (
);
}
const isObjectArray =
field.widget === "tags" &&
Array.isArray(fieldValue) &&
fieldValue.some((item) => item && typeof item === "object");
if (isObjectArray) {
return (
);
}
if (field.widget === "object") {
return (
);
}
return (
{field.label}
{field.description &&
{field.description}
}
{focused && (
)}
);
}
function ReviewToggle({ enabled, label }) {
return (
{label}
);
}
function ReviewSettingsLayout({ focusRef, navigation, section, step }) {
const valueFor = (key) =>
step.values[key] ?? section.fields[key]?.default ?? false;
const defaultZones = ["Front Door", "Driveway", "Side Yard"];
const configuredZones = [
...(Array.isArray(valueFor("alerts.required_zones"))
? valueFor("alerts.required_zones")
: []),
...(Array.isArray(valueFor("detections.required_zones"))
? valueFor("detections.required_zones")
: []),
];
const zones = configuredZones.length
? [...new Set(configuredZones)]
: defaultZones;
const configGroups = [
{
title: translate({
id: "configMock.review.alertsConfig",
message: "Alerts config",
}),
fields: ["alerts.enabled", "alerts.labels"],
},
{
title: translate({
id: "configMock.review.detectionsConfig",
message: "Detections config",
}),
fields: ["detections.enabled", "detections.labels"],
},
];
return (
Camera Review Settings
Temporarily enable or disable alerts and detections for this camera
until Frigate restarts.
Generative AI Review Descriptions
Temporarily enable or disable Generative AI review descriptions for
this camera until Frigate restarts.
Review Classification
Configure which zones classify review items as Alerts and
Detections.
Read the documentation
{" "}
{[
{
fieldKey: "alerts.required_zones",
label: translate({
id: "configMock.review.alerts",
message: "Alerts",
}),
description: translate({
id: "configMock.review.selectAlertsZones",
message: "Select zones for Alerts",
}),
},
{
fieldKey: "detections.required_zones",
label: translate({
id: "configMock.review.detections",
message: "Detections",
}),
description: translate({
id: "configMock.review.selectDetectionZones",
message: "Select zones for Detections",
}),
},
].map(({ description, fieldKey, label }) => {
const focused =
step.guidePhase === "field" && step.focus === fieldKey;
const selectedZones = Array.isArray(valueFor(fieldKey))
? valueFor(fieldKey)
: [];
return (
{label}
{description}
{zones.map((zone) => (
{zone}
))}
{focused && (
)}
);
})}
{configGroups.map((group) => (
{group.title}
Configure review generation and retention for this camera.
{group.fields.map((key) => (
))}
))}
);
}
function DetectorModelLayout({ focusRef, navigation, section, step }) {
const detectors = step.values.detectors ?? {};
const detectorLabels = [
...new Set(
Object.values(detectors).map((detector) => {
const type = detector.type ?? "cpu";
return manifest.detectorTypes?.[type]?.label ?? humanizeKey(type);
}),
),
];
const detectorFocused =
step.guidePhase === "field" && step.focus === "detectors";
const modelFocused =
step.guidePhase === "field" && step.focus === "custom_model";
const orderedModelFields = section.order.filter((key) => section.fields[key]);
const standardModelFields = orderedModelFields.filter(
(key) => !section.fields[key].advanced,
);
const advancedModelFields = orderedModelFields.filter(
(key) => section.fields[key].advanced,
);
return (
Detector Hardware
Configure the detector backend that runs object detection.
{Object.entries(detectors).map(([name, detector]) => {
const detectorType = detector.type ?? "cpu";
const detectorMetadata = manifest.detectorTypes?.[detectorType];
const detectorLabel =
detectorMetadata?.label ?? humanizeKey(detectorType);
const detectorFields = Object.entries(detector).filter(
([key]) => key !== "type",
);
return (
{detectorLabel}
{name}
{detectorMetadata?.description && (
{detectorMetadata.description}
)}
ID
{name}
Type
{detectorLabel}
{detectorFields.map(([key, value]) => {
const metadata = detectorMetadata?.fields?.[key];
const displayValue =
value === ""
? ""
: value && typeof value === "object"
? JSON.stringify(value)
: String(value);
return (
{metadata?.label ?? humanizeKey(key)}
{metadata?.description && (
{metadata.description}
)}
{displayValue}
);
})}
Add custom key
);
})}
{detectorFocused && (
)}
Detection Model
Configure the model and its input shape.
Frigate+
Custom Model
{standardModelFields.map((key) => (
))}
Advanced Settings
{" "}
({advancedModelFields.length})
{advancedModelFields.map((key) => (
))}
{modelFocused && (
)}
);
}
const maskZoneLabels = {
zones: "Zones",
"zone.add": "Add Zone",
"zone.canvas": "Draw the zone",
"zone.options": "Zone options",
"zone.objects": "Objects",
"zone.loitering_time": "Loitering Time",
"zone.inertia": "Inertia",
"zone.speed": "Speed Estimation",
"zone.speed_threshold": "Speed Threshold",
"zone.save": "Save",
motionMasks: "Motion Mask",
"motionMask.add": "New Motion Mask",
"motionMask.canvas": "Draw the motion mask",
"motionMask.options": "Motion mask options",
objectMasks: "Object Masks",
"objectMask.add": "New Object Mask",
"objectMask.canvas": "Draw the object mask",
"objectMask.options": "Object mask options",
"objectMask.objects": "Objects",
};
function MaskZoneTarget({ children, focusRef, id, navigation, step }) {
const focused = step.guidePhase === "field" && step.focus === id;
return (
{children}
{focused && !step.maskZoneOverlay && (
)}
);
}
function MaskZoneListSection({
addFocus,
focusRef,
icon: Icon,
items,
navigation,
sectionFocus,
step,
title,
}) {
return (
{items.map((item) => (
{item.name}
{item.points} points
))}
);
}
function MaskZoneFormField({
children,
description,
focusRef,
id,
label,
navigation,
step,
}) {
return (
{label}
{description && {description}}
{children}
);
}
function PolygonEditor({ focusRef, navigation, step, type }) {
const isZone = type === "zone";
const isMotionMask = type === "motionMask";
const name = isZone
? "Driveway"
: isMotionMask
? "Timestamp area"
: "Roof area";
const prefix = type;
return (
{isZone
? "Edit Zone"
: isMotionMask
? "Edit Motion Mask"
: "Edit Object Mask"}
Click the image to draw a polygon.
{name}
Enabled
{isZone && (
<>
person, car
4
3
{["10", "12", "11", "13.5"].map((value, index) => (
Line {String.fromCharCode(65 + index)}: {value}
))}
20
>
)}
{!isZone && !isMotionMask && (
person
)}
Cancel
Save
);
}
function MasksAndZonesLayout({ focusRef, navigation, step }) {
const focus = step.focus ?? "zones";
const layoutStep = { ...step, maskZoneOverlay: true };
const editorType =
focus.startsWith("zone.") && focus !== "zone.add"
? "zone"
: focus.startsWith("motionMask.") && focus !== "motionMask.add"
? "motionMask"
: focus.startsWith("objectMask.") && focus !== "objectMask.add"
? "objectMask"
: null;
const polygonClass =
editorType === "motionMask"
? styles.motionMaskPolygon
: editorType === "objectMask"
? styles.objectMaskPolygon
: styles.zonePolygon;
useEffect(() => {
if (step.guidePhase !== "field") return undefined;
const timer = window.setTimeout(() => {
const target = focusRef.current;
const sidebar = target?.closest(`.${styles.polygonSidebar}`);
if (!target || !sidebar) return;
const sidebarRect = sidebar.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
if (
targetRect.top >= sidebarRect.top + 12 &&
targetRect.bottom <= sidebarRect.bottom - 12
) {
return;
}
sidebar.scrollTo({
top: Math.max(
0,
sidebar.scrollTop + targetRect.top - sidebarRect.top - 18,
),
behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches
? "auto"
: "smooth",
});
}, 40);
return () => window.clearTimeout(timer);
}, [focusRef, step.focus, step.guidePhase]);
return (
Front Door
{editorType && (
Clear points
Snap points
)}
{step.guidePhase === "field" && (
)}
);
}
function SettingsContent({ navigation, step }) {
const section = manifest.levels[step.level]?.[step.section];
const viewportRef = useRef(null);
const focusRef = useRef(null);
const pageRef = useRef(null);
useEffect(() => {
const viewport = viewportRef.current;
const target = focusRef.current;
if (!viewport) return undefined;
const page = `${step.level}:${step.section}`;
const pageChanged = pageRef.current !== page;
pageRef.current = page;
if (pageChanged || step.guidePhase !== "field") {
viewport.scrollTop = 0;
}
if (step.guidePhase !== "field" || !target) return undefined;
const timer = window.setTimeout(() => {
const viewportRect = viewport.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
const outsideViewport =
targetRect.top < viewportRect.top + 24 ||
targetRect.bottom > viewportRect.bottom - 24;
if (!outsideViewport) return;
const targetIsTall = targetRect.height > viewportRect.height - 48;
const reduceMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
viewport.scrollTo({
top:
viewport.scrollTop +
targetRect.top -
viewportRect.top -
(targetIsTall
? 24
: viewport.clientHeight / 2 - targetRect.height / 2),
behavior: reduceMotion ? "auto" : "smooth",
});
}, 40);
return () => window.clearTimeout(timer);
}, [step]);
if (step.section === "masksAndZones" && step.level === "camera") {
return (
);
}
if (!section) return null;
if (step.section === "model" && step.level === "global") {
return (
Detectors and model
Configure the detector backend and the model it uses.
);
}
if (step.section === "review" && step.level === "camera") {
return (
{step.title ?? section.label}
{section.description &&
{section.description}
}
Read the documentation
{" "}
);
}
const orderedKeys = section.order.length
? section.order.filter((key) => section.fields[key])
: Object.keys(section.fields);
if (
step.focus &&
section.fields[step.focus] &&
!orderedKeys.includes(step.focus)
) {
orderedKeys.push(step.focus);
}
const advancedKeys = orderedKeys.filter(
(key) => section.fields[key]?.advanced,
);
const standardKeys = orderedKeys.filter(
(key) => !section.fields[key]?.advanced,
);
const groupByField = new Map();
(section.groups ?? []).forEach((group) => {
group.fields.forEach((key) => groupByField.set(key, group));
});
const renderedGroups = new Set();
const panels = [];
standardKeys.forEach((key) => {
const group = groupByField.get(key);
if (!group) {
panels.push({ type: "field", key, fields: [key] });
return;
}
if (renderedGroups.has(group.key)) return;
renderedGroups.add(group.key);
panels.push({
type: "group",
key: group.key,
label: group.label,
fields: group.fields.filter((fieldKey) =>
standardKeys.includes(fieldKey),
),
});
});
const focusIsAdvanced =
step.guidePhase === "field" && advancedKeys.includes(step.focus);
return (
{step.title ?? section.label}
{section.description &&
{section.description}
}
{section.docs && (
Read the documentation
{" "}
)}
{panels.map((panel) =>
panel.type === "group" ? (
{panel.label}
{panel.fields.map((key) => (
))}
) : (
),
)}
{advancedKeys.length > 0 &&
(focusIsAdvanced ? (
Advanced settings
{" "}
({advancedKeys.length})
{advancedKeys.map((key) => (
))}
) : (
{" "}
Advanced settings
{" "}
({advancedKeys.length})
))}
);
}
function FocusedSettings({ navigation, step }) {
const [cameraReady, setCameraReady] = useState(false);
useEffect(() => {
const timer = window.setTimeout(() => setCameraReady(true), 40);
return () => window.clearTimeout(timer);
}, []);
const cameraClass = !cameraReady
? styles.cameraOverview
: step.guidePhase === "settings"
? styles.cameraSettings
: step.guidePhase === "menu"
? styles.cameraMenu
: styles.cameraField;
return (
{step.guidePhase !== "field" && (
)}
);
}
export default function FrigateConfigMock({
autoPlay = true,
showNavigationSteps = true,
section,
level,
fields,
values,
focus,
hint,
label,
cameraImage = "/img/frigate-autotracking-example.gif",
targets,
steps,
}) {
const resolvedSteps = useMemo(() => {
const base = {
section,
level: level ?? "global",
fields,
values,
focus,
hint,
label,
cameraImage,
};
if (targets?.length) {
return targets.map((target) =>
normalizeStep(base, {
focus: typeof target === "string" ? target : target.field,
hint: typeof target === "string" ? undefined : target.hint,
}),
);
}
return steps?.length
? steps.flatMap((step) => {
const page = normalizeStep(base, step);
if (!step.targets?.length) return [page];
return step.targets.map((target) =>
normalizeStep(page, {
focus: typeof target === "string" ? target : target.field,
hint: typeof target === "string" ? undefined : target.hint,
}),
);
})
: [base];
}, [
cameraImage,
fields,
focus,
hint,
label,
level,
section,
steps,
targets,
values,
]);
const guideSteps = useMemo(
() =>
resolvedSteps.flatMap((step, index) => {
const sectionData = manifest.levels[step.level]?.[step.section];
const navigationGroup = manifest.navigation?.groups.find((group) =>
group.items.some(
(item) =>
item.section === step.section && item.level === step.level,
),
);
const navigationItem = navigationGroup?.items.find(
(item) => item.section === step.section && item.level === step.level,
);
const fieldLabel =
step.label ??
maskZoneLabels[step.focus] ??
sectionData?.fields?.[step.focus]?.label ??
(step.focus ? humanizeKey(step.focus.split(".").at(-1)) : undefined);
const previous = resolvedSteps[index - 1];
const samePage =
previous?.section === step.section && previous?.level === step.level;
const stages = [];
if (!samePage && showNavigationSteps) {
if (index === 0) {
stages.push({
...step,
guidePhase: "settings",
guideLabel: translate({
id: "configMock.guide.openSettings",
message: "Open Settings",
}),
});
}
stages.push({
...step,
guidePhase: "menu",
guideLabel: translate(
{
id: "configMock.guide.openSection",
message: "Find {section}",
},
{
section:
navigationItem?.label ?? sectionData?.label ?? step.section,
},
),
guideDetail: navigationGroup?.label,
});
}
if (fieldLabel) {
stages.push({
...step,
guidePhase: "field",
guideLabel:
step.label ??
translate(
{
id: "configMock.guide.findField",
message: "Find {field}",
},
{ field: fieldLabel },
),
});
}
return stages;
}),
[resolvedSteps, showNavigationSteps],
);
const [activeStep, setActiveStep] = useState(0);
const [playing, setPlaying] = useState(autoPlay);
const [isVisible, setIsVisible] = useState(false);
const mockRef = useRef(null);
const guideStepsRef = useRef(null);
const activeGuideStepRef = useRef(null);
const current = guideSteps[Math.min(activeStep, guideSteps.length - 1)];
useEffect(() => {
const mock = mockRef.current;
if (!mock) return undefined;
if (!("IntersectionObserver" in window)) {
setIsVisible(true);
return undefined;
}
const observer = new window.IntersectionObserver(
([entry]) => setIsVisible(entry.intersectionRatio >= 0.35),
{ threshold: [0, 0.35] },
);
observer.observe(mock);
return () => observer.disconnect();
}, []);
useEffect(() => {
if (!playing || !isVisible || activeStep >= guideSteps.length - 1) {
return undefined;
}
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
return undefined;
}
const timer = window.setTimeout(
() => {
setActiveStep((value) => Math.min(guideSteps.length - 1, value + 1));
},
current.guidePhase === "settings" ? 3400 : 2600,
);
return () => window.clearTimeout(timer);
}, [activeStep, current.guidePhase, guideSteps.length, isVisible, playing]);
useEffect(() => {
if (activeStep >= guideSteps.length - 1) setPlaying(false);
}, [activeStep, guideSteps.length]);
useEffect(() => {
const guide = guideStepsRef.current;
const target = activeGuideStepRef.current;
if (!guide || !target) return undefined;
if (activeStep <= 1) {
guide.scrollLeft = 0;
return undefined;
}
const previous = guide.children[activeStep - 1];
if (!previous) return undefined;
const guideRect = guide.getBoundingClientRect();
const previousRect = previous.getBoundingClientRect();
const previousContentLeft =
guide.scrollLeft + previousRect.left - guideRect.left;
guide.scrollLeft = Math.max(0, previousContentLeft - 14);
return undefined;
}, [activeStep]);
const selectStep = (index) => {
setPlaying(false);
setActiveStep(index);
};
const navigation = {
current: activeStep,
total: guideSteps.length,
previous: () => selectStep(Math.max(0, activeStep - 1)),
next: () => selectStep(Math.min(guideSteps.length - 1, activeStep + 1)),
};
return (
{guideSteps.map((step, index) => (
))}
Step {activeStep + 1}{" "}
/ {guideSteps.length}
{current.guideLabel}
{current.guideDetail && {current.guideDetail}}
);
}