consolidate probe dialog

This commit is contained in:
Josh Hawkins 2025-11-10 12:33:07 -06:00
parent 5c41107fcd
commit c54ada65dc
4 changed files with 386 additions and 401 deletions

View File

@ -217,6 +217,7 @@
"presets": "Presets",
"deviceInfo": "Device Information",
"rtspCandidates": "RTSP Candidates",
"noRtspCandidates": "No RTSP URLs were found from the camera. Your credentials may be incorrect, or the camera may not support ONVIF or the method used to retrieve RTSP URLs. Go back and enter the RTSP URL manually.",
"candidateStreamTitle": "Candidate {{number}}",
"useCandidate": "Use",
"uriCopy": "Copy",
@ -265,6 +266,7 @@
"autotrackingSupport": "Autotracking Support",
"presets": "Presets",
"rtspCandidates": "RTSP Candidates",
"rtspCandidatesDescription": "The following RTSP URLs were found from the camera probe. Select one or more to use for this camera in Frigate.",
"candidateStreamTitle": "Candidate {{number}}",
"useCandidate": "Use",
"uriCopy": "Copy",

View File

@ -1,7 +1,6 @@
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardTitle } from "@/components/ui/card";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
// Input removed: URL shown as text
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { FaCopy, FaCheck } from "react-icons/fa";
import { LuX } from "react-icons/lu";
@ -96,137 +95,155 @@ export default function OnvifProbeResults({
);
}
const rtspCandidates = probeResult.rtsp_candidates || [];
const rtspCandidates = (probeResult.rtsp_candidates || []).filter(
(c) => c.source === "GetStreamUri",
);
if (probeResult?.success && rtspCandidates.length === 0) {
return (
<div className="space-y-4">
<Alert variant="destructive">
<CiCircleAlert className="size-5" />
<AlertTitle>{t("cameraWizard.step2.noRtspCandidates")}</AlertTitle>
</Alert>
</div>
);
}
return (
<div className="space-y-4">
{/* Probe success header (green check + text) */}
{probeResult?.success && (
<div className="mb-3 flex flex-row items-center gap-2 text-sm text-success">
<FaCircleCheck className="size-4" />
<span>{t("cameraWizard.step2.probeSuccessful")}</span>
</div>
)}
<Card>
<CardTitle className="border-b p-4 text-sm">
{t("cameraWizard.step2.deviceInfo")}
</CardTitle>
<CardContent className="space-y-2 p-4 text-sm">
{probeResult.manufacturer && (
<div>
<span className="text-muted-foreground">
{t("cameraWizard.step2.manufacturer")}:
</span>{" "}
<span className="text-primary-variant">
{probeResult.manufacturer}
</span>
</div>
)}
{probeResult.model && (
<div>
<span className="text-muted-foreground">
{t("cameraWizard.step2.model")}:
</span>{" "}
<span className="text-primary-variant">{probeResult.model}</span>
</div>
)}
{probeResult.firmware_version && (
<div>
<span className="text-muted-foreground">
{t("cameraWizard.step2.firmware")}:
</span>{" "}
<span className="text-primary-variant">
{probeResult.firmware_version}
</span>
</div>
)}
{probeResult.profiles_count !== undefined && (
<div>
<span className="text-muted-foreground">
{t("cameraWizard.step2.profiles")}:
</span>{" "}
<span className="text-primary-variant">
{probeResult.profiles_count}
</span>
</div>
)}
{probeResult.ptz_supported !== undefined && (
<div>
<span className="text-muted-foreground">
{t("cameraWizard.step2.ptzSupport")}:
</span>{" "}
<span className="text-primary-variant">
{probeResult.ptz_supported
? t("yes", { ns: "common" })
: t("no", { ns: "common" })}
</span>
</div>
)}
{probeResult.ptz_supported && probeResult.autotrack_supported && (
<div>
<span className="text-muted-foreground">
{t("cameraWizard.step2.autotrackingSupport")}:
</span>{" "}
<span className="text-primary-variant">
{t("yes", { ns: "common" })}
</span>
</div>
)}
{probeResult.ptz_supported &&
probeResult.presets_count !== undefined && (
<>
<div className="space-y-2">
{probeResult?.success && (
<div className="mb-3 flex flex-row items-center gap-2 text-sm text-success">
<FaCircleCheck className="size-4" />
<span>{t("cameraWizard.step2.probeSuccessful")}</span>
</div>
)}
<div className="text-sm">{t("cameraWizard.step2.deviceInfo")}</div>
<Card>
<CardContent className="space-y-2 p-4 text-sm">
{probeResult.manufacturer && (
<div>
<span className="text-muted-foreground">
{t("cameraWizard.step2.presets")}:
{t("cameraWizard.step2.manufacturer")}:
</span>{" "}
<span className="text-primary-variant">
{probeResult.presets_count}
{probeResult.manufacturer}
</span>
</div>
)}
</CardContent>
</Card>
{probeResult.model && (
<div>
<span className="text-muted-foreground">
{t("cameraWizard.step2.model")}:
</span>{" "}
<span className="text-primary-variant">
{probeResult.model}
</span>
</div>
)}
{probeResult.firmware_version && (
<div>
<span className="text-muted-foreground">
{t("cameraWizard.step2.firmware")}:
</span>{" "}
<span className="text-primary-variant">
{probeResult.firmware_version}
</span>
</div>
)}
{probeResult.profiles_count !== undefined && (
<div>
<span className="text-muted-foreground">
{t("cameraWizard.step2.profiles")}:
</span>{" "}
<span className="text-primary-variant">
{probeResult.profiles_count}
</span>
</div>
)}
{probeResult.ptz_supported !== undefined && (
<div>
<span className="text-muted-foreground">
{t("cameraWizard.step2.ptzSupport")}:
</span>{" "}
<span className="text-primary-variant">
{probeResult.ptz_supported
? t("yes", { ns: "common" })
: t("no", { ns: "common" })}
</span>
</div>
)}
{probeResult.ptz_supported && probeResult.autotrack_supported && (
<div>
<span className="text-muted-foreground">
{t("cameraWizard.step2.autotrackingSupport")}:
</span>{" "}
<span className="text-primary-variant">
{t("yes", { ns: "common" })}
</span>
</div>
)}
{probeResult.ptz_supported &&
probeResult.presets_count !== undefined && (
<div>
<span className="text-muted-foreground">
{t("cameraWizard.step2.presets")}:
</span>{" "}
<span className="text-primary-variant">
{probeResult.presets_count}
</span>
</div>
)}
</CardContent>
</Card>
</div>
<div className="space-y-2">
{rtspCandidates.length > 0 && (
<div className="mt-5 space-y-2">
<div className="text-sm">
{t("cameraWizard.step2.rtspCandidates")}
</div>
<div className="text-sm text-muted-foreground">
{t("cameraWizard.step2.rtspCandidatesDescription")}
</div>
{rtspCandidates.length > 0 && (
<div className="space-y-4">
<h3 className="text-sm">{t("cameraWizard.step2.rtspCandidates")}</h3>
<div className="space-y-2">
{rtspCandidates.map((candidate, idx) => {
const isSelected = !!selectedUris?.includes(candidate.uri);
const candidateTest = candidateTests?.[candidate.uri];
const isTesting = testingCandidates?.[candidate.uri];
<div className="space-y-2">
{rtspCandidates.map((candidate, idx) => {
const isSelected = !!selectedUris?.includes(candidate.uri);
const candidateTest = candidateTests?.[candidate.uri];
const isTesting = testingCandidates?.[candidate.uri];
return (
<CandidateItem
key={idx}
index={idx}
candidate={candidate}
copiedUri={copiedUri}
onCopy={() => handleCopyUri(candidate.uri)}
onUse={() => onSelectCandidate(candidate.uri)}
isSelected={isSelected}
testCandidate={testCandidate}
candidateTest={candidateTest}
isTesting={isTesting}
/>
);
})}
return (
<CandidateItem
key={idx}
index={idx}
candidate={candidate}
copiedUri={copiedUri}
onCopy={() => handleCopyUri(candidate.uri)}
onUse={() => onSelectCandidate(candidate.uri)}
isSelected={isSelected}
testCandidate={testCandidate}
candidateTest={candidateTest}
isTesting={isTesting}
/>
);
})}
</div>
</div>
</div>
)}
</div>
)}
</div>
</>
);
}
type CandidateItemProps = {
candidate: OnvifRtspCandidate;
index?: number;
// isTested?: boolean; (unused)
copiedUri: string | null;
onCopy: () => void;
onUse: () => void;
isSelected?: boolean;
// onTest?: () => void; (unused)
testCandidate?: (uri: string) => void;
candidateTest?: TestResult | { success: false; error: string };
isTesting?: boolean;
@ -253,110 +270,115 @@ function CandidateItem({
};
return (
<div
<Card
className={cn(
"rounded-lg bg-card",
isSelected &&
"outline outline-[3px] -outline-offset-[2.8px] outline-selected duration-200",
)}
>
<div className="flex flex-col space-y-4 p-4">
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium">
{t("cameraWizard.step2.candidateStreamTitle", {
number: (index ?? 0) + 1,
})}
</h4>
{candidateTest?.success && (
<div className="mt-1 text-sm text-muted-foreground">
{[
candidateTest.resolution,
candidateTest.fps
? `${candidateTest.fps} ${t(
"cameraWizard.testResultLabels.fps",
)}`
: null,
candidateTest.videoCodec,
candidateTest.audioCodec,
]
.filter(Boolean)
.join(" · ")}
</div>
)}
</div>
<div className="flex flex-shrink-0 items-center gap-2">
{candidateTest?.success && (
<div className="flex items-center gap-2 text-sm">
<FaCircleCheck className="size-4 text-success" />
<span className="text-success">
{t("cameraWizard.step2.connected")}
</span>
</div>
)}
{candidateTest && !candidateTest.success && (
<div className="flex items-center gap-2 text-sm">
<LuX className="size-4 text-danger" />
<span className="text-danger">
{t("cameraWizard.step2.notConnected")}
</span>
</div>
)}
</div>
</div>
<div className="mt-1 flex items-start gap-2">
<p
className="flex-1 cursor-pointer break-all text-sm text-primary-variant hover:underline"
onClick={() => setShowFull((s) => !s)}
title={t("cameraWizard.step2.toggleUriView")}
>
{showFull ? candidate.uri : maskUri(candidate.uri)}
</p>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
onClick={onCopy}
className="mr-4 size-8 p-0"
title={t("cameraWizard.step2.uriCopy")}
>
{copiedUri === candidate.uri ? (
<FaCheck className="size-3" />
) : (
<FaCopy className="size-3" />
<CardContent className="p-4">
<div className="flex flex-col space-y-4">
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium">
{t("cameraWizard.step2.candidateStreamTitle", {
number: (index ?? 0) + 1,
})}
</h4>
{candidateTest?.success && (
<div className="mt-1 text-sm text-muted-foreground">
{[
candidateTest.resolution,
candidateTest.fps
? `${candidateTest.fps} ${t(
"cameraWizard.testResultLabels.fps",
)}`
: null,
candidateTest.videoCodec,
candidateTest.audioCodec,
]
.filter(Boolean)
.join(" · ")}
</div>
)}
</Button>
</div>
<div className="flex flex-shrink-0 items-center gap-2">
{candidateTest?.success && (
<div className="flex items-center gap-2 text-sm">
<FaCircleCheck className="size-4 text-success" />
<span className="text-success">
{t("cameraWizard.step2.connected")}
</span>
</div>
)}
{candidateTest && !candidateTest.success && (
<div className="flex items-center gap-2 text-sm">
<LuX className="size-4 text-danger" />
<span className="text-danger">
{t("cameraWizard.step2.notConnected")}
</span>
</div>
)}
</div>
</div>
<div className="mt-1 flex items-start gap-2">
<p
className="flex-1 cursor-pointer break-all text-sm text-primary-variant hover:underline"
onClick={() => setShowFull((s) => !s)}
title={t("cameraWizard.step2.toggleUriView")}
>
{showFull ? candidate.uri : maskUri(candidate.uri)}
</p>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
onClick={onCopy}
className="mr-4 size-8 p-0"
title={t("cameraWizard.step2.uriCopy")}
>
{copiedUri === candidate.uri ? (
<FaCheck className="size-3" />
) : (
<FaCopy className="size-3" />
)}
</Button>
<Button
size="sm"
variant="outline"
disabled={isTesting}
onClick={() => testCandidate?.(candidate.uri)}
className="h-8 px-3 text-sm"
>
{isTesting ? (
<>
<ActivityIndicator className="mr-2 size-4" />{" "}
{t("cameraWizard.step2.testConnection")}
</>
) : (
t("cameraWizard.step2.testConnection")
)}
</Button>
</div>
</div>
<div className="mt-3 flex flex-row justify-end">
<Button
size="sm"
variant="outline"
onClick={() => testCandidate?.(candidate.uri)}
onClick={onUse}
variant="select"
className="h-8 px-3 text-sm"
>
{isTesting ? (
<ActivityIndicator className="size-3" />
) : (
t("cameraWizard.step2.testConnection")
)}
{t("cameraWizard.step2.useCandidate")}
</Button>
</div>
</div>
<div className="mt-3 flex flex-row justify-end">
<Button
size="sm"
onClick={onUse}
variant="select"
className="h-8 px-3 text-sm"
>
{t("cameraWizard.step2.useCandidate")}
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -1,126 +0,0 @@
import { useTranslation } from "react-i18next";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import OnvifProbeResults from "./OnvifProbeResults";
import {} from "@/components/ui/card";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import type {
OnvifProbeResponse,
CandidateTestMap,
} from "@/types/cameraWizard";
import StepIndicator from "@/components/indicators/StepIndicator";
type ProbeDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
isLoading: boolean;
isError: boolean;
error?: string;
probeResult?: OnvifProbeResponse | null;
onSelectCandidate: (uri: string) => void;
onRetry: () => void;
selectedCandidateUris?: string[];
testAllSelectedCandidates?: () => void;
isTesting?: boolean;
testStatus?: string;
testCandidate?: (uri: string) => void;
candidateTests?: CandidateTestMap;
testingCandidates?: Record<string, boolean>;
};
export default function ProbeDialog({
open,
onOpenChange,
isLoading,
isError,
error,
probeResult,
onSelectCandidate,
onRetry,
selectedCandidateUris,
testAllSelectedCandidates,
isTesting,
testStatus,
testCandidate,
candidateTests,
testingCandidates,
}: ProbeDialogProps) {
const { t } = useTranslation(["views/settings"]);
const STEPS = [
"cameraWizard.steps.nameAndConnection",
"cameraWizard.steps.streamConfiguration",
"cameraWizard.steps.validationAndTesting",
];
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90%] max-w-3xl overflow-y-auto xl:max-h-[80%]">
<StepIndicator
steps={STEPS}
currentStep={0}
variant="dots"
className="mb-4 justify-start"
/>
<DialogHeader>
<DialogTitle>{t("cameraWizard.title")}</DialogTitle>
<DialogDescription>{t("cameraWizard.description")}</DialogDescription>
</DialogHeader>
<div className="p-0">
<OnvifProbeResults
isLoading={isLoading}
isError={isError}
error={error}
probeResult={probeResult || undefined}
onSelectCandidate={onSelectCandidate}
onRetry={onRetry}
selectedUris={selectedCandidateUris}
testCandidate={testCandidate}
candidateTests={candidateTests}
testingCandidates={testingCandidates}
/>
</div>
<div className="mt-4">
{isTesting && testStatus && (
<div className="mb-3 flex items-center gap-2 text-sm text-muted-foreground">
<ActivityIndicator className="size-4" />
<span>{testStatus}</span>
</div>
)}
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
<Button
className="flex items-center justify-center gap-2 sm:flex-1"
onClick={() => onOpenChange(false)}
>
{t("button.back", { ns: "common" })}
</Button>
<Button
className="flex items-center justify-center gap-2 sm:flex-1"
onClick={testAllSelectedCandidates}
disabled={
!(selectedCandidateUris && selectedCandidateUris.length > 0) ||
isLoading ||
isTesting
}
variant="select"
>
<span className="flex items-center gap-2">
{isTesting && <ActivityIndicator className="size-4" />}
<span>{t("cameraWizard.step1.testConnection")}</span>
</span>
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -17,7 +17,7 @@ import type {
} from "@/types/cameraWizard";
import { FaCircleCheck } from "react-icons/fa6";
import { Card, CardContent, CardTitle } from "../../ui/card";
import ProbeDialog from "./ProbeDialog";
import OnvifProbeResults from "./OnvifProbeResults";
import { CAMERA_BRANDS } from "@/types/cameraWizard";
import { detectReolinkCamera } from "@/utils/cameraUtil";
@ -45,16 +45,15 @@ export default function Step2ProbeOrSnapshot({
const [probeResult, setProbeResult] = useState<OnvifProbeResponse | null>(
null,
);
const [probeDialogOpen, setProbeDialogOpen] = useState(false);
const [testingCandidates, setTestingCandidates] = useState<
Record<string, boolean>
>({} as Record<string, boolean>);
const [selectedCandidateUris, setSelectedCandidateUris] = useState<string[]>(
[],
);
const [candidateTests, setCandidateTests] = useState<CandidateTestMap>(
{} as CandidateTestMap,
);
const [testingCandidates, setTestingCandidates] = useState<
Record<string, boolean>
>({} as Record<string, boolean>);
const handleSelectCandidate = useCallback((uri: string) => {
setSelectedCandidateUris((s) => {
@ -205,7 +204,6 @@ export default function Step2ProbeOrSnapshot({
if (response.data && response.data.success) {
setProbeResult(response.data);
setProbeDialogOpen(true);
} else {
setProbeError(response.data?.message || "Probe failed");
}
@ -266,7 +264,6 @@ export default function Step2ProbeOrSnapshot({
if (streamConfigs.length > 0) {
onNext({ streams: streamConfigs });
toast.success(t("cameraWizard.step2.testSuccess"));
setProbeDialogOpen(false);
} else {
toast.error(
t("cameraWizard.commonErrors.testFailed", {
@ -459,29 +456,18 @@ export default function Step2ProbeOrSnapshot({
return (
<div className="space-y-6">
{probeMode ? (
// Probe mode: show probe dialog
// Probe mode: show probe results directly
<>
{probeResult && (
<div className="p-4">
<ProbeDialog
open={probeDialogOpen}
onOpenChange={(open) => {
setProbeDialogOpen(open);
// If dialog is being closed (open=false), go back
if (!open) {
onBack();
}
}}
<div className="space-y-4">
<OnvifProbeResults
isLoading={isProbing}
isError={!!probeError}
error={probeError || undefined}
probeResult={probeResult}
onSelectCandidate={handleSelectCandidate}
onRetry={probeCamera}
selectedCandidateUris={selectedCandidateUris}
testAllSelectedCandidates={testAllSelectedCandidates}
isTesting={isTesting}
testStatus={testStatus}
selectedUris={selectedCandidateUris}
testCandidate={testCandidate}
candidateTests={candidateTests}
testingCandidates={testingCandidates}
@ -489,31 +475,15 @@ export default function Step2ProbeOrSnapshot({
</div>
)}
{isProbing && !probeResult && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<ActivityIndicator className="size-4" />
{t("cameraWizard.step2.probing")}
</div>
)}
{probeError && !probeResult && (
<div className="space-y-4">
<div className="text-sm text-destructive">{probeError}</div>
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
<Button type="button" onClick={onBack} className="sm:flex-1">
{t("button.back", { ns: "common" })}
</Button>
<Button
type="button"
onClick={probeCamera}
variant="select"
className="flex items-center justify-center gap-2 sm:flex-1"
>
{t("cameraWizard.step2.retry")}
</Button>
</div>
</div>
)}
<ProbeFooterButtons
isProbing={isProbing}
probeError={probeError}
onBack={onBack}
onTestAll={testAllSelectedCandidates}
onRetry={probeCamera}
isTesting={isTesting}
selectedCount={selectedCandidateUris.length}
/>
</>
) : (
// Manual mode: show snapshot and stream details
@ -566,32 +536,19 @@ export default function Step2ProbeOrSnapshot({
</div>
)}
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
<Button type="button" onClick={onBack} className="sm:flex-1">
{t("button.back", { ns: "common" })}
</Button>
{testResult?.success ? (
<Button
type="button"
onClick={handleContinue}
variant="select"
className="flex items-center justify-center gap-2 sm:flex-1"
>
{t("button.continue", { ns: "common" })}
</Button>
) : (
<Button
type="button"
onClick={testConnection}
disabled={isTesting}
variant="select"
className="flex items-center justify-center gap-2 sm:flex-1"
>
{isTesting && <ActivityIndicator className="size-4" />}
{t("cameraWizard.step2.retry")}
</Button>
)}
</div>
<ProbeFooterButtons
mode="manual"
isProbing={false}
probeError={null}
onBack={onBack}
onTestAll={testAllSelectedCandidates}
onRetry={probeCamera}
isTesting={isTesting}
selectedCount={selectedCandidateUris.length}
manualTestSuccess={!!testResult?.success}
onContinue={handleContinue}
onManualTest={testConnection}
/>
</>
)}
</div>
@ -638,3 +595,133 @@ function StreamDetails({ testResult }: { testResult: TestResult }) {
</>
);
}
type ProbeFooterProps = {
isProbing: boolean;
probeError: string | null;
onBack: () => void;
onTestAll: () => void;
onRetry: () => void;
isTesting: boolean;
selectedCount: number;
mode?: "probe" | "manual";
manualTestSuccess?: boolean;
onContinue?: () => void;
onManualTest?: () => void;
};
function ProbeFooterButtons({
isProbing,
probeError,
onBack,
onTestAll,
onRetry,
isTesting,
selectedCount,
mode = "probe",
manualTestSuccess,
onContinue,
onManualTest,
}: ProbeFooterProps) {
const { t } = useTranslation(["views/settings"]);
// Loading footer
if (isProbing) {
return (
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<ActivityIndicator className="size-4" />
{t("cameraWizard.step2.probing")}
</div>
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
<Button type="button" onClick={onBack} disabled className="sm:flex-1">
{t("button.back", { ns: "common" })}
</Button>
<Button
type="button"
disabled
variant="select"
className="flex items-center justify-center gap-2 sm:flex-1"
>
<ActivityIndicator className="size-4" />
{t("cameraWizard.step2.probing")}
</Button>
</div>
</div>
);
}
// Error footer
if (probeError) {
return (
<div className="space-y-4">
<div className="text-sm text-destructive">{probeError}</div>
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
<Button type="button" onClick={onBack} className="sm:flex-1">
{t("button.back", { ns: "common" })}
</Button>
<Button
type="button"
onClick={onRetry}
variant="select"
className="flex items-center justify-center gap-2 sm:flex-1"
>
{t("cameraWizard.step2.retry")}
</Button>
</div>
</div>
);
}
// Default footer: show back + test (test disabled if none selected or testing)
// If manual mode, show Continue when test succeeded, otherwise show Test (calls onManualTest)
if (mode === "manual") {
return (
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
<Button type="button" onClick={onBack} className="sm:flex-1">
{t("button.back", { ns: "common" })}
</Button>
{manualTestSuccess ? (
<Button
type="button"
onClick={onContinue}
variant="select"
className="flex items-center justify-center gap-2 sm:flex-1"
>
{t("button.continue", { ns: "common" })}
</Button>
) : (
<Button
type="button"
onClick={onManualTest}
disabled={isTesting}
variant="select"
className="flex items-center justify-center gap-2 sm:flex-1"
>
{isTesting && <ActivityIndicator className="size-4" />}
{t("cameraWizard.step2.retry")}
</Button>
)}
</div>
);
}
// Default probe footer
return (
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
<Button type="button" onClick={onBack} className="sm:flex-1">
{t("button.back", { ns: "common" })}
</Button>
<Button
type="button"
onClick={onTestAll}
disabled={isTesting || selectedCount === 0}
variant="select"
className="flex items-center justify-center gap-2 sm:flex-1"
>
{isTesting && <ActivityIndicator className="size-4" />}
{t("cameraWizard.step2.testConnection")}
</Button>
</div>
);
}