allow to edit profiles

This commit is contained in:
Eduard Gert
2026-06-18 14:39:27 +02:00
parent 0b5fa75549
commit 2e9ae5e8e1
17 changed files with 409 additions and 86 deletions

View File

@@ -81,14 +81,8 @@ jobs:
- name: Generate Wails bindings
run: pnpm run bindings
- name: ESLint
run: pnpm lint
- name: Type-check
run: pnpm typecheck
- name: Prettier check
run: pnpm format:check
- name: Lint, typecheck, format
run: pnpm check
- name: Build
run: pnpm build

View File

@@ -14,7 +14,8 @@
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,css,json,md}\"",
"lint": "eslint \"src/**/*.{ts,tsx}\"",
"lint:fix": "eslint \"src/**/*.{ts,tsx}\" --fix",
"lint:report": "eslint \"src/**/*.{ts,tsx}\" --format json --output-file eslint-report.json || true"
"check": "pnpm lint && pnpm typecheck && pnpm format:check",
"check:fix": "pnpm lint:fix && pnpm format && pnpm typecheck"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.15",

View File

@@ -15,8 +15,8 @@ const menuItemVariants = cva("", {
variants: {
variant: {
default:
"text-nb-gray-200 focus:bg-nb-gray-900 focus:text-nb-gray-50 data-[state=open]:bg-nb-gray-900 data-[state=open]:text-nb-gray-50",
danger: "text-red-500 focus:bg-red-900/20 focus:text-red-500",
"text-nb-gray-200 hover:bg-nb-gray-900 hover:text-nb-gray-50 focus-visible:bg-nb-gray-900 focus-visible:text-nb-gray-50 data-[state=open]:bg-nb-gray-900 data-[state=open]:text-nb-gray-50",
danger: "text-red-500 hover:bg-red-900/20 hover:text-red-500 focus-visible:bg-red-900/20 focus-visible:text-red-500",
},
},
defaultVariants: { variant: "default" },
@@ -135,7 +135,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none",
"text-nb-gray-200 transition-colors focus:bg-nb-gray-900 focus:text-nb-gray-50",
"text-nb-gray-200 transition-colors hover:bg-nb-gray-900 hover:text-nb-gray-50 focus-visible:bg-nb-gray-900 focus-visible:text-nb-gray-50",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
@@ -160,7 +160,7 @@ const DropdownMenuRadioItem = React.forwardRef<
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none",
"text-nb-gray-200 transition-colors focus:bg-nb-gray-900 focus:text-nb-gray-50",
"text-nb-gray-200 transition-colors hover:bg-nb-gray-900 hover:text-nb-gray-50 focus-visible:bg-nb-gray-900 focus-visible:text-nb-gray-50",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}

View File

@@ -61,7 +61,7 @@ export const ConfirmModal = ({
<DialogActions className={"flex-row justify-end gap-2.5"}>
<Button
variant={"secondary"}
size={"xs2"}
size={"sm"}
disabled={busy}
onClick={onCancel}
>
@@ -70,7 +70,7 @@ export const ConfirmModal = ({
<Button
autoFocus
variant={danger ? "danger" : "primary"}
size={"xs2"}
size={"sm"}
disabled={busy}
onClick={onConfirm}
>

View File

@@ -28,6 +28,9 @@ export const SearchInput = forwardRef<HTMLInputElement, Props>(function SearchIn
type={"search"}
disabled={disabled}
aria-label={ariaLabel ?? props.placeholder ?? t("common.search")}
autoCorrect={"off"}
autoCapitalize={"off"}
spellCheck={false}
{...props}
className={cn(
"w-full bg-transparent text-sm text-nb-gray-200 placeholder:text-nb-gray-400",

View File

@@ -30,6 +30,7 @@ type ProfileContextValue = {
switchProfile: (id: string) => Promise<void>;
addProfile: (name: string) => Promise<string>;
removeProfile: (id: string) => Promise<void>;
renameProfile: (id: string, newName: string) => Promise<void>;
logoutProfile: (id: string) => Promise<void>;
};
@@ -130,6 +131,16 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
[username, refresh],
);
// The daemon resolves the handle (exact ID, ID prefix, or unique display
// name) — passing the ID is precise and avoids collisions on rename.
const renameProfile = useCallback(
async (id: string, newName: string) => {
await ProfilesSvc.Rename({ handle: id, newName, username });
await refresh();
},
[username, refresh],
);
const logoutProfile = useCallback(
async (id: string) => {
await Connection.Logout({ profileName: id, username });
@@ -149,6 +160,7 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
switchProfile,
addProfile,
removeProfile,
renameProfile,
logoutProfile,
}),
[
@@ -161,6 +173,7 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
switchProfile,
addProfile,
removeProfile,
renameProfile,
logoutProfile,
],
);

View File

@@ -16,53 +16,102 @@ import {
} from "@/hooks/useManagementUrl";
import { useRestrictions } from "@/contexts/RestrictionsContext.tsx";
export type ProfileFormInitial = {
name: string;
managementUrl: string;
};
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreate: (name: string, managementUrl: string) => void;
onSubmit: (name: string, managementUrl: string) => void | Promise<void>;
initial?: ProfileFormInitial;
};
// The daemon (profilemanager.sanitizeDisplayName) accepts free-form display
// names — spaces, emoji, punctuation, any valid UTF-8 — stripping only control
// characters and capping the length. Since #6367 the on-disk ID is separate
// from the display name, so the raw input no longer needs to be coerced into a
// filename-safe slug client-side; just trim and let the daemon canonicalize.
const MAX_PROFILE_NAME_LEN = 128;
export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) => {
export const ProfileCreationModal = ({ open, onOpenChange, onSubmit, initial }: Props) => {
const { t } = useTranslation();
const { mdm } = useRestrictions();
const managedManagementUrl = mdm.managementURL;
const nameId = useId();
const urlId = useId();
const [name, setName] = useState("");
const isEdit = !!initial;
const initialModeFromUrl = (u: string): ManagementMode =>
u && u !== CLOUD_MANAGEMENT_URL ? ManagementMode.SelfHosted : ManagementMode.Cloud;
const initialSelfHostedUrl = (u: string): string => (u && u !== CLOUD_MANAGEMENT_URL ? u : "");
const [name, setName] = useState(initial?.name ?? "");
const [nameError, setNameError] = useState<string | null>(null);
const nameRef = useRef<HTMLInputElement>(null);
const [mode, setMode] = useState<ManagementMode>(ManagementMode.Cloud);
const [url, setUrl] = useState("");
const [mode, setMode] = useState<ManagementMode>(
initial ? initialModeFromUrl(initial.managementUrl) : ManagementMode.Cloud,
);
const [url, setUrl] = useState(initial ? initialSelfHostedUrl(initial.managementUrl) : "");
const [urlError, setUrlError] = useState<string | null>(null);
const [unreachable, setUnreachable] = useState(false);
const [checking, setChecking] = useState(false);
const urlRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!open) {
setName("");
if (open) {
setName(initial?.name ?? "");
setMode(initial ? initialModeFromUrl(initial.managementUrl) : ManagementMode.Cloud);
setUrl(initial ? initialSelfHostedUrl(initial.managementUrl) : "");
setNameError(null);
setMode(ManagementMode.Cloud);
setUrl("");
setUrlError(null);
setUnreachable(false);
setChecking(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, initial?.name, initial?.managementUrl]);
const initialModeRef = useRef<ManagementMode>(ManagementMode.Cloud);
useEffect(() => {
if (!open) return;
initialModeRef.current = mode;
const id = window.setTimeout(() => {
nameRef.current?.focus();
nameRef.current?.select();
}, 0);
return () => window.clearTimeout(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
// When the user toggles to Self-hosted inside the dialog (not on initial
// open), move focus to the URL input so they can start typing immediately.
useEffect(() => {
if (!open) return;
if (mode === initialModeRef.current) return;
if (mode !== ManagementMode.SelfHosted) return;
urlRef.current?.focus();
}, [open, mode]);
useEffect(() => {
setUrlError(null);
setUnreachable(false);
}, [url, mode]);
const resolveTargetUrl = (): { url: string; needsReachCheck: boolean } | null => {
if (managedManagementUrl) {
return { url: managedManagementUrl, needsReachCheck: false };
}
if (mode === ManagementMode.Cloud) {
return { url: CLOUD_MANAGEMENT_URL, needsReachCheck: false };
}
const trimmed = url.trim();
if (!trimmed || !isValidManagementUrl(trimmed)) {
setUrlError(t("settings.general.management.urlError"));
urlRef.current?.focus();
return null;
}
const target = normalizeManagementUrl(trimmed);
const unchanged = initial && target === initial.managementUrl;
return { url: target, needsReachCheck: !unchanged };
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (checking) return;
@@ -74,35 +123,20 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
return;
}
if (managedManagementUrl) {
onCreate(sanitized, managedManagementUrl);
onOpenChange(false);
return;
const target = resolveTargetUrl();
if (!target) return;
if (target.needsReachCheck) {
setChecking(true);
const reachable = await checkManagementUrlReachable(target.url);
setChecking(false);
if (!reachable && !unreachable) {
setUnreachable(true);
return;
}
}
if (mode === ManagementMode.Cloud) {
onCreate(sanitized, CLOUD_MANAGEMENT_URL);
onOpenChange(false);
return;
}
const trimmed = url.trim();
if (!trimmed || !isValidManagementUrl(trimmed)) {
setUrlError(t("settings.general.management.urlError"));
urlRef.current?.focus();
return;
}
const target = normalizeManagementUrl(trimmed);
setChecking(true);
const reachable = await checkManagementUrlReachable(target);
setChecking(false);
if (!reachable && !unreachable) {
setUnreachable(true);
return;
}
onCreate(sanitized, target);
await onSubmit(sanitized, target.url);
onOpenChange(false);
};
@@ -128,9 +162,15 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
maxWidthClass={"max-w-md"}
showClose={false}
className={"py-7"}
srTitle={t("profile.dialog.title")}
srTitle={isEdit ? t("profile.edit.title") : t("profile.dialog.title")}
srDescription={t("profile.dialog.description")}
onOpenAutoFocus={(e) => e.preventDefault()}
onOpenAutoFocus={(e) => {
e.preventDefault();
// Focus + select-all so editing an existing name is one
// keystroke away from overwriting it.
nameRef.current?.focus();
nameRef.current?.select();
}}
>
<form onSubmit={handleSubmit}>
<div className={"flex flex-col gap-6 px-7"}>
@@ -178,7 +218,6 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
<Input
id={urlId}
ref={urlRef}
autoFocus
aria-label={t("settings.general.management.label")}
placeholder={t(
"settings.general.management.urlPlaceholder",
@@ -201,7 +240,7 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
<Button
type={"button"}
variant={"secondary"}
size={"xs2"}
size={"sm"}
disabled={checking}
onClick={() => onOpenChange(false)}
>
@@ -210,10 +249,10 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
<Button
type={"submit"}
variant={"primary"}
size={"xs2"}
size={"sm"}
loading={checking}
>
{t("profile.dialog.submit")}
{isEdit ? t("profile.edit.submit") : t("profile.dialog.submit")}
</Button>
</DialogActions>
</div>

View File

@@ -1,13 +1,30 @@
import { type KeyboardEvent, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { CircleMinus, LogIn, PlusCircle, Trash2, UserCircle } from "lucide-react";
import {
CircleMinus,
LogIn,
MoreVertical,
PencilLine,
PlusCircle,
Trash2,
UserCircle,
} from "lucide-react";
import type { Profile } from "@bindings/services/models.js";
import { Badge } from "@/components/Badge";
import { Button } from "@/components/buttons/Button";
import HelpText from "@/components/typography/HelpText";
import { ProfileCreationModal } from "@/modules/profiles/ProfileCreationModal";
import {
ProfileCreationModal,
type ProfileFormInitial,
} from "@/modules/profiles/ProfileCreationModal";
import { pickProfileIcon } from "@/modules/profiles/ProfileAvatar";
import { Tooltip } from "@/components/Tooltip";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/DropdownMenu";
import i18next from "@/lib/i18n";
import { useProfile } from "@/contexts/ProfileContext";
import { useConfirm } from "@/contexts/DialogContext";
@@ -31,11 +48,16 @@ export function ProfilesTab() {
switchProfile,
addProfile,
removeProfile,
renameProfile,
logoutProfile,
} = useProfile();
const confirm = useConfirm();
const [newOpen, setNewOpen] = useState(false);
const [editTarget, setEditTarget] = useState<{
profile: Profile;
initial: ProfileFormInitial;
} | null>(null);
const [busy, setBusy] = useState(false);
// Order is held stable so switching only flips the badge, never reorders rows
@@ -118,6 +140,37 @@ export function ProfilesTab() {
});
};
const handleEdit = async (id: string, name: string) => {
await guarded(i18next.t("profile.error.editTitle"), async () => {
const config = await SettingsSvc.GetConfig({ profileName: id, username });
const profile = profiles.find((p) => p.id === id);
if (!profile) return;
setEditTarget({
profile,
initial: { name, managementUrl: config.managementUrl },
});
});
};
const handleSave = async (name: string, managementUrl: string) => {
if (!editTarget) return;
const { profile, initial } = editTarget;
await guarded(i18next.t("profile.error.editTitle"), async () => {
if (name !== initial.name) {
await renameProfile(profile.id, name);
}
if (managementUrl !== initial.managementUrl) {
await SettingsSvc.SetConfig(
new SetConfigParams({
profileName: profile.id,
username,
managementUrl,
}),
);
}
});
};
return (
<div>
<SectionGroup title={t("settings.profiles.section.profiles")}>
@@ -132,6 +185,7 @@ export function ProfilesTab() {
ordered={ordered}
activeProfileId={activeProfileId}
onSwitch={handleSwitch}
onEdit={handleEdit}
onDeregister={handleDeregister}
onDelete={handleDelete}
/>
@@ -168,7 +222,16 @@ export function ProfilesTab() {
<ProfileCreationModal
open={newOpen}
onOpenChange={setNewOpen}
onCreate={handleCreate}
onSubmit={handleCreate}
/>
<ProfileCreationModal
open={editTarget !== null}
onOpenChange={(o) => {
if (!o) setEditTarget(null);
}}
initial={editTarget?.initial}
onSubmit={handleSave}
/>
</div>
);
@@ -178,6 +241,7 @@ type ProfilesTableProps = {
ordered: Profile[];
activeProfileId: string | undefined;
onSwitch: (id: string, name: string) => void;
onEdit: (id: string, name: string) => void;
onDeregister: (id: string, name: string) => void;
onDelete: (id: string, name: string) => void;
};
@@ -186,6 +250,7 @@ const ProfilesTable = ({
ordered,
activeProfileId,
onSwitch,
onEdit,
onDeregister,
onDelete,
}: ProfilesTableProps) => {
@@ -291,6 +356,7 @@ const ProfilesTable = ({
onKeyDown={(e) => handleRowKeyDown(e, index)}
onFocus={() => setFocusedIndex(index)}
onSwitch={() => onSwitch(profile.id, profile.name)}
onEdit={() => onEdit(profile.id, profile.name)}
onDeregister={() => onDeregister(profile.id, profile.name)}
onDelete={() => onDelete(profile.id, profile.name)}
/>
@@ -310,6 +376,7 @@ type ProfileRowProps = {
onKeyDown: (e: KeyboardEvent<HTMLTableRowElement>) => void;
onFocus: () => void;
onSwitch: () => void;
onEdit: () => void;
onDeregister: () => void;
onDelete: () => void;
};
@@ -324,6 +391,7 @@ const ProfileRow = ({
onKeyDown,
onFocus,
onSwitch,
onEdit,
onDeregister,
onDelete,
}: ProfileRowProps) => {
@@ -380,6 +448,7 @@ const ProfileRow = ({
isActive={isActive}
rowFocused={isFocused}
onSwitch={onSwitch}
onEdit={onEdit}
onDeregister={onDeregister}
onDelete={onDelete}
/>
@@ -417,6 +486,7 @@ type RowActionsProps = {
isActive: boolean;
rowFocused: boolean;
onSwitch: () => void;
onEdit: () => void;
onDeregister: () => void;
onDelete: () => void;
};
@@ -428,32 +498,19 @@ const RowActions = ({
isActive,
rowFocused,
onSwitch,
onEdit,
onDeregister,
onDelete,
}: RowActionsProps) => {
const { t } = useTranslation();
const deleteDisabled = isDefault || isActive;
const nonDefaultDeleteLabel = isActive
? t("profile.delete.disabledActive")
: t("profile.selector.delete");
const deleteLabel = isDefault ? t("profile.delete.disabledDefault") : nonDefaultDeleteLabel;
const deleteDisabledReason = isDefault
? t("profile.delete.disabledDefault")
: isActive
? t("profile.delete.disabledActive")
: null;
return (
<div className={"inline-flex items-center gap-1"}>
<ActionIconButton
label={t("profile.selector.deregister")}
icon={CircleMinus}
onClick={onDeregister}
hidden={!canDeregister}
tabbable={rowFocused}
/>
<ActionIconButton
label={deleteLabel}
icon={Trash2}
onClick={onDelete}
variant={"danger"}
disabled={deleteDisabled}
tabbable={rowFocused}
/>
<ActionIconButton
label={t("profile.selector.switchTo")}
icon={LogIn}
@@ -461,10 +518,114 @@ const RowActions = ({
hidden={!canSwitch}
tabbable={rowFocused}
/>
<RowMoreMenu
canDeregister={canDeregister}
deleteDisabled={deleteDisabled}
deleteDisabledReason={deleteDisabledReason}
rowFocused={rowFocused}
onEdit={onEdit}
onDeregister={onDeregister}
onDelete={onDelete}
/>
</div>
);
};
type RowMoreMenuProps = {
canDeregister: boolean;
deleteDisabled: boolean;
deleteDisabledReason: string | null;
rowFocused: boolean;
onEdit: () => void;
onDeregister: () => void;
onDelete: () => void;
};
const RowMoreMenu = ({
canDeregister,
deleteDisabled,
deleteDisabledReason,
rowFocused,
onEdit,
onDeregister,
onDelete,
}: RowMoreMenuProps) => {
const { t } = useTranslation();
const moreLabel = t("profile.selector.moreOptions");
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button
type={"button"}
aria-label={moreLabel}
tabIndex={rowFocused ? 0 : -1}
className={cn(
"inline-flex h-9 w-9 cursor-default items-center justify-center rounded-md outline-none",
"text-nb-gray-400 hover:bg-nb-gray-900 hover:text-nb-gray-100",
"transition-colors duration-150",
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
"data-[state=open]:bg-nb-gray-900 data-[state=open]:text-nb-gray-100",
)}
>
<MoreVertical size={16} aria-hidden={"true"} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align={"end"} sideOffset={4} className={"min-w-36 select-none"}>
<DropdownMenuItem onClick={onEdit}>
<div className={"flex w-full items-center gap-2"}>
<PencilLine size={14} aria-hidden={"true"} />
<span className={"flex-1"}>{t("profile.selector.edit")}</span>
</div>
</DropdownMenuItem>
{canDeregister && (
<DropdownMenuItem onClick={onDeregister}>
<div className={"flex w-full items-center gap-2"}>
<CircleMinus size={14} aria-hidden={"true"} />
<span className={"flex-1"}>{t("profile.selector.deregister")}</span>
</div>
</DropdownMenuItem>
)}
<DeleteMenuItem
disabled={deleteDisabled}
disabledReason={deleteDisabledReason}
onDelete={onDelete}
/>
</DropdownMenuContent>
</DropdownMenu>
);
};
type DeleteMenuItemProps = {
disabled: boolean;
disabledReason: string | null;
onDelete: () => void;
};
const DeleteMenuItem = ({ disabled, disabledReason, onDelete }: DeleteMenuItemProps) => {
const { t } = useTranslation();
const item = (
<DropdownMenuItem
disabled={disabled}
onClick={disabled ? undefined : onDelete}
className={cn(!disabled && "text-red-500 hover:!text-red-500 focus:text-red-500")}
>
<div className={"flex w-full items-center gap-2"}>
<Trash2 size={14} aria-hidden={"true"} />
<span className={"flex-1"}>{t("profile.selector.delete")}</span>
</div>
</DropdownMenuItem>
);
if (!disabled || !disabledReason) return item;
return (
<Tooltip
content={<span className={"block max-w-[260px] leading-snug"}>{disabledReason}</span>}
side={"left"}
>
<span className={"block"}>{item}</span>
</Tooltip>
);
};
type ActionIconButtonProps = {
label: string;
icon: typeof CircleMinus;

View File

@@ -323,6 +323,15 @@
"profile.selector.switchTo": {
"message": "Zu diesem Profil wechseln"
},
"profile.selector.edit": {
"message": "Bearbeiten"
},
"profile.edit.title": {
"message": "Profil bearbeiten"
},
"profile.edit.submit": {
"message": "Änderungen speichern"
},
"profile.dialog.title": {
"message": "Neues Profil"
},
@@ -437,6 +446,9 @@
"profile.error.createTitle": {
"message": "Erstellen des Profils fehlgeschlagen"
},
"profile.error.editTitle": {
"message": "Bearbeiten des Profils fehlgeschlagen"
},
"profile.error.loadTitle": {
"message": "Laden der Profile fehlgeschlagen"
},

View File

@@ -431,6 +431,18 @@
"message": "Switch to this profile",
"description": "Tooltip / label for the action that switches to a profile."
},
"profile.selector.edit": {
"message": "Edit",
"description": "Per-profile menu action: open the edit dialog to rename or change the management server."
},
"profile.edit.title": {
"message": "Edit Profile",
"description": "Title of the dialog for editing an existing profile."
},
"profile.edit.submit": {
"message": "Save Changes",
"description": "Submit button on the edit-profile dialog. Keep short."
},
"profile.dialog.title": {
"message": "Enter Profile Name",
"description": "Title of the dialog for naming a new profile."
@@ -444,8 +456,8 @@
"description": "Helper text under the profile-name field."
},
"profile.dialog.placeholder": {
"message": "e.g. work",
"description": "Example placeholder shown in the profile-name field. 'work' is a sample value; translate it to a natural example."
"message": "e.g. Work",
"description": "Example placeholder shown in the profile-name field. 'Work' is a sample value; translate it to a natural example."
},
"profile.dialog.submit": {
"message": "Add Profile",
@@ -579,6 +591,10 @@
"message": "Create Profile Failed",
"description": "Error-dialog title when creating a profile fails."
},
"profile.error.editTitle": {
"message": "Edit Profile Failed",
"description": "Error-dialog title when editing a profile (rename or management URL change) fails."
},
"profile.error.loadTitle": {
"message": "Load Profiles Failed",
"description": "Error-dialog title when loading profiles fails."

View File

@@ -323,6 +323,15 @@
"profile.selector.switchTo": {
"message": "Cambiar a este perfil"
},
"profile.selector.edit": {
"message": "Editar"
},
"profile.edit.title": {
"message": "Editar perfil"
},
"profile.edit.submit": {
"message": "Guardar cambios"
},
"profile.dialog.title": {
"message": "Introduzca el nombre del perfil"
},
@@ -437,6 +446,9 @@
"profile.error.createTitle": {
"message": "Error al crear el perfil"
},
"profile.error.editTitle": {
"message": "Error al editar el perfil"
},
"profile.error.loadTitle": {
"message": "Error al cargar los perfiles"
},

View File

@@ -323,6 +323,15 @@
"profile.selector.switchTo": {
"message": "Basculer vers ce profil"
},
"profile.selector.edit": {
"message": "Modifier"
},
"profile.edit.title": {
"message": "Modifier le profil"
},
"profile.edit.submit": {
"message": "Enregistrer les modifications"
},
"profile.dialog.title": {
"message": "Saisir le nom du profil"
},
@@ -437,6 +446,9 @@
"profile.error.createTitle": {
"message": "Échec de la création du profil"
},
"profile.error.editTitle": {
"message": "Échec de la modification du profil"
},
"profile.error.loadTitle": {
"message": "Échec du chargement des profils"
},

View File

@@ -323,6 +323,15 @@
"profile.selector.switchTo": {
"message": "Váltás erre a profilra"
},
"profile.selector.edit": {
"message": "Szerkesztés"
},
"profile.edit.title": {
"message": "Profil szerkesztése"
},
"profile.edit.submit": {
"message": "Módosítások mentése"
},
"profile.dialog.title": {
"message": "Új profil"
},
@@ -437,6 +446,9 @@
"profile.error.createTitle": {
"message": "Profil létrehozása sikertelen"
},
"profile.error.editTitle": {
"message": "Profil szerkesztése sikertelen"
},
"profile.error.loadTitle": {
"message": "Profilok betöltése sikertelen"
},

View File

@@ -323,6 +323,15 @@
"profile.selector.switchTo": {
"message": "Passa a questo profilo"
},
"profile.selector.edit": {
"message": "Modifica"
},
"profile.edit.title": {
"message": "Modifica profilo"
},
"profile.edit.submit": {
"message": "Salva modifiche"
},
"profile.dialog.title": {
"message": "Inserisci il nome del profilo"
},
@@ -437,6 +446,9 @@
"profile.error.createTitle": {
"message": "Creazione profilo non riuscita"
},
"profile.error.editTitle": {
"message": "Modifica del profilo non riuscita"
},
"profile.error.loadTitle": {
"message": "Caricamento profili non riuscito"
},

View File

@@ -323,6 +323,15 @@
"profile.selector.switchTo": {
"message": "Alternar para este perfil"
},
"profile.selector.edit": {
"message": "Editar"
},
"profile.edit.title": {
"message": "Editar perfil"
},
"profile.edit.submit": {
"message": "Salvar alterações"
},
"profile.dialog.title": {
"message": "Insira o nome do perfil"
},
@@ -437,6 +446,9 @@
"profile.error.createTitle": {
"message": "Falha ao criar o perfil"
},
"profile.error.editTitle": {
"message": "Falha ao editar o perfil"
},
"profile.error.loadTitle": {
"message": "Falha ao carregar os perfis"
},

View File

@@ -323,6 +323,15 @@
"profile.selector.switchTo": {
"message": "Переключиться на этот профиль"
},
"profile.selector.edit": {
"message": "Изменить"
},
"profile.edit.title": {
"message": "Изменить профиль"
},
"profile.edit.submit": {
"message": "Сохранить изменения"
},
"profile.dialog.title": {
"message": "Введите имя профиля"
},
@@ -437,6 +446,9 @@
"profile.error.createTitle": {
"message": "Не удалось создать профиль"
},
"profile.error.editTitle": {
"message": "Не удалось изменить профиль"
},
"profile.error.loadTitle": {
"message": "Не удалось загрузить профили"
},

View File

@@ -323,6 +323,15 @@
"profile.selector.switchTo": {
"message": "切换到此配置文件"
},
"profile.selector.edit": {
"message": "编辑"
},
"profile.edit.title": {
"message": "编辑配置文件"
},
"profile.edit.submit": {
"message": "保存更改"
},
"profile.dialog.title": {
"message": "输入配置文件名称"
},
@@ -437,6 +446,9 @@
"profile.error.createTitle": {
"message": "创建配置文件失败"
},
"profile.error.editTitle": {
"message": "编辑配置文件失败"
},
"profile.error.loadTitle": {
"message": "加载配置文件失败"
},