From 85c50fad39c2014a0ac478624535ff18bc4ae84e Mon Sep 17 00:00:00 2001 From: Matthew Laszcz Date: Fri, 24 Jul 2026 08:22:52 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat(frontend):=20bilingual=20UI=20?= =?UTF-8?q?=E2=80=94=20static=20en/ko=20locales=20with=20language=20toggle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move every user-visible Korean string into src/locales (ko.ts verbatim, en.ts translated). The locale is chosen once at page load (localStorage rc_lang override, else browser language) and exported as L; components read plain constants — no i18n library, no runtime lookup. ko.ts is typed against en.ts's shape so the two languages cannot drift apart silently. Parameterized strings are per-language functions so Korean counters and English word order each read naturally. A globe LanguageToggle (labeled with the language it switches to) sits in both navbars — signed-in and signed-out — and persists + reloads. Tests are pinned to Korean in setup.ts since the suite asserts the original copy; locales.test.ts covers the English side and structural parity. Dev-only fixtures (UITestPage, teamsDummyData) and code comments intentionally keep their Korean. Co-Authored-By: Claude Fable 5 --- .../src/components/auth/OwnerLockedNotice.tsx | 11 +- .../src/components/drawer/MembershipRow.tsx | 5 +- frontend/src/components/elements/Dropdown.tsx | 3 +- .../src/components/elements/Pagination.tsx | 7 +- .../src/components/elements/SearchInput.tsx | 5 +- .../components/navigation/LanguageToggle.tsx | 28 ++ frontend/src/components/navigation/Navbar.tsx | 13 +- .../src/components/navigation/ProfileMenu.tsx | 7 +- .../components/navigation/PublicNavbar.tsx | 22 +- .../src/components/teams/AddMemberModal.tsx | 17 +- .../src/components/teams/CreateTeamModal.tsx | 15 +- .../src/components/teams/DeleteTeamModal.tsx | 33 +- frontend/src/components/teams/OrgChart.tsx | 7 +- .../teams/RemoveMembershipModal.tsx | 13 +- .../src/components/teams/RenameTeamModal.tsx | 5 +- .../teams/RoleChangeConfirmModal.tsx | 7 +- .../src/components/teams/TreeDetailView.tsx | 122 +++--- frontend/src/components/teams/teamOptions.ts | 4 +- .../src/components/toast/ToastContainer.tsx | 3 +- frontend/src/components/tree/TeamTree.tsx | 5 +- frontend/src/components/tree/TreeNode.tsx | 7 +- .../components/update/UpdateFloatingCard.tsx | 19 +- .../users/CancelInvitationModal.tsx | 5 +- .../components/users/InviteMemberModal.tsx | 36 +- .../components/users/MemberDeleteModal.tsx | 13 +- .../components/users/MemberDetailDrawer.tsx | 83 ++-- .../users/MembershipRemoveModal.tsx | 11 +- .../users/RoleChangeConfirmModal.tsx | 9 +- .../users/SessionDeactivateModal.tsx | 5 +- .../src/components/users/invitePreview.ts | 7 +- .../components/workspace/WorkspaceModal.tsx | 44 +- frontend/src/constants/commonConstants.ts | 92 +---- frontend/src/constants/styleConstants.ts | 31 +- .../src/locales/__tests__/locales.test.ts | 49 +++ frontend/src/locales/en.ts | 386 ++++++++++++++++++ frontend/src/locales/index.ts | 37 ++ frontend/src/locales/ko.ts | 373 +++++++++++++++++ frontend/src/pages/LoginPage.tsx | 7 +- frontend/src/pages/SessionsPage.tsx | 35 +- frontend/src/pages/TeamsPage.tsx | 35 +- frontend/src/pages/UsersPage.tsx | 80 ++-- frontend/src/pages/WorkspacePage.tsx | 21 +- frontend/src/test/setup.ts | 6 + frontend/src/utils/username.ts | 5 +- 44 files changed, 1312 insertions(+), 416 deletions(-) create mode 100644 frontend/src/components/navigation/LanguageToggle.tsx create mode 100644 frontend/src/locales/__tests__/locales.test.ts create mode 100644 frontend/src/locales/en.ts create mode 100644 frontend/src/locales/index.ts create mode 100644 frontend/src/locales/ko.ts diff --git a/frontend/src/components/auth/OwnerLockedNotice.tsx b/frontend/src/components/auth/OwnerLockedNotice.tsx index aa14caa..8259ce2 100644 --- a/frontend/src/components/auth/OwnerLockedNotice.tsx +++ b/frontend/src/components/auth/OwnerLockedNotice.tsx @@ -6,6 +6,7 @@ import RuneMark from "@/components/elements/RuneMark"; import PublicNavbar from "@/components/navigation/PublicNavbar"; import { postLogout } from "@/api/authAPIs"; import { BTN_TEXT, PATH_LIST, QUERY_KEYS } from "@/constants/commonConstants"; +import { L } from "@/locales"; /** * OwnerLockedNotice is the soft-block screen shown when a signed-in account is @@ -35,18 +36,18 @@ const OwnerLockedNotice = ({ owner }: { owner?: string }) => {
-

사용할 수 없는 계정

+

{L.auth.ownerLockedTitle}

- 이 콘솔은{" "} + {L.auth.ownerLockedPrefix} {owner ? ( {owner} ) : ( - "다른 계정" + L.auth.ownerFallback )} - 이(가) 관리하고 있어요. + {L.auth.ownerLockedSuffix}
- 이 계정으로는 콘솔을 사용할 수 없습니다. + {L.auth.ownerLockedBody}

+ ); +}; + +export default LanguageToggle; diff --git a/frontend/src/components/navigation/Navbar.tsx b/frontend/src/components/navigation/Navbar.tsx index bae937e..cfc8e37 100644 --- a/frontend/src/components/navigation/Navbar.tsx +++ b/frontend/src/components/navigation/Navbar.tsx @@ -3,6 +3,7 @@ import { useQueryClient } from "@tanstack/react-query"; import RuneMark from "@/components/elements/RuneMark"; import WorkspaceStatus from "@/components/elements/WorkspaceStatus"; +import LanguageToggle from "@/components/navigation/LanguageToggle"; import ProfileMenu from "@/components/navigation/ProfileMenu"; import WorkspaceModal from "@/components/workspace/WorkspaceModal"; import { useSessionQuery } from "@/hooks/queries/useSessionQuery"; @@ -13,6 +14,7 @@ import { PATH_LIST, QUERY_KEYS, } from "@/constants/commonConstants"; +import { L } from "@/locales"; import { useWorkspaceStore } from "@/stores/workspaceStore"; /** @@ -64,21 +66,23 @@ const Navbar = () => { }`} onClick={workspace ? openModal : undefined} > - 워크스페이스 + + {L.workspace.badgeLabel} + {workspace ? ( workspace.orphaned ? ( /* Reinstall detected: the workspace no longer matches this console. Flag it so the badge doesn't read as healthy; clicking opens the modal's 재생성 prompt. */ - 재생성 필요 + {L.workspace.badgeRecreate} ) : workspace.reconnectRequired ? ( /* Data-plane credential expired: the cloud workspace is fine but the local engine link is stale. Flag it (not the healthy pill); clicking opens the modal's 재연결 prompt. */ - 재연결 필요 + {L.workspace.badgeReconnect} ) : ( @@ -89,10 +93,11 @@ const Navbar = () => { className="text-muted-foreground hover:text-foreground cursor-pointer rounded border px-2 py-1 text-xs" onClick={() => navigate(PATH_LIST.workspace)} > - 워크스페이스 없음 + {L.workspace.badgeNone} ) : null}
+ {me && } diff --git a/frontend/src/components/navigation/ProfileMenu.tsx b/frontend/src/components/navigation/ProfileMenu.tsx index ba66f14..d2455c7 100644 --- a/frontend/src/components/navigation/ProfileMenu.tsx +++ b/frontend/src/components/navigation/ProfileMenu.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import TextButton from "@/components/elements/TextButton"; import { BTN_TEXT } from "@/constants/commonConstants"; +import { L } from "@/locales"; interface ProfileMenuProps { // avatar is optional: the backend omits it when the principal has no picture, @@ -59,7 +60,7 @@ const ProfileMenu = ({ me, onSignOut }: ProfileMenuProps) => { type="button" aria-haspopup="menu" aria-expanded={open} - aria-label="프로필 메뉴" + aria-label={L.profile.menu} className="block cursor-pointer rounded-full" onClick={() => setOpen((v) => !v)} > @@ -68,7 +69,7 @@ const ProfileMenu = ({ me, onSignOut }: ProfileMenuProps) => { ) : ( 프로필 이미지 setImgError(true)} /> @@ -83,7 +84,7 @@ const ProfileMenu = ({ me, onSignOut }: ProfileMenuProps) => {
{me.email}
-
플랜: Free
+
{L.profile.plan}

{ {BRAND_WORDMARK} - {!onLoginPage && ( -