Merge branch 'blakeblackshear:dev' into dev

This commit is contained in:
Dmytro Marchuk
2026-05-17 08:50:35 +03:00
committed by GitHub
745 changed files with 32109 additions and 4377 deletions
@@ -22,7 +22,7 @@ export default function ActionsDropdown({
const { t } = useTranslation(["components/dialog", "views/replay", "common"]);
return (
<DropdownMenu modal={false}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className="flex items-center gap-2"
@@ -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>
<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>
);
}
@@ -127,7 +127,7 @@ export function CustomTimeSelector({
{formattedStart}
</Button>
</PopoverTrigger>
<PopoverContent className="flex flex-col items-center" disablePortal>
<PopoverContent className="flex flex-col items-center">
<TimezoneAwareCalendar
timezone={config?.ui.timezone}
selectedDay={new Date(startTime * 1000)}
@@ -193,7 +193,7 @@ export function CustomTimeSelector({
{formattedEnd}
</Button>
</PopoverTrigger>
<PopoverContent className="flex flex-col items-center" disablePortal>
<PopoverContent className="flex flex-col items-center">
<TimezoneAwareCalendar
timezone={config?.ui.timezone}
selectedDay={new Date(endTime * 1000)}
@@ -209,10 +209,7 @@ export default function DebugReplayDialog({
end_time: range.before,
})
.then((response) => {
if (response.status === 200) {
toast.success(t("dialog.toast.success"), {
position: "top-center",
});
if (response.status === 202 || response.status === 200) {
setMode("none");
setRange(undefined);
navigate("/replay");
@@ -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>
<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>
);
}
@@ -262,10 +262,7 @@ export default function MobileReviewSettingsDrawer({
end_time: debugReplayRange.before,
});
if (response.status === 200) {
toast.success(t("dialog.toast.success", { ns: "views/replay" }), {
position: "top-center",
});
if (response.status === 202 || response.status === 200) {
setDebugReplayMode("none");
setDebugReplayRange(undefined);
setDrawerMode("none");
@@ -391,10 +388,8 @@ export default function MobileReviewSettingsDrawer({
className="flex w-full items-center justify-center gap-2"
aria-label={t("title", { ns: "views/replay" })}
onClick={() => {
const now = new Date(latestTime * 1000);
now.setHours(now.getHours() - 1);
setDebugReplayRange({
after: now.getTime() / 1000,
after: latestTime - 60,
before: latestTime,
});
setSelectedReplayOption("1");
@@ -541,11 +536,9 @@ export default function MobileReviewSettingsDrawer({
return;
}
const hours = parseInt(option);
const minutes = parseInt(option, 10);
const end = latestTime;
const now = new Date(end * 1000);
now.setHours(now.getHours() - hours);
setDebugReplayRange({ after: now.getTime() / 1000, before: end });
setDebugReplayRange({ after: end - minutes * 60, before: end });
};
content = (
@@ -178,6 +178,7 @@ export default function MultiExportDialog({
start_time: review.start_time - REVIEW_PADDING,
end_time: (review.end_time ?? Date.now() / 1000) + REVIEW_PADDING,
image_path: review.thumb_path || undefined,
friendly_name: review.data.metadata?.title?.trim() || undefined,
client_item_id: review.id,
}));
@@ -14,7 +14,7 @@ import {
import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { CameraPtzInfo } from "@/types/ptz";
import React, { useCallback } from "react";
import { isDesktop, isMobile } from "react-device-detect";
import { isMobile } from "react-device-detect";
import { BsThreeDotsVertical } from "react-icons/bs";
import {
FaAngleDown,
@@ -292,7 +292,7 @@ export default function PtzControlPanel({
</Tooltip>
)}
{(ptz?.presets?.length ?? 0) > 0 && (
<DropdownMenu modal={!isDesktop}>
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
@@ -1,4 +1,6 @@
import { useCallback, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { flushSync } from "react-dom";
import { throttle } from "lodash";
import { Slider } from "@/components/ui/slider";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "../../ui/popover";
@@ -19,11 +21,21 @@ import { useIsAdmin } from "@/hooks/use-is-admin";
import { useDocDomain } from "@/hooks/use-doc-domain";
import { Link } from "react-router-dom";
const SLIDER_DRAG_THROTTLE_MS = 80;
type Props = {
className?: string;
// Optional side-effect invoked atomically with setAnnotationOffset (inside
// flushSync) so callers like the timeline panel can re-seek the video in the
// same React commit as the offset state update — preventing a one-frame
// overlay mismatch where annotationOffset has changed but currentTime has not.
onApplyOffset?: (newOffset: number) => void;
};
export default function AnnotationOffsetSlider({ className }: Props) {
export default function AnnotationOffsetSlider({
className,
onApplyOffset,
}: Props) {
const { annotationOffset, setAnnotationOffset, camera } = useDetailStream();
const isAdmin = useIsAdmin();
const { getLocaleDocUrl } = useDocDomain();
@@ -31,31 +43,62 @@ export default function AnnotationOffsetSlider({ className }: Props) {
const { t } = useTranslation(["views/explore"]);
const [isSaving, setIsSaving] = useState(false);
const applyOffset = useCallback(
(newOffset: number) => {
flushSync(() => {
setAnnotationOffset(newOffset);
onApplyOffset?.(newOffset);
});
},
[setAnnotationOffset, onApplyOffset],
);
const throttledApplyOffset = useMemo(
() =>
throttle(applyOffset, SLIDER_DRAG_THROTTLE_MS, {
leading: true,
trailing: true,
}),
[applyOffset],
);
useEffect(() => () => throttledApplyOffset.cancel(), [throttledApplyOffset]);
const handleChange = useCallback(
(values: number[]) => {
if (!values || values.length === 0) return;
const valueMs = values[0];
setAnnotationOffset(valueMs);
throttledApplyOffset(values[0]);
},
[setAnnotationOffset],
[throttledApplyOffset],
);
const handleCommit = useCallback(
(values: number[]) => {
if (!values || values.length === 0) return;
// Ensure the final value lands even if it would otherwise be discarded
// by the trailing edge of the throttle window.
throttledApplyOffset.cancel();
applyOffset(values[0]);
},
[throttledApplyOffset, applyOffset],
);
const stepOffset = useCallback(
(delta: number) => {
setAnnotationOffset((prev) => {
const next = prev + delta;
return Math.max(
ANNOTATION_OFFSET_MIN,
Math.min(ANNOTATION_OFFSET_MAX, next),
);
});
const next = Math.max(
ANNOTATION_OFFSET_MIN,
Math.min(ANNOTATION_OFFSET_MAX, annotationOffset + delta),
);
throttledApplyOffset.cancel();
applyOffset(next);
},
[setAnnotationOffset],
[annotationOffset, applyOffset, throttledApplyOffset],
);
const reset = useCallback(() => {
setAnnotationOffset(0);
}, [setAnnotationOffset]);
throttledApplyOffset.cancel();
applyOffset(0);
}, [applyOffset, throttledApplyOffset]);
const save = useCallback(async () => {
setIsSaving(true);
@@ -130,6 +173,7 @@ export default function AnnotationOffsetSlider({ className }: Props) {
max={ANNOTATION_OFFSET_MAX}
step={ANNOTATION_OFFSET_STEP}
onValueChange={handleChange}
onValueCommit={handleCommit}
/>
</div>
<Button
@@ -1,7 +1,9 @@
import { Event } from "@/types/event";
import { FrigateConfig } from "@/types/frigateConfig";
import axios from "axios";
import { useCallback, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { flushSync } from "react-dom";
import { throttle } from "lodash";
import { LuExternalLink, LuMinus, LuPlus } from "react-icons/lu";
import { Link } from "react-router-dom";
import { toast } from "sonner";
@@ -19,6 +21,8 @@ import {
ANNOTATION_OFFSET_STEP,
} from "@/lib/const";
const SLIDER_DRAG_THROTTLE_MS = 80;
type AnnotationSettingsPaneProps = {
event: Event;
annotationOffset: number;
@@ -38,30 +42,64 @@ export function AnnotationSettingsPane({
const [isLoading, setIsLoading] = useState(false);
const handleSliderChange = useCallback(
(values: number[]) => {
if (!values || values.length === 0) return;
setAnnotationOffset(values[0]);
},
[setAnnotationOffset],
);
const stepOffset = useCallback(
(delta: number) => {
setAnnotationOffset((prev) => {
const next = prev + delta;
return Math.max(
ANNOTATION_OFFSET_MIN,
Math.min(ANNOTATION_OFFSET_MAX, next),
);
// flushSync ensures setAnnotationOffset commits synchronously so the
// useLayoutEffect in TrackingDetails (which seeks the video and sets
// currentTime in response) runs before the browser paints — preventing a
// one-frame overlay mismatch where annotationOffset has changed but
// currentTime has not.
const applyOffset = useCallback(
(newOffset: number) => {
flushSync(() => {
setAnnotationOffset(newOffset);
});
},
[setAnnotationOffset],
);
const throttledApplyOffset = useMemo(
() =>
throttle(applyOffset, SLIDER_DRAG_THROTTLE_MS, {
leading: true,
trailing: true,
}),
[applyOffset],
);
useEffect(() => () => throttledApplyOffset.cancel(), [throttledApplyOffset]);
const handleSliderChange = useCallback(
(values: number[]) => {
if (!values || values.length === 0) return;
throttledApplyOffset(values[0]);
},
[throttledApplyOffset],
);
const handleSliderCommit = useCallback(
(values: number[]) => {
if (!values || values.length === 0) return;
throttledApplyOffset.cancel();
applyOffset(values[0]);
},
[throttledApplyOffset, applyOffset],
);
const stepOffset = useCallback(
(delta: number) => {
const next = Math.max(
ANNOTATION_OFFSET_MIN,
Math.min(ANNOTATION_OFFSET_MAX, annotationOffset + delta),
);
throttledApplyOffset.cancel();
applyOffset(next);
},
[annotationOffset, applyOffset, throttledApplyOffset],
);
const reset = useCallback(() => {
setAnnotationOffset(0);
}, [setAnnotationOffset]);
throttledApplyOffset.cancel();
applyOffset(0);
}, [applyOffset, throttledApplyOffset]);
const saveToConfig = useCallback(async () => {
if (!config || !event) return;
@@ -143,6 +181,7 @@ export function AnnotationSettingsPane({
max={ANNOTATION_OFFSET_MAX}
step={ANNOTATION_OFFSET_STEP}
onValueChange={handleSliderChange}
onValueCommit={handleSliderCommit}
className="flex-1"
/>
<Button
@@ -73,7 +73,7 @@ export default function DetailActionsMenu({
}
return (
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenu modal={false} open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuTrigger>
<div className="rounded" role="button">
<HiDotsHorizontal className="size-4 text-muted-foreground" />
@@ -241,7 +241,7 @@ function AnnotationSettings({
return (
<div className="ml-2">
<Overlay modal={isDesktop} open={open} onOpenChange={handleOpenChange}>
<Overlay open={open} onOpenChange={handleOpenChange}>
<Trigger asChild>
<Button
type="button"
@@ -269,7 +269,6 @@ function AnnotationSettings({
: "mx-1 max-h-[75dvh] overflow-hidden rounded-t-2xl px-4 pb-4"
}
{...contentProps}
{...(isDesktop ? { disablePortal: true } : {})}
data-annotation-popover
>
<AnnotationSettingsPane
@@ -958,8 +957,9 @@ function ObjectDetailsTab({
toast.success(
t("details.item.toast.success.regenerate", {
provider: capitalizeAll(
config?.genai.provider.replaceAll("_", " ") ??
t("generativeAI"),
Object.values(config?.genai ?? {})
.find((agent) => agent?.roles?.includes("descriptions"))
?.provider?.replaceAll("_", " ") ?? t("generativeAI"),
),
}),
{
@@ -977,8 +977,9 @@ function ObjectDetailsTab({
toast.error(
t("details.item.toast.error.regenerate", {
provider: capitalizeAll(
config?.genai.provider.replaceAll("_", " ") ??
t("generativeAI"),
Object.values(config?.genai ?? {})
.find((agent) => agent?.roles?.includes("descriptions"))
?.provider?.replaceAll("_", " ") ?? t("generativeAI"),
),
errorMessage,
}),
@@ -1,5 +1,13 @@
import useSWR from "swr";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { flushSync } from "react-dom";
import { useResizeObserver } from "@/hooks/resize-observer";
import { useFullscreen } from "@/hooks/use-fullscreen";
import { Event } from "@/types/event";
@@ -389,7 +397,12 @@ export function TrackingDetails({
// When the pinned timestamp or offset changes, re-seek the video and
// explicitly update currentTime so the overlay shows the pinned event's box.
useEffect(() => {
// useLayoutEffect + flushSync force the setCurrentTime commit to land before
// the browser paints, so the overlay never shows a frame where
// annotationOffset has changed but currentTime has not — that mismatch would
// resolve effectiveCurrentTime away from the pinned detect timestamp and
// make the bounding box disappear or jump for one frame.
useLayoutEffect(() => {
const pinned = pinnedDetectTimestampRef.current;
if (!isAnnotationSettingsOpen || pinned == null) return;
if (!videoRef.current || displaySource !== "video") return;
@@ -398,10 +411,9 @@ export function TrackingDetails({
const relativeTime = timestampToVideoTime(targetTimeRecord);
videoRef.current.currentTime = relativeTime;
// Explicitly update currentTime state so the overlay's effectiveCurrentTime
// resolves back to the pinned detect timestamp:
// effectiveCurrentTime = targetTimeRecord - annotationOffset/1000 = pinned
setCurrentTime(targetTimeRecord);
flushSync(() => {
setCurrentTime(targetTimeRecord);
});
}, [
isAnnotationSettingsOpen,
annotationOffset,
@@ -1204,7 +1216,11 @@ function LifecycleIconRow({
<div className="flex flex-row items-center gap-3">
<div className="whitespace-nowrap">{formattedEventTimestamp}</div>
{isAdmin && (config?.plus?.enabled || item.data.box) && (
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenu
modal={false}
open={isOpen}
onOpenChange={setIsOpen}
>
<DropdownMenuTrigger>
<div className="rounded p-1 pr-2" role="button">
<HiDotsHorizontal className="size-4 text-muted-foreground" />
@@ -38,12 +38,6 @@ export default function RestartDialog({
const [restartingSheetOpen, setRestartingSheetOpen] = useState(false);
const [countdown, setCountdown] = useState(60);
const clearBodyPointerEvents = () => {
if (typeof document !== "undefined") {
document.body.style.pointerEvents = "";
}
};
useEffect(() => {
setRestartDialogOpen(isOpen);
}, [isOpen]);
@@ -85,16 +79,10 @@ export default function RestartDialog({
if (!open) {
setRestartDialogOpen(false);
onClose();
clearBodyPointerEvents();
}
}}
>
<AlertDialogContent
onCloseAutoFocus={(event) => {
event.preventDefault();
clearBodyPointerEvents();
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("restart.title")}</AlertDialogTitle>
<AlertDialogDescription className="sr-only">
@@ -1,3 +1,4 @@
import ActivityIndicator from "@/components/indicators/activity-indicator";
import TextEntry from "@/components/input/TextEntry";
import { Button } from "@/components/ui/button";
import {
@@ -19,7 +20,9 @@ type TextEntryDialogProps = {
setOpen: (open: boolean) => void;
onSave: (text: string) => void;
defaultValue?: string;
placeholder?: string;
allowEmpty?: boolean;
isSaving?: boolean;
regexPattern?: RegExp;
regexErrorMessage?: string;
forbiddenPattern?: RegExp;
@@ -33,7 +36,9 @@ export default function TextEntryDialog({
setOpen,
onSave,
defaultValue = "",
placeholder,
allowEmpty = false,
isSaving = false,
regexPattern,
regexErrorMessage,
forbiddenPattern,
@@ -50,6 +55,7 @@ export default function TextEntryDialog({
</DialogHeader>
<TextEntry
defaultValue={defaultValue}
placeholder={placeholder}
allowEmpty={allowEmpty}
onSave={onSave}
regexPattern={regexPattern}
@@ -58,11 +64,22 @@ export default function TextEntryDialog({
forbiddenErrorMessage={forbiddenErrorMessage}
>
<DialogFooter className={cn("pt-4", isMobile && "gap-2")}>
<Button type="button" onClick={() => setOpen(false)}>
<Button
type="button"
disabled={isSaving}
onClick={() => setOpen(false)}
>
{t("button.cancel")}
</Button>
<Button variant="select" type="submit">
{t("button.save")}
<Button variant="select" type="submit" disabled={isSaving}>
{isSaving ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator className="size-4" />
<span>{t("button.saving")}</span>
</div>
) : (
t("button.save")
)}
</Button>
</DialogFooter>
</TextEntry>