diff --git a/.github/workflows/frontend-ui.yml b/.github/workflows/frontend-ui.yml index 1290abe8a..32b5ceb65 100644 --- a/.github/workflows/frontend-ui.yml +++ b/.github/workflows/frontend-ui.yml @@ -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 diff --git a/client/ui/frontend/package.json b/client/ui/frontend/package.json index f0f4813f2..3131b36cd 100644 --- a/client/ui/frontend/package.json +++ b/client/ui/frontend/package.json @@ -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", diff --git a/client/ui/frontend/src/components/DropdownMenu.tsx b/client/ui/frontend/src/components/DropdownMenu.tsx index fe6fddb80..b48379576 100644 --- a/client/ui/frontend/src/components/DropdownMenu.tsx +++ b/client/ui/frontend/src/components/DropdownMenu.tsx @@ -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, )} diff --git a/client/ui/frontend/src/components/dialog/ConfirmModal.tsx b/client/ui/frontend/src/components/dialog/ConfirmModal.tsx index 4ae46a272..241a69ec9 100644 --- a/client/ui/frontend/src/components/dialog/ConfirmModal.tsx +++ b/client/ui/frontend/src/components/dialog/ConfirmModal.tsx @@ -61,7 +61,7 @@ export const ConfirmModal = ({ diff --git a/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx b/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx index b6c5c50e3..4049f0610 100644 --- a/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx +++ b/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx @@ -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 (
@@ -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() { + + { + if (!o) setEditTarget(null); + }} + initial={editTarget?.initial} + onSubmit={handleSave} />
); @@ -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) => 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 (
-
); }; +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 ( + + + + + + +
+ + {t("profile.selector.edit")} +
+
+ {canDeregister && ( + +
+ + {t("profile.selector.deregister")} +
+
+ )} + +
+
+ ); +}; + +type DeleteMenuItemProps = { + disabled: boolean; + disabledReason: string | null; + onDelete: () => void; +}; + +const DeleteMenuItem = ({ disabled, disabledReason, onDelete }: DeleteMenuItemProps) => { + const { t } = useTranslation(); + const item = ( + +
+ + {t("profile.selector.delete")} +
+
+ ); + if (!disabled || !disabledReason) return item; + return ( + {disabledReason}} + side={"left"} + > + {item} + + ); +}; + type ActionIconButtonProps = { label: string; icon: typeof CircleMinus; diff --git a/client/ui/i18n/locales/de/common.json b/client/ui/i18n/locales/de/common.json index ea59c99bb..82f34ebf3 100644 --- a/client/ui/i18n/locales/de/common.json +++ b/client/ui/i18n/locales/de/common.json @@ -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" }, diff --git a/client/ui/i18n/locales/en/common.json b/client/ui/i18n/locales/en/common.json index bced359c9..1553e7f2f 100644 --- a/client/ui/i18n/locales/en/common.json +++ b/client/ui/i18n/locales/en/common.json @@ -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." diff --git a/client/ui/i18n/locales/es/common.json b/client/ui/i18n/locales/es/common.json index 8594f4b9e..6276dd7f5 100644 --- a/client/ui/i18n/locales/es/common.json +++ b/client/ui/i18n/locales/es/common.json @@ -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" }, diff --git a/client/ui/i18n/locales/fr/common.json b/client/ui/i18n/locales/fr/common.json index 47c9ba8e7..f51a1ded6 100644 --- a/client/ui/i18n/locales/fr/common.json +++ b/client/ui/i18n/locales/fr/common.json @@ -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" }, diff --git a/client/ui/i18n/locales/hu/common.json b/client/ui/i18n/locales/hu/common.json index 8cba56346..37dd61d0d 100644 --- a/client/ui/i18n/locales/hu/common.json +++ b/client/ui/i18n/locales/hu/common.json @@ -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" }, diff --git a/client/ui/i18n/locales/it/common.json b/client/ui/i18n/locales/it/common.json index 1cc67f23f..3224be972 100644 --- a/client/ui/i18n/locales/it/common.json +++ b/client/ui/i18n/locales/it/common.json @@ -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" }, diff --git a/client/ui/i18n/locales/pt/common.json b/client/ui/i18n/locales/pt/common.json index fc3e9ed29..75b977204 100644 --- a/client/ui/i18n/locales/pt/common.json +++ b/client/ui/i18n/locales/pt/common.json @@ -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" }, diff --git a/client/ui/i18n/locales/ru/common.json b/client/ui/i18n/locales/ru/common.json index 9feba5d99..b9dcc0af7 100644 --- a/client/ui/i18n/locales/ru/common.json +++ b/client/ui/i18n/locales/ru/common.json @@ -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": "Не удалось загрузить профили" }, diff --git a/client/ui/i18n/locales/zh-CN/common.json b/client/ui/i18n/locales/zh-CN/common.json index 7dec5c59d..6c3f8fcda 100644 --- a/client/ui/i18n/locales/zh-CN/common.json +++ b/client/ui/i18n/locales/zh-CN/common.json @@ -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": "加载配置文件失败" },