mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-28 06:39:01 +03:00
Miscellaneous fixes (#23186)
* improve scroll handling for non-modal DropdownMenu in classification and face selection dialogs * clean up * fix incorrect key capitalization * fix profile array overrides not replacing base arrays don't use lodash merge(), it does positional merging and an empty source array doesn't override the destination, and shorter arrays leak destination elements through. backend is unaffected, so the saved config and actual backend functionality was right * only show audio debug tab when audio is enabled in config * move apple_compatibility out of advanced * remove retry_interval from UI 99% of users should never be changing this * hide switch in optionalfieldwidget if editing a profile * add override badges for cameras and profiles collect shared functions into the config util and separate hooks * Use new models endpoint info to determine modalities * clarify language * fix linter --------- Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
This commit is contained in:
co-authored by
Nicolas Mowen
parent
bd1fc1cc72
commit
ca75f06456
@@ -156,7 +156,7 @@ export function MessageBubble({
|
||||
<div
|
||||
className={cn(
|
||||
!isComplete &&
|
||||
"[&>p:last-child]:inline after:ml-0.5 after:inline-block after:h-4 after:w-2 after:animate-cursor-blink after:rounded-sm after:bg-foreground after:align-middle after:content-['']",
|
||||
"after:ml-0.5 after:inline-block after:h-4 after:w-2 after:animate-cursor-blink after:rounded-sm after:bg-foreground after:align-middle after:content-[''] [&>p:last-child]:inline",
|
||||
)}
|
||||
>
|
||||
<ReactMarkdown
|
||||
|
||||
@@ -41,19 +41,12 @@ const ffmpeg: SectionConfigOverrides = {
|
||||
"input_args",
|
||||
"hwaccel_args",
|
||||
"output_args",
|
||||
"path",
|
||||
"retry_interval",
|
||||
"apple_compatibility",
|
||||
"gpu",
|
||||
],
|
||||
hiddenFields: [],
|
||||
advancedFields: [
|
||||
"path",
|
||||
"global_args",
|
||||
"retry_interval",
|
||||
"apple_compatibility",
|
||||
"path",
|
||||
"gpu",
|
||||
],
|
||||
hiddenFields: ["retry_interval"],
|
||||
advancedFields: ["path", "global_args", "gpu"],
|
||||
overrideFields: [
|
||||
"inputs",
|
||||
"path",
|
||||
@@ -61,7 +54,6 @@ const ffmpeg: SectionConfigOverrides = {
|
||||
"input_args",
|
||||
"hwaccel_args",
|
||||
"output_args",
|
||||
"retry_interval",
|
||||
"apple_compatibility",
|
||||
"gpu",
|
||||
],
|
||||
@@ -125,19 +117,10 @@ const ffmpeg: SectionConfigOverrides = {
|
||||
"global_args",
|
||||
"input_args",
|
||||
"output_args",
|
||||
"retry_interval",
|
||||
"apple_compatibility",
|
||||
"gpu",
|
||||
],
|
||||
advancedFields: [
|
||||
"global_args",
|
||||
"input_args",
|
||||
"output_args",
|
||||
"path",
|
||||
"retry_interval",
|
||||
"apple_compatibility",
|
||||
"gpu",
|
||||
],
|
||||
advancedFields: ["global_args", "input_args", "output_args", "path", "gpu"],
|
||||
uiSchema: {
|
||||
path: {
|
||||
"ui:options": { size: "md" },
|
||||
|
||||
@@ -27,21 +27,17 @@ import {
|
||||
import { getSectionValidation } from "../section-validations";
|
||||
import { useConfigOverride } from "@/hooks/use-config-override";
|
||||
import { CameraOverridesBadge } from "./CameraOverridesBadge";
|
||||
import { GlobalOverridesBadge } from "./GlobalOverridesBadge";
|
||||
import { ProfileOverridesBadge } from "./ProfileOverridesBadge";
|
||||
import { useSectionSchema } from "@/hooks/use-config-schema";
|
||||
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LuChevronDown, LuChevronRight } from "react-icons/lu";
|
||||
import Heading from "@/components/ui/heading";
|
||||
import get from "lodash/get";
|
||||
import cloneDeep from "lodash/cloneDeep";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import merge from "lodash/merge";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
@@ -73,6 +69,7 @@ import {
|
||||
buildConfigDataForPath,
|
||||
flattenOverrides,
|
||||
getBaseCameraSectionValue,
|
||||
mergeProfileOverrides,
|
||||
resolveHiddenFieldEntries,
|
||||
sanitizeSectionData as sharedSanitizeSectionData,
|
||||
requiresRestartForOverrides as sharedRequiresRestartForOverrides,
|
||||
@@ -353,7 +350,10 @@ export function ConfigSection({
|
||||
`profiles.${profileName}.${sectionPath}`,
|
||||
);
|
||||
if (profileOverrides && typeof profileOverrides === "object") {
|
||||
return merge(cloneDeep(baseValue ?? {}), cloneDeep(profileOverrides));
|
||||
return mergeProfileOverrides(
|
||||
(baseValue as object) ?? {},
|
||||
profileOverrides as object,
|
||||
);
|
||||
}
|
||||
return baseValue;
|
||||
}
|
||||
@@ -1044,6 +1044,7 @@ export function ConfigSection({
|
||||
hiddenFields: effectiveHiddenFields,
|
||||
restartRequired: sectionConfig.restartRequired,
|
||||
requiresRestart,
|
||||
isProfile: !!profileName,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1253,33 +1254,22 @@ export function ConfigSection({
|
||||
<Heading as="h4">{title}</Heading>
|
||||
{showOverrideIndicator &&
|
||||
effectiveLevel === "camera" &&
|
||||
(profileOverridesSection || isOverridden) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{overrideSource === "profile"
|
||||
? t("button.overriddenBaseConfig", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Overridden (Base Config)",
|
||||
})
|
||||
: t("button.overriddenGlobal", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Overridden (Global)",
|
||||
})}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{overrideSource === "profile"
|
||||
? t("button.overriddenBaseConfigTooltip", {
|
||||
ns: "views/settings",
|
||||
profile: profileFriendlyName ?? profileName,
|
||||
})
|
||||
: t("button.overriddenGlobalTooltip", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
(profileOverridesSection || isOverridden) &&
|
||||
cameraName &&
|
||||
(overrideSource === "profile" && profileName ? (
|
||||
<ProfileOverridesBadge
|
||||
sectionPath={sectionPath}
|
||||
cameraName={cameraName}
|
||||
profileName={profileName}
|
||||
profileFriendlyName={profileFriendlyName}
|
||||
profileBorderColor={profileBorderColor}
|
||||
/>
|
||||
) : (
|
||||
<GlobalOverridesBadge
|
||||
sectionPath={sectionPath}
|
||||
cameraName={cameraName}
|
||||
/>
|
||||
))}
|
||||
{showOverrideIndicator && effectiveLevel === "global" && (
|
||||
<CameraOverridesBadge sectionPath={sectionPath} />
|
||||
)}
|
||||
@@ -1319,41 +1309,22 @@ export function ConfigSection({
|
||||
<Heading as="h4">{title}</Heading>
|
||||
{showOverrideIndicator &&
|
||||
effectiveLevel === "camera" &&
|
||||
(profileOverridesSection || isOverridden) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"cursor-default border-2 text-center text-xs text-primary-variant",
|
||||
overrideSource === "profile" && profileBorderColor
|
||||
? profileBorderColor
|
||||
: "border-selected",
|
||||
)}
|
||||
>
|
||||
{overrideSource === "profile"
|
||||
? t("button.overriddenBaseConfig", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Overridden (Base Config)",
|
||||
})
|
||||
: t("button.overriddenGlobal", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Overridden (Global)",
|
||||
})}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{overrideSource === "profile"
|
||||
? t("button.overriddenBaseConfigTooltip", {
|
||||
ns: "views/settings",
|
||||
profile: profileFriendlyName ?? profileName,
|
||||
})
|
||||
: t("button.overriddenGlobalTooltip", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
(profileOverridesSection || isOverridden) &&
|
||||
cameraName &&
|
||||
(overrideSource === "profile" && profileName ? (
|
||||
<ProfileOverridesBadge
|
||||
sectionPath={sectionPath}
|
||||
cameraName={cameraName}
|
||||
profileName={profileName}
|
||||
profileFriendlyName={profileFriendlyName}
|
||||
profileBorderColor={profileBorderColor}
|
||||
/>
|
||||
) : (
|
||||
<GlobalOverridesBadge
|
||||
sectionPath={sectionPath}
|
||||
cameraName={cameraName}
|
||||
/>
|
||||
))}
|
||||
{showOverrideIndicator && effectiveLevel === "global" && (
|
||||
<CameraOverridesBadge sectionPath={sectionPath} />
|
||||
)}
|
||||
|
||||
@@ -17,10 +17,13 @@ import {
|
||||
} from "@/hooks/use-config-override";
|
||||
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||
import type { ProfilesApiResponse } from "@/types/profile";
|
||||
import { humanizeKey } from "@/components/config-form/theme/utils/i18n";
|
||||
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
||||
import { formatList } from "@/utils/stringUtil";
|
||||
import { getEffectiveHiddenFields } from "@/utils/configUtil";
|
||||
import {
|
||||
getEffectiveHiddenFields,
|
||||
pathMatchesHiddenPattern,
|
||||
} from "@/utils/configUtil";
|
||||
import { useOverrideFieldLabel } from "./useOverrideFieldLabel";
|
||||
|
||||
const CAMERA_PAGE_BY_SECTION: Record<string, string> = {
|
||||
detect: "cameraDetect",
|
||||
@@ -72,26 +75,6 @@ const SECTIONS_WITHOUT_OVERRIDE_BADGE = new Set([
|
||||
"model",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Match a delta path against a hidden-field pattern. Supports literal prefixes
|
||||
* (so a hidden field "streams" also hides "streams.foo.bar") and `*` wildcards
|
||||
* matching exactly one path segment (e.g. "filters.*.mask").
|
||||
*/
|
||||
function pathMatchesHiddenPattern(path: string, pattern: string): boolean {
|
||||
if (!pattern) return false;
|
||||
if (!pattern.includes("*")) {
|
||||
return path === pattern || path.startsWith(`${pattern}.`);
|
||||
}
|
||||
const patternSegments = pattern.split(".");
|
||||
const pathSegments = path.split(".");
|
||||
if (pathSegments.length < patternSegments.length) return false;
|
||||
for (let i = 0; i < patternSegments.length; i += 1) {
|
||||
if (patternSegments[i] === "*") continue;
|
||||
if (patternSegments[i] !== pathSegments[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
type CameraEntryProps = {
|
||||
sectionPath: string;
|
||||
entry: CameraOverrideEntry;
|
||||
@@ -127,11 +110,8 @@ function groupDeltasBySource(deltas: FieldDelta[]): SourceGroup[] {
|
||||
}
|
||||
|
||||
function CameraEntry({ sectionPath, entry, cameraPage }: CameraEntryProps) {
|
||||
const { t, i18n } = useTranslation([
|
||||
"config/global",
|
||||
"views/settings",
|
||||
"objects",
|
||||
]);
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const fieldLabel = useOverrideFieldLabel(sectionPath);
|
||||
const friendlyName = useCameraFriendlyName(entry.camera);
|
||||
const { data: profilesData } = useSWR<ProfilesApiResponse>("profiles");
|
||||
|
||||
@@ -141,49 +121,6 @@ function CameraEntry({ sectionPath, entry, cameraPage }: CameraEntryProps) {
|
||||
return map;
|
||||
}, [profilesData]);
|
||||
|
||||
const fieldLabel = (fieldPath: string) => {
|
||||
if (!fieldPath) {
|
||||
const sectionKey = `${sectionPath}.label`;
|
||||
return i18n.exists(sectionKey, { ns: "config/global" })
|
||||
? t(sectionKey, { ns: "config/global" })
|
||||
: humanizeKey(sectionPath);
|
||||
}
|
||||
|
||||
const segments = fieldPath.split(".");
|
||||
|
||||
// Most specific: try the full nested path
|
||||
const fullKey = `${sectionPath}.${fieldPath}.label`;
|
||||
if (i18n.exists(fullKey, { ns: "config/global" })) {
|
||||
return t(fullKey, { ns: "config/global" });
|
||||
}
|
||||
|
||||
// Try dropping each intermediate segment in turn — those are typically
|
||||
// user-defined dict keys (object class names, zone names, etc.) that
|
||||
// don't have their own label entries. Prepend the dropped segment as
|
||||
// context to disambiguate (e.g. "Person · Minimum object area").
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const reduced = [...segments.slice(0, i), ...segments.slice(i + 1)].join(
|
||||
".",
|
||||
);
|
||||
if (!reduced) continue;
|
||||
const reducedKey = `${sectionPath}.${reduced}.label`;
|
||||
if (i18n.exists(reducedKey, { ns: "config/global" })) {
|
||||
const resolvedLabel = t(reducedKey, { ns: "config/global" });
|
||||
const dropped = segments[i];
|
||||
// Object class names ("person", "car", "fox") have translations in
|
||||
// the `objects` namespace; fall back to humanizing the raw key for
|
||||
// anything that isn't a known label.
|
||||
const droppedLabel = i18n.exists(dropped, { ns: "objects" })
|
||||
? t(dropped, { ns: "objects" })
|
||||
: humanizeKey(dropped);
|
||||
return `${droppedLabel} · ${resolvedLabel}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: humanize the leaf segment
|
||||
return humanizeKey(segments[segments.length - 1]);
|
||||
};
|
||||
|
||||
const formatDeltas = (deltas: FieldDelta[]) => {
|
||||
const visibleLabels = deltas
|
||||
.slice(0, MAX_FIELDS_PER_CAMERA)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import useSWR from "swr";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useCameraSectionDeltas } from "@/hooks/use-config-override";
|
||||
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { OverrideDeltaPopover } from "./OverrideDeltaPopover";
|
||||
|
||||
type Props = {
|
||||
sectionPath: string;
|
||||
cameraName: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function GlobalOverridesBadge({
|
||||
sectionPath,
|
||||
cameraName,
|
||||
className,
|
||||
}: Props) {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const deltas = useCameraSectionDeltas(config, cameraName, sectionPath);
|
||||
|
||||
return (
|
||||
<OverrideDeltaPopover
|
||||
sectionPath={sectionPath}
|
||||
deltas={deltas}
|
||||
badgeLabel={t("button.overriddenGlobal", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Overridden (Global)",
|
||||
})}
|
||||
ariaLabel={t("button.overriddenGlobalTooltip", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
heading={t("button.overriddenGlobalHeading", {
|
||||
ns: "views/settings",
|
||||
count: deltas.length,
|
||||
})}
|
||||
noDeltasMessage={t("button.overriddenGlobalNoDeltas", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { LuChevronDown } from "react-icons/lu";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import type { FieldDelta } from "@/hooks/use-config-override";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useOverrideFieldLabel } from "./useOverrideFieldLabel";
|
||||
|
||||
type Props = {
|
||||
sectionPath: string;
|
||||
deltas: FieldDelta[];
|
||||
/** Translated label shown inside the badge */
|
||||
badgeLabel: string;
|
||||
/** Accessible label for the badge trigger */
|
||||
ariaLabel: string;
|
||||
/** Heading rendered at the top of the popover content */
|
||||
heading: string;
|
||||
/** Message shown when there are zero field deltas */
|
||||
noDeltasMessage: string;
|
||||
/** Border color class for the badge (defaults to selected) */
|
||||
borderColorClass?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared popover layout for "this scope overrides these fields" badges
|
||||
* (e.g. profile overrides base config, camera overrides global config).
|
||||
*/
|
||||
export function OverrideDeltaPopover({
|
||||
sectionPath,
|
||||
deltas,
|
||||
badgeLabel,
|
||||
ariaLabel,
|
||||
heading,
|
||||
noDeltasMessage,
|
||||
borderColorClass,
|
||||
className,
|
||||
}: Props) {
|
||||
const fieldLabel = useOverrideFieldLabel(sectionPath);
|
||||
const count = deltas.length;
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"cursor-pointer border-2 text-center text-xs text-primary-variant",
|
||||
borderColorClass ?? "border-selected",
|
||||
className,
|
||||
)}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<span>{badgeLabel}</span>
|
||||
<LuChevronDown className="ml-1 size-3" />
|
||||
</Badge>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-80 max-w-[90vw] pr-0">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="pr-4 text-xs text-primary-variant">
|
||||
{count > 0 ? heading : noDeltasMessage}
|
||||
</div>
|
||||
{count > 0 && (
|
||||
<ul className="scrollbar-container ml-5 flex max-h-[40dvh] list-disc flex-col gap-1 overflow-y-auto pr-4 text-xs">
|
||||
{deltas.map((delta) => (
|
||||
<li key={delta.fieldPath}>{fieldLabel(delta.fieldPath)}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import useSWR from "swr";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useProfileSectionDeltas } from "@/hooks/use-config-override";
|
||||
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { OverrideDeltaPopover } from "./OverrideDeltaPopover";
|
||||
|
||||
type Props = {
|
||||
sectionPath: string;
|
||||
cameraName: string;
|
||||
profileName: string;
|
||||
profileFriendlyName?: string;
|
||||
/** Border color class for profile-themed badge (e.g., "border-amber-500") */
|
||||
profileBorderColor?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function ProfileOverridesBadge({
|
||||
sectionPath,
|
||||
cameraName,
|
||||
profileName,
|
||||
profileFriendlyName,
|
||||
profileBorderColor,
|
||||
className,
|
||||
}: Props) {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const deltas = useProfileSectionDeltas(
|
||||
config,
|
||||
cameraName,
|
||||
profileName,
|
||||
sectionPath,
|
||||
);
|
||||
|
||||
const displayProfile = profileFriendlyName ?? profileName;
|
||||
|
||||
return (
|
||||
<OverrideDeltaPopover
|
||||
sectionPath={sectionPath}
|
||||
deltas={deltas}
|
||||
badgeLabel={t("button.overriddenBaseConfig", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Overridden (Base Config)",
|
||||
})}
|
||||
ariaLabel={t("button.overriddenBaseConfigTooltip", {
|
||||
ns: "views/settings",
|
||||
profile: displayProfile,
|
||||
})}
|
||||
heading={t("button.overriddenBaseConfigHeading", {
|
||||
ns: "views/settings",
|
||||
profile: displayProfile,
|
||||
count: deltas.length,
|
||||
})}
|
||||
noDeltasMessage={t("button.overriddenBaseConfigNoDeltas", {
|
||||
ns: "views/settings",
|
||||
profile: displayProfile,
|
||||
})}
|
||||
borderColorClass={profileBorderColor}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { humanizeKey } from "@/components/config-form/theme/utils/i18n";
|
||||
|
||||
/**
|
||||
* Resolve a translated label for a config field path within a section, falling
|
||||
* back through reduced paths (dropping each intermediate segment in turn) so
|
||||
* dict-keyed paths like `filters.person.threshold` still surface a meaningful
|
||||
* label. Dropped segments are prepended as context (e.g. "Person · Threshold").
|
||||
*
|
||||
* Shared between override badges that need to render field labels (e.g.
|
||||
* CameraOverridesBadge, ProfileOverridesBadge).
|
||||
*/
|
||||
export function useOverrideFieldLabel(sectionPath: string) {
|
||||
const { t, i18n } = useTranslation([
|
||||
"config/global",
|
||||
"views/settings",
|
||||
"objects",
|
||||
]);
|
||||
|
||||
return (fieldPath: string): string => {
|
||||
if (!fieldPath) {
|
||||
const sectionKey = `${sectionPath}.label`;
|
||||
return i18n.exists(sectionKey, { ns: "config/global" })
|
||||
? t(sectionKey, { ns: "config/global" })
|
||||
: humanizeKey(sectionPath);
|
||||
}
|
||||
|
||||
const segments = fieldPath.split(".");
|
||||
|
||||
const fullKey = `${sectionPath}.${fieldPath}.label`;
|
||||
if (i18n.exists(fullKey, { ns: "config/global" })) {
|
||||
return t(fullKey, { ns: "config/global" });
|
||||
}
|
||||
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const reduced = [...segments.slice(0, i), ...segments.slice(i + 1)].join(
|
||||
".",
|
||||
);
|
||||
if (!reduced) continue;
|
||||
const reducedKey = `${sectionPath}.${reduced}.label`;
|
||||
if (i18n.exists(reducedKey, { ns: "config/global" })) {
|
||||
const resolvedLabel = t(reducedKey, { ns: "config/global" });
|
||||
const dropped = segments[i];
|
||||
const droppedLabel = i18n.exists(dropped, { ns: "objects" })
|
||||
? t(dropped, { ns: "objects" })
|
||||
: humanizeKey(dropped);
|
||||
return `${droppedLabel} · ${resolvedLabel}`;
|
||||
}
|
||||
}
|
||||
|
||||
return humanizeKey(segments[segments.length - 1]);
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { getWidget } from "@rjsf/utils";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getNonNullSchema } from "../fields/nullableUtils";
|
||||
import type { ConfigFormContext } from "@/types/configForm";
|
||||
|
||||
export function OptionalFieldWidget(props: WidgetProps) {
|
||||
const { id, value, disabled, readonly, onChange, schema, options, registry } =
|
||||
@@ -13,6 +14,8 @@ export function OptionalFieldWidget(props: WidgetProps) {
|
||||
|
||||
const innerWidgetName = (options.innerWidget as string) || undefined;
|
||||
const isEnabled = value !== undefined && value !== null;
|
||||
const formContext = registry?.formContext as ConfigFormContext | undefined;
|
||||
const isProfile = !!formContext?.isProfile;
|
||||
|
||||
// Extract the non-null branch from anyOf [Type, null]
|
||||
const innerSchema = getNonNullSchema(schema) ?? schema;
|
||||
@@ -42,10 +45,17 @@ export function OptionalFieldWidget(props: WidgetProps) {
|
||||
const innerProps: WidgetProps = {
|
||||
...props,
|
||||
schema: innerSchema,
|
||||
disabled: disabled || readonly || !isEnabled,
|
||||
disabled: disabled || readonly || (!isProfile && !isEnabled),
|
||||
value: isEnabled ? value : getDefaultValue(),
|
||||
};
|
||||
|
||||
// don't show the switch if we're editing in a profile
|
||||
// to disable in a profile, users should edit the config manually, eg:
|
||||
// skip_motion_threshold: None
|
||||
if (isProfile) {
|
||||
return <InnerWidget {...innerProps} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
|
||||
@@ -102,6 +102,19 @@ export default function ClassificationSelectionDialog({
|
||||
// control
|
||||
const [newClass, setNewClass] = useState(false);
|
||||
|
||||
// Non-modal Radix DropdownMenu doesn't propagate wheel events to nested
|
||||
// scroll containers, so attach a non-passive listener that scrolls manually.
|
||||
const scrollContainerRef = useCallback((el: HTMLDivElement | null) => {
|
||||
if (!el || !isDesktop) return;
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
if (el.scrollHeight <= el.clientHeight) return;
|
||||
e.preventDefault();
|
||||
el.scrollTop += e.deltaY;
|
||||
};
|
||||
el.addEventListener("wheel", handleWheel, { passive: false });
|
||||
return () => el.removeEventListener("wheel", handleWheel);
|
||||
}, []);
|
||||
|
||||
// components
|
||||
const Selector = isDesktop ? DropdownMenu : Drawer;
|
||||
const SelectorTrigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger;
|
||||
@@ -114,6 +127,8 @@ export default function ClassificationSelectionDialog({
|
||||
</DrawerClose>
|
||||
);
|
||||
|
||||
// keep modal false on desktop to prevent dismissable layer pointer events
|
||||
// issue with dialog auto-close
|
||||
return (
|
||||
<div className={className ?? "flex"}>
|
||||
<TextEntryDialog
|
||||
@@ -122,60 +137,60 @@ export default function ClassificationSelectionDialog({
|
||||
title={t("createCategory.new")}
|
||||
onSave={(newCat) => onCategorizeImage(newCat)}
|
||||
/>
|
||||
|
||||
<Tooltip>
|
||||
<Selector {...(isDesktop ? { modal: false } : {})}>
|
||||
<SelectorTrigger asChild>
|
||||
<TooltipTrigger asChild={isChildButton}>{children}</TooltipTrigger>
|
||||
</SelectorTrigger>
|
||||
<SelectorContent
|
||||
className={cn("", isMobile && "mx-1 gap-2 rounded-t-2xl px-4")}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{isMobile && (
|
||||
<DrawerHeader className="sr-only">
|
||||
<DrawerTitle>Details</DrawerTitle>
|
||||
<DrawerDescription>Details</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
)}
|
||||
<DropdownMenuLabel>
|
||||
{dialogLabel ?? t("categorizeImageAs")}
|
||||
</DropdownMenuLabel>
|
||||
<div
|
||||
className={cn(
|
||||
"flex max-h-[40dvh] flex-col overflow-y-auto",
|
||||
isMobile && "gap-2 pb-4",
|
||||
)}
|
||||
<Selector {...(isDesktop ? { modal: false } : {})}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={isChildButton}>
|
||||
<SelectorTrigger asChild>{children}</SelectorTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{tooltipLabel ?? t("categorizeImage")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<SelectorContent
|
||||
ref={scrollContainerRef}
|
||||
className={cn(
|
||||
isDesktop && "scrollbar-container max-h-[40dvh] overflow-y-auto",
|
||||
isMobile && "mx-1 gap-2 rounded-t-2xl px-4",
|
||||
)}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{isMobile && (
|
||||
<DrawerHeader className="sr-only">
|
||||
<DrawerTitle>Details</DrawerTitle>
|
||||
<DrawerDescription>Details</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
)}
|
||||
<DropdownMenuLabel>
|
||||
{dialogLabel ?? t("categorizeImageAs")}
|
||||
</DropdownMenuLabel>
|
||||
<div className={cn("flex flex-col", isMobile && "gap-2 pb-4")}>
|
||||
{filteredClasses
|
||||
.sort((a, b) => {
|
||||
if (a === "none") return 1;
|
||||
if (b === "none") return -1;
|
||||
return a.localeCompare(b);
|
||||
})
|
||||
.map((category) => (
|
||||
<SelectorItem
|
||||
key={category}
|
||||
className="flex cursor-pointer gap-2 smart-capitalize"
|
||||
onClick={() => onCategorizeImage(category)}
|
||||
>
|
||||
{category === "none"
|
||||
? t("details.none")
|
||||
: category.replaceAll("_", " ")}
|
||||
</SelectorItem>
|
||||
))}
|
||||
<Separator />
|
||||
<SelectorItem
|
||||
className="flex cursor-pointer gap-2 smart-capitalize"
|
||||
onClick={() => setNewClass(true)}
|
||||
>
|
||||
{filteredClasses
|
||||
.sort((a, b) => {
|
||||
if (a === "none") return 1;
|
||||
if (b === "none") return -1;
|
||||
return a.localeCompare(b);
|
||||
})
|
||||
.map((category) => (
|
||||
<SelectorItem
|
||||
key={category}
|
||||
className="flex cursor-pointer gap-2 smart-capitalize"
|
||||
onClick={() => onCategorizeImage(category)}
|
||||
>
|
||||
{category === "none"
|
||||
? t("details.none")
|
||||
: category.replaceAll("_", " ")}
|
||||
</SelectorItem>
|
||||
))}
|
||||
<Separator />
|
||||
<SelectorItem
|
||||
className="flex cursor-pointer gap-2 smart-capitalize"
|
||||
onClick={() => setNewClass(true)}
|
||||
>
|
||||
{t("createCategory.new")}
|
||||
</SelectorItem>
|
||||
</div>
|
||||
</SelectorContent>
|
||||
</Selector>
|
||||
<TooltipContent>{tooltipLabel ?? t("categorizeImage")}</TooltipContent>
|
||||
</Tooltip>
|
||||
{t("createCategory.new")}
|
||||
</SelectorItem>
|
||||
</div>
|
||||
</SelectorContent>
|
||||
</Selector>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
import { isDesktop, isMobile } from "react-device-detect";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
import React, { ReactNode, useMemo, useState } from "react";
|
||||
import React, { ReactNode, useCallback, useMemo, useState } from "react";
|
||||
import TextEntryDialog from "./dialog/TextEntryDialog";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
@@ -61,6 +61,19 @@ export default function FaceSelectionDialog({
|
||||
// control
|
||||
const [newFace, setNewFace] = useState(false);
|
||||
|
||||
// Non-modal Radix DropdownMenu doesn't propagate wheel events to nested
|
||||
// scroll containers, so attach a non-passive listener that scrolls manually.
|
||||
const scrollContainerRef = useCallback((el: HTMLDivElement | null) => {
|
||||
if (!el || !isDesktop) return;
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
if (el.scrollHeight <= el.clientHeight) return;
|
||||
e.preventDefault();
|
||||
el.scrollTop += e.deltaY;
|
||||
};
|
||||
el.addEventListener("wheel", handleWheel, { passive: false });
|
||||
return () => el.removeEventListener("wheel", handleWheel);
|
||||
}, []);
|
||||
|
||||
// components
|
||||
const Selector = isDesktop ? DropdownMenu : Drawer;
|
||||
const SelectorTrigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger;
|
||||
@@ -73,6 +86,8 @@ export default function FaceSelectionDialog({
|
||||
</DrawerClose>
|
||||
);
|
||||
|
||||
// keep modal false on desktop to prevent dismissable layer pointer events
|
||||
// issue with dialog auto-close
|
||||
return (
|
||||
<div className={className ?? "flex"}>
|
||||
{newFace && (
|
||||
@@ -83,52 +98,56 @@ export default function FaceSelectionDialog({
|
||||
onSave={(newName) => onTrainAttempt(newName)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
<Selector {...(isDesktop ? { modal: false } : {})}>
|
||||
<SelectorTrigger asChild>
|
||||
<TooltipTrigger asChild={isChildButton}>{children}</TooltipTrigger>
|
||||
</SelectorTrigger>
|
||||
<SelectorContent
|
||||
className={cn("", isMobile && "mx-1 gap-2 rounded-t-2xl px-4")}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{isMobile && (
|
||||
<DrawerHeader className="sr-only">
|
||||
<DrawerTitle>Details</DrawerTitle>
|
||||
<DrawerDescription>Details</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<Selector {...(isDesktop ? { modal: false } : {})}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={isChildButton}>
|
||||
<SelectorTrigger asChild>{children}</SelectorTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{tooltipLabel ?? t("trainFace")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<SelectorContent
|
||||
ref={scrollContainerRef}
|
||||
className={cn(
|
||||
isDesktop && "scrollbar-container max-h-[40dvh] overflow-y-auto",
|
||||
isMobile && "mx-1 gap-2 rounded-t-2xl px-4",
|
||||
)}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{isMobile && (
|
||||
<DrawerHeader className="sr-only">
|
||||
<DrawerTitle>Details</DrawerTitle>
|
||||
<DrawerDescription>Details</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
)}
|
||||
<DropdownMenuLabel>
|
||||
{dialogLabel ?? t("trainFaceAs")}
|
||||
</DropdownMenuLabel>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col",
|
||||
isMobile &&
|
||||
"max-h-[40dvh] gap-2 overflow-y-auto overflow-x-hidden pb-4",
|
||||
)}
|
||||
<DropdownMenuLabel>
|
||||
{dialogLabel ?? t("trainFaceAs")}
|
||||
</DropdownMenuLabel>
|
||||
<div
|
||||
className={cn(
|
||||
"flex max-h-[40dvh] flex-col overflow-y-auto overflow-x-hidden",
|
||||
isMobile && "gap-2 pb-4",
|
||||
)}
|
||||
>
|
||||
{filteredNames.sort().map((faceName) => (
|
||||
<SelectorItem
|
||||
key={faceName}
|
||||
className="flex cursor-pointer gap-2 smart-capitalize"
|
||||
onClick={() => onTrainAttempt(faceName)}
|
||||
>
|
||||
{faceName}
|
||||
</SelectorItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
>
|
||||
{filteredNames.sort().map((faceName) => (
|
||||
<SelectorItem
|
||||
key={faceName}
|
||||
className="flex cursor-pointer gap-2 smart-capitalize"
|
||||
onClick={() => setNewFace(true)}
|
||||
onClick={() => onTrainAttempt(faceName)}
|
||||
>
|
||||
{t("createFaceLibrary.new")}
|
||||
{faceName}
|
||||
</SelectorItem>
|
||||
</div>
|
||||
</SelectorContent>
|
||||
</Selector>
|
||||
<TooltipContent>{tooltipLabel ?? t("trainFace")}</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<SelectorItem
|
||||
className="flex cursor-pointer gap-2 smart-capitalize"
|
||||
onClick={() => setNewFace(true)}
|
||||
>
|
||||
{t("createFaceLibrary.new")}
|
||||
</SelectorItem>
|
||||
</div>
|
||||
</SelectorContent>
|
||||
</Selector>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user