mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-06-21 03:41:55 +03:00
Some checks failed
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
* remove redundant per-view toasters in settings * add variants to standardize dialog footer button layouts * remove text-md this class name compiles to nothing in tailwind. we used to add it to prevent iOS from zooming when focusing on an input, but that is now solved via the viewport meta in index.html * make wizard footers consistent with dialog footers * consistent destructive button style remove text-white from individual buttons and add it to the variant
768 lines
26 KiB
TypeScript
768 lines
26 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useForm, FormProvider } from "react-hook-form";
|
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
import { z } from "zod";
|
|
import useSWR from "swr";
|
|
import axios from "axios";
|
|
import { toast } from "sonner";
|
|
import { Pencil, Trash2 } from "lucide-react";
|
|
import {
|
|
LuChevronDown,
|
|
LuChevronRight,
|
|
LuExternalLink,
|
|
LuPlus,
|
|
} from "react-icons/lu";
|
|
import { Link } from "react-router-dom";
|
|
import type { FrigateConfig } from "@/types/frigateConfig";
|
|
import type { JsonObject } from "@/types/configForm";
|
|
import type { ProfileState, ProfilesApiResponse } from "@/types/profile";
|
|
import { getProfileColor } from "@/utils/profileColors";
|
|
import { PROFILE_ELIGIBLE_SECTIONS } from "@/utils/configUtil";
|
|
import { isReplayCamera } from "@/utils/cameraUtil";
|
|
import { resolveCameraName } from "@/hooks/use-camera-friendly-name";
|
|
import { useDocDomain } from "@/hooks/use-doc-domain";
|
|
import { cn } from "@/lib/utils";
|
|
import Heading from "@/components/ui/heading";
|
|
import { Button, buttonVariants } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Input } from "@/components/ui/input";
|
|
import NameAndIdFields from "@/components/input/NameAndIdFields";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
Collapsible,
|
|
CollapsibleContent,
|
|
CollapsibleTrigger,
|
|
} from "@/components/ui/collapsible";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from "@/components/ui/alert-dialog";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { Label } from "@/components/ui/label";
|
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
|
|
|
type ProfilesViewProps = {
|
|
setUnsavedChanges?: React.Dispatch<React.SetStateAction<boolean>>;
|
|
profileState?: ProfileState;
|
|
profilesUIEnabled?: boolean;
|
|
setProfilesUIEnabled?: React.Dispatch<React.SetStateAction<boolean>>;
|
|
};
|
|
|
|
export default function ProfilesView({
|
|
profileState,
|
|
profilesUIEnabled,
|
|
setProfilesUIEnabled,
|
|
}: ProfilesViewProps) {
|
|
const { t } = useTranslation(["views/settings", "common"]);
|
|
const { getLocaleDocUrl } = useDocDomain();
|
|
const { data: config, mutate: updateConfig } =
|
|
useSWR<FrigateConfig>("config");
|
|
const { data: profilesData, mutate: updateProfiles } =
|
|
useSWR<ProfilesApiResponse>("profiles");
|
|
|
|
const [activating, setActivating] = useState(false);
|
|
const [deleteProfile, setDeleteProfile] = useState<string | null>(null);
|
|
const [deleting, setDeleting] = useState(false);
|
|
const [renameProfile, setRenameProfile] = useState<string | null>(null);
|
|
const [renameValue, setRenameValue] = useState("");
|
|
const [renaming, setRenaming] = useState(false);
|
|
const [expandedProfiles, setExpandedProfiles] = useState<Set<string>>(
|
|
new Set(),
|
|
);
|
|
const [addDialogOpen, setAddDialogOpen] = useState(false);
|
|
|
|
const allProfileNames = useMemo(
|
|
() => profileState?.allProfileNames ?? [],
|
|
[profileState?.allProfileNames],
|
|
);
|
|
|
|
const addProfileSchema = useMemo(
|
|
() =>
|
|
z.object({
|
|
name: z
|
|
.string()
|
|
.min(2, {
|
|
message: t("profiles.error.mustBeAtLeastTwoCharacters", {
|
|
ns: "views/settings",
|
|
}),
|
|
})
|
|
.refine((value) => !value.includes("."), {
|
|
message: t("profiles.error.mustNotContainPeriod", {
|
|
ns: "views/settings",
|
|
}),
|
|
})
|
|
.refine((value) => !allProfileNames.includes(value), {
|
|
message: t("profiles.error.alreadyExists", {
|
|
ns: "views/settings",
|
|
}),
|
|
}),
|
|
friendly_name: z.string().min(2, {
|
|
message: t("profiles.error.mustBeAtLeastTwoCharacters", {
|
|
ns: "views/settings",
|
|
}),
|
|
}),
|
|
}),
|
|
[t, allProfileNames],
|
|
);
|
|
|
|
type AddProfileForm = z.infer<typeof addProfileSchema>;
|
|
const addForm = useForm<AddProfileForm>({
|
|
resolver: zodResolver(addProfileSchema),
|
|
defaultValues: { friendly_name: "", name: "" },
|
|
});
|
|
|
|
const profileFriendlyNames = profileState?.profileFriendlyNames;
|
|
|
|
useEffect(() => {
|
|
document.title = t("documentTitle.profiles", {
|
|
ns: "views/settings",
|
|
});
|
|
}, [t]);
|
|
|
|
const activeProfile = profilesData?.active_profile ?? null;
|
|
|
|
// Build overview data: for each profile, which cameras have which sections
|
|
const profileOverviewData = useMemo(() => {
|
|
if (!config || allProfileNames.length === 0) return {};
|
|
|
|
const data: Record<string, Record<string, string[]>> = {};
|
|
const cameras = Object.keys(config.cameras)
|
|
.filter((name) => !isReplayCamera(name))
|
|
.sort();
|
|
|
|
for (const profile of allProfileNames) {
|
|
data[profile] = {};
|
|
for (const camera of cameras) {
|
|
const profileData = config.cameras[camera]?.profiles?.[profile];
|
|
if (!profileData) continue;
|
|
|
|
const sections: string[] = [];
|
|
for (const section of PROFILE_ELIGIBLE_SECTIONS) {
|
|
if (
|
|
profileData[section as keyof typeof profileData] !== undefined &&
|
|
profileData[section as keyof typeof profileData] !== null
|
|
) {
|
|
sections.push(section);
|
|
}
|
|
}
|
|
if (profileData.enabled !== undefined && profileData.enabled !== null) {
|
|
sections.push("enabled");
|
|
}
|
|
if (sections.length > 0) {
|
|
data[profile][camera] = sections;
|
|
}
|
|
}
|
|
}
|
|
return data;
|
|
}, [config, allProfileNames]);
|
|
|
|
const [addingProfile, setAddingProfile] = useState(false);
|
|
|
|
const handleAddSubmit = useCallback(
|
|
async (data: AddProfileForm) => {
|
|
const id = data.name.trim();
|
|
const friendlyName = data.friendly_name.trim();
|
|
if (!id || !friendlyName) return;
|
|
|
|
setAddingProfile(true);
|
|
try {
|
|
await axios.put("config/set", {
|
|
requires_restart: 0,
|
|
config_data: {
|
|
profiles: { [id]: { friendly_name: friendlyName } },
|
|
},
|
|
});
|
|
await updateConfig();
|
|
await updateProfiles();
|
|
toast.success(
|
|
t("profiles.createSuccess", {
|
|
ns: "views/settings",
|
|
profile: friendlyName,
|
|
}),
|
|
{ position: "top-center" },
|
|
);
|
|
setAddDialogOpen(false);
|
|
addForm.reset();
|
|
} catch {
|
|
toast.error(t("toast.save.error.noMessage", { ns: "common" }), {
|
|
position: "top-center",
|
|
});
|
|
} finally {
|
|
setAddingProfile(false);
|
|
}
|
|
},
|
|
[updateConfig, updateProfiles, addForm, t],
|
|
);
|
|
|
|
const handleActivateProfile = useCallback(
|
|
async (profile: string | null) => {
|
|
setActivating(true);
|
|
try {
|
|
await axios.put("camera/*/set/profile", {
|
|
value: profile ?? "none",
|
|
});
|
|
await updateProfiles();
|
|
toast.success(
|
|
profile
|
|
? t("profiles.activated", {
|
|
ns: "views/settings",
|
|
profile: profileFriendlyNames?.get(profile) ?? profile,
|
|
})
|
|
: t("profiles.deactivated", { ns: "views/settings" }),
|
|
{ position: "top-center" },
|
|
);
|
|
} catch (err) {
|
|
const message =
|
|
axios.isAxiosError(err) && err.response?.data?.message
|
|
? String(err.response.data.message)
|
|
: undefined;
|
|
toast.error(
|
|
message || t("profiles.activateFailed", { ns: "views/settings" }),
|
|
{ position: "top-center" },
|
|
);
|
|
} finally {
|
|
setActivating(false);
|
|
}
|
|
},
|
|
[updateProfiles, profileFriendlyNames, t],
|
|
);
|
|
|
|
const handleDeleteProfile = useCallback(async () => {
|
|
if (!deleteProfile || !config) return;
|
|
|
|
setDeleting(true);
|
|
|
|
try {
|
|
// If this profile is active, deactivate it first
|
|
if (activeProfile === deleteProfile) {
|
|
await axios.put("camera/*/set/profile", { value: "none" });
|
|
}
|
|
|
|
// Remove the profile from all cameras and the top-level definition
|
|
const cameraData: JsonObject = {};
|
|
for (const camera of Object.keys(config.cameras)) {
|
|
if (config.cameras[camera]?.profiles?.[deleteProfile]) {
|
|
cameraData[camera] = {
|
|
profiles: { [deleteProfile]: "" },
|
|
};
|
|
}
|
|
}
|
|
|
|
const configData: JsonObject = {
|
|
profiles: { [deleteProfile]: "" },
|
|
};
|
|
if (Object.keys(cameraData).length > 0) {
|
|
configData.cameras = cameraData;
|
|
}
|
|
|
|
await axios.put("config/set", {
|
|
requires_restart: 0,
|
|
config_data: configData,
|
|
});
|
|
|
|
await updateConfig();
|
|
await updateProfiles();
|
|
|
|
toast.success(
|
|
t("profiles.deleteSuccess", {
|
|
ns: "views/settings",
|
|
profile: profileFriendlyNames?.get(deleteProfile) ?? deleteProfile,
|
|
}),
|
|
{ position: "top-center" },
|
|
);
|
|
} catch (err) {
|
|
const errorMessage =
|
|
axios.isAxiosError(err) && err.response?.data?.message
|
|
? String(err.response.data.message)
|
|
: undefined;
|
|
toast.error(
|
|
errorMessage || t("toast.save.error.noMessage", { ns: "common" }),
|
|
{ position: "top-center" },
|
|
);
|
|
} finally {
|
|
setDeleting(false);
|
|
setDeleteProfile(null);
|
|
}
|
|
}, [
|
|
deleteProfile,
|
|
activeProfile,
|
|
config,
|
|
profileFriendlyNames,
|
|
updateConfig,
|
|
updateProfiles,
|
|
t,
|
|
]);
|
|
|
|
const toggleExpanded = useCallback((profile: string) => {
|
|
setExpandedProfiles((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(profile)) {
|
|
next.delete(profile);
|
|
} else {
|
|
next.add(profile);
|
|
}
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const handleRename = useCallback(async () => {
|
|
if (!renameProfile || !renameValue.trim()) return;
|
|
|
|
setRenaming(true);
|
|
try {
|
|
await axios.put("config/set", {
|
|
requires_restart: 0,
|
|
config_data: {
|
|
profiles: {
|
|
[renameProfile]: { friendly_name: renameValue.trim() },
|
|
},
|
|
},
|
|
});
|
|
|
|
await updateConfig();
|
|
await updateProfiles();
|
|
|
|
toast.success(
|
|
t("profiles.renameSuccess", {
|
|
ns: "views/settings",
|
|
profile: renameValue.trim(),
|
|
}),
|
|
{ position: "top-center" },
|
|
);
|
|
} catch {
|
|
toast.error(t("toast.save.error.noMessage", { ns: "common" }), {
|
|
position: "top-center",
|
|
});
|
|
} finally {
|
|
setRenaming(false);
|
|
setRenameProfile(null);
|
|
}
|
|
}, [renameProfile, renameValue, updateConfig, updateProfiles, t]);
|
|
|
|
if (!config || !profilesData) {
|
|
return null;
|
|
}
|
|
|
|
const hasProfiles = allProfileNames.length > 0;
|
|
|
|
return (
|
|
<div className="flex size-full max-w-5xl flex-col lg:pr-2">
|
|
<Heading as="h4">{t("profiles.title", { ns: "views/settings" })}</Heading>
|
|
<div className="my-1 text-sm text-muted-foreground">
|
|
{t("profiles.disabledDescription", { ns: "views/settings" })}
|
|
</div>
|
|
<div className="flex items-center text-sm text-primary-variant">
|
|
<Link
|
|
to={getLocaleDocUrl("configuration/profiles")}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="inline"
|
|
>
|
|
{t("readTheDocumentation", { ns: "common" })}
|
|
<LuExternalLink className="ml-2 inline-flex size-3" />
|
|
</Link>
|
|
</div>
|
|
|
|
{/* Enable Profiles Toggle — shown only when no profiles exist */}
|
|
{!hasProfiles && setProfilesUIEnabled && (
|
|
<div className="my-6 max-w-xl rounded-lg border border-border/70 bg-card/30 p-4">
|
|
<div className="flex items-center justify-between">
|
|
<Label htmlFor="profiles-toggle" className="cursor-pointer">
|
|
{t("profiles.enableSwitch", { ns: "views/settings" })}
|
|
</Label>
|
|
<Switch
|
|
id="profiles-toggle"
|
|
checked={profilesUIEnabled ?? false}
|
|
onCheckedChange={setProfilesUIEnabled}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{profilesUIEnabled && !hasProfiles && (
|
|
<p className="mb-5 max-w-xl text-sm text-primary-variant">
|
|
{t("profiles.enabledDescription", { ns: "views/settings" })}
|
|
</p>
|
|
)}
|
|
|
|
{/* Active Profile + Add Profile bar */}
|
|
{(hasProfiles || profilesUIEnabled) && (
|
|
<div className="my-4 flex flex-col gap-3 rounded-lg border border-border/70 bg-card/30 p-4 sm:flex-row sm:items-center sm:justify-between">
|
|
{hasProfiles && (
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-sm font-semibold text-primary-variant">
|
|
{t("profiles.activeProfile", { ns: "views/settings" })}
|
|
</span>
|
|
<Select
|
|
value={activeProfile ?? "__none__"}
|
|
onValueChange={(v) =>
|
|
handleActivateProfile(v === "__none__" ? null : v)
|
|
}
|
|
disabled={activating}
|
|
>
|
|
<SelectTrigger className="">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__none__">
|
|
{t("profiles.noActiveProfile", { ns: "views/settings" })}
|
|
</SelectItem>
|
|
{allProfileNames.map((profile) => {
|
|
const color = getProfileColor(profile, allProfileNames);
|
|
return (
|
|
<SelectItem key={profile} value={profile}>
|
|
<div className="flex items-center gap-2">
|
|
<span
|
|
className={cn(
|
|
"h-2 w-2 shrink-0 rounded-full",
|
|
color.dot,
|
|
)}
|
|
/>
|
|
{profileFriendlyNames?.get(profile) ?? profile}
|
|
</div>
|
|
</SelectItem>
|
|
);
|
|
})}
|
|
</SelectContent>
|
|
</Select>
|
|
{activating && <ActivityIndicator className="size-4" />}
|
|
</div>
|
|
)}
|
|
<Button
|
|
variant="default"
|
|
size="sm"
|
|
onClick={() => setAddDialogOpen(true)}
|
|
>
|
|
<LuPlus className="mr-1.5 size-4" />
|
|
{t("profiles.addProfile", { ns: "views/settings" })}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Profile List */}
|
|
{!hasProfiles ? (
|
|
profilesUIEnabled ? (
|
|
<p className="text-sm text-muted-foreground">
|
|
{t("profiles.noProfiles", { ns: "views/settings" })}
|
|
</p>
|
|
) : (
|
|
<div />
|
|
)
|
|
) : (
|
|
<div className="flex flex-col gap-2">
|
|
{allProfileNames.map((profile) => {
|
|
const color = getProfileColor(profile, allProfileNames);
|
|
const isActive = activeProfile === profile;
|
|
const cameraData = profileOverviewData[profile] ?? {};
|
|
const cameras = Object.keys(cameraData).sort();
|
|
const isExpanded = expandedProfiles.has(profile);
|
|
|
|
return (
|
|
<Collapsible
|
|
key={profile}
|
|
open={isExpanded}
|
|
onOpenChange={() => toggleExpanded(profile)}
|
|
>
|
|
<div
|
|
className={cn(
|
|
"rounded-lg border",
|
|
isActive
|
|
? "border-selected bg-selected/5"
|
|
: "border-border/70",
|
|
)}
|
|
>
|
|
<CollapsibleTrigger asChild>
|
|
<div className="flex cursor-pointer flex-wrap items-center gap-y-2 px-4 py-3 hover:bg-secondary/30">
|
|
<div className="flex min-w-0 items-center gap-3">
|
|
{isExpanded ? (
|
|
<LuChevronDown className="size-4 shrink-0 text-muted-foreground" />
|
|
) : (
|
|
<LuChevronRight className="size-4 shrink-0 text-muted-foreground" />
|
|
)}
|
|
<span
|
|
className={cn(
|
|
"size-2.5 shrink-0 rounded-full",
|
|
color.dot,
|
|
)}
|
|
/>
|
|
<span className="truncate font-medium">
|
|
{profileFriendlyNames?.get(profile) ?? profile}
|
|
</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="size-6 shrink-0 text-muted-foreground hover:text-primary"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setRenameProfile(profile);
|
|
setRenameValue(
|
|
profileFriendlyNames?.get(profile) ?? profile,
|
|
);
|
|
}}
|
|
>
|
|
<Pencil className="size-3" />
|
|
</Button>
|
|
</div>
|
|
<div className="ml-auto flex items-center gap-3">
|
|
{isActive && (
|
|
<Badge
|
|
variant="secondary"
|
|
className="text-xs text-primary-variant"
|
|
>
|
|
{t("profiles.active", { ns: "views/settings" })}
|
|
</Badge>
|
|
)}
|
|
<span className="text-sm text-muted-foreground">
|
|
{cameras.length > 0
|
|
? t("profiles.cameraCount", {
|
|
ns: "views/settings",
|
|
count: cameras.length,
|
|
})
|
|
: t("profiles.noOverrides", {
|
|
ns: "views/settings",
|
|
})}
|
|
</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="size-7 shrink-0 text-muted-foreground hover:text-destructive"
|
|
disabled={deleting && deleteProfile === profile}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setDeleteProfile(profile);
|
|
}}
|
|
>
|
|
{deleting && deleteProfile === profile ? (
|
|
<ActivityIndicator className="size-4" />
|
|
) : (
|
|
<Trash2 className="size-4" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent>
|
|
{cameras.length > 0 ? (
|
|
<div className="mx-4 mb-3 ml-11 border-l border-border/50 pl-4">
|
|
<div className="flex items-baseline gap-3 border-b border-border/30 pb-1.5">
|
|
<span className="min-w-[120px] shrink-0 text-xs font-semibold uppercase text-muted-foreground">
|
|
{t("profiles.columnCamera", {
|
|
ns: "views/settings",
|
|
defaultValue: "Camera",
|
|
})}
|
|
</span>
|
|
<span className="text-xs font-semibold uppercase text-muted-foreground">
|
|
{t("profiles.columnOverrides", {
|
|
ns: "views/settings",
|
|
defaultValue: "Profile Overrides",
|
|
})}
|
|
</span>
|
|
</div>
|
|
{cameras.map((camera) => {
|
|
const sections = cameraData[camera];
|
|
return (
|
|
<div
|
|
key={camera}
|
|
className="flex items-baseline gap-3 py-1.5"
|
|
>
|
|
<span className="min-w-[120px] shrink-0 truncate text-sm font-medium">
|
|
{resolveCameraName(config, camera)}
|
|
</span>
|
|
<span className="text-sm text-muted-foreground">
|
|
{sections
|
|
.map((section) =>
|
|
t(`configForm.sections.${section}`, {
|
|
ns: "views/settings",
|
|
defaultValue: section,
|
|
}),
|
|
)
|
|
.join(", ")}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<div className="mx-4 mb-3 ml-11 text-sm text-muted-foreground">
|
|
{t("profiles.noOverrides", { ns: "views/settings" })}
|
|
</div>
|
|
)}
|
|
</CollapsibleContent>
|
|
</div>
|
|
</Collapsible>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Add Profile Dialog */}
|
|
<Dialog
|
|
open={addDialogOpen}
|
|
onOpenChange={(open) => {
|
|
setAddDialogOpen(open);
|
|
if (!open) {
|
|
addForm.reset();
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent className="sm:max-w-[400px]">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{t("profiles.newProfile", { ns: "views/settings" })}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
<FormProvider {...addForm}>
|
|
<form
|
|
onSubmit={addForm.handleSubmit(handleAddSubmit)}
|
|
className="space-y-4 py-2"
|
|
>
|
|
<NameAndIdFields<AddProfileForm>
|
|
control={addForm.control}
|
|
type="profile"
|
|
nameField="friendly_name"
|
|
idField="name"
|
|
nameLabel={t("profiles.friendlyNameLabel", {
|
|
ns: "views/settings",
|
|
})}
|
|
idLabel={t("profiles.profileIdLabel", {
|
|
ns: "views/settings",
|
|
})}
|
|
idDescription={t("profiles.profileIdDescription", {
|
|
ns: "views/settings",
|
|
})}
|
|
placeholderName={t("profiles.profileNamePlaceholder", {
|
|
ns: "views/settings",
|
|
})}
|
|
/>
|
|
<DialogFooter>
|
|
<Button
|
|
type="button"
|
|
onClick={() => setAddDialogOpen(false)}
|
|
disabled={addingProfile}
|
|
>
|
|
{t("button.cancel", { ns: "common" })}
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
variant="select"
|
|
disabled={
|
|
addingProfile ||
|
|
!addForm.watch("friendly_name").trim() ||
|
|
!addForm.watch("name").trim()
|
|
}
|
|
>
|
|
{addingProfile && (
|
|
<ActivityIndicator className="mr-2 size-4" />
|
|
)}
|
|
{t("button.add", { ns: "common" })}
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</FormProvider>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Delete Profile Confirmation */}
|
|
<AlertDialog
|
|
open={!!deleteProfile}
|
|
onOpenChange={(open) => {
|
|
if (!open) setDeleteProfile(null);
|
|
}}
|
|
>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>
|
|
{t("profiles.deleteProfile", { ns: "views/settings" })}
|
|
</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{t("profiles.deleteProfileConfirm", {
|
|
ns: "views/settings",
|
|
profile: deleteProfile
|
|
? (profileFriendlyNames?.get(deleteProfile) ?? deleteProfile)
|
|
: "",
|
|
})}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel disabled={deleting}>
|
|
{t("button.cancel", { ns: "common" })}
|
|
</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
className={cn(buttonVariants({ variant: "destructive" }))}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
handleDeleteProfile();
|
|
}}
|
|
disabled={deleting}
|
|
>
|
|
{deleting && <ActivityIndicator className="mr-2 size-4" />}
|
|
{t("button.delete", { ns: "common" })}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
|
|
{/* Rename Profile Dialog */}
|
|
<Dialog
|
|
open={!!renameProfile}
|
|
onOpenChange={(open) => {
|
|
if (!open) setRenameProfile(null);
|
|
}}
|
|
>
|
|
<DialogContent className="sm:max-w-[400px]">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{t("profiles.renameProfile", { ns: "views/settings" })}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4 py-2">
|
|
<Input
|
|
value={renameValue}
|
|
onChange={(e) => setRenameValue(e.target.value)}
|
|
placeholder={t("profiles.profileNamePlaceholder", {
|
|
ns: "views/settings",
|
|
})}
|
|
/>
|
|
<DialogFooter>
|
|
<Button
|
|
onClick={() => setRenameProfile(null)}
|
|
disabled={renaming}
|
|
>
|
|
{t("button.cancel", { ns: "common" })}
|
|
</Button>
|
|
<Button
|
|
variant="select"
|
|
onClick={handleRename}
|
|
disabled={renaming || !renameValue.trim()}
|
|
>
|
|
{renaming && <ActivityIndicator className="mr-2 size-4" />}
|
|
{t("button.save", { ns: "common" })}
|
|
</Button>
|
|
</DialogFooter>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|