Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"dependencies": {
"@tanstack/react-query": "^5.101.2",
"clsx": "^2.1.1",
"i18next": "^26.3.6",
"lucide-react": "^1.24.0",
"pretendard": "^1.3.9",
"react": "^19.2.7",
Expand Down
15 changes: 15 additions & 0 deletions frontend/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

27 changes: 27 additions & 0 deletions frontend/src/components/LocaleProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { useEffect, useState, type ReactNode } from "react";

import { i18n } from "@/locales";

/**
* LocaleProvider makes the module-global `L` Proxy reactive without a page
* reload. `L.x.y` reads the current language on every access, but a module
* global can't tell React to re-render — so this boundary subscribes to
* i18next's `languageChanged` event and remounts its subtree (via a changing
* `key`) when the language switches. The tree then re-renders in place — no
* reload, so no flash — and every `L.x.y` re-resolves to the new language.
*/
export default function LocaleProvider({ children }: { children: ReactNode }) {
const [lang, setLang] = useState(i18n.language);

useEffect(() => {
const onChange = (next: string) => setLang(next);
i18n.on("languageChanged", onChange);
if (i18n.language !== lang) setLang(i18n.language);
return () => {
i18n.off("languageChanged", onChange);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

return <div key={lang}>{children}</div>;
}
11 changes: 6 additions & 5 deletions frontend/src/components/auth/OwnerLockedNotice.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -35,18 +36,18 @@ const OwnerLockedNotice = ({ owner }: { owner?: string }) => {
<div className="border-border bg-panel-solid flex w-100 flex-col gap-8 rounded-lg border p-7 text-center">
<div className="flex items-center justify-center gap-2">
<RuneMark />
<h1 className="text-lg font-semibold">사용할 수 없는 계정</h1>
<h1 className="text-lg font-semibold">{L.auth.ownerLockedTitle}</h1>
</div>
<p className="text-muted-foreground text-md">
이 콘솔은{" "}
{L.auth.ownerLockedPrefix}
{owner ? (
<span className="text-foreground font-medium">{owner}</span>
) : (
"다른 계정"
L.auth.ownerFallback
)}
이(가) 관리하고 있어요.
{L.auth.ownerLockedSuffix}
<br />
이 계정으로는 콘솔을 사용할 수 없습니다.
{L.auth.ownerLockedBody}
</p>
<Button
btnText={BTN_TEXT.signOut}
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/components/drawer/MembershipRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Dropdown from "@/components/elements/Dropdown";
import TableCell from "@/components/table/TableCell";
import TableRow from "@/components/table/TableRow";
import { cn } from "@/utils/cn";
import { L } from "@/locales";
import type { TDropdownOption } from "@/types/commonTypes";

const styles = {
Expand Down Expand Up @@ -46,7 +47,7 @@ const MembershipRow = ({
<Checkbox
checked={checked}
onChange={onCheck}
ariaLabel={`${name} 선택`}
ariaLabel={L.common.selectName(name)}
/>
</TableCell>
<TableCell>
Expand All @@ -56,7 +57,7 @@ const MembershipRow = ({
>
{name}
{changed && (
<em className={styles.changedBadge} aria-label="변경사항 있음">
<em className={styles.changedBadge} aria-label={L.elements.unsavedChanges}>
CHANGED
</em>
)}
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/components/elements/Dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createPortal } from "react-dom";
import { ChevronDown } from "lucide-react";

import { cn } from "@/utils/cn";
import { L } from "@/locales";
import type { TDropdownOption } from "@/types/commonTypes";

const styles = {
Expand Down Expand Up @@ -91,7 +92,7 @@ interface DropdownProps {
const Dropdown = ({
options,
label,
placeholder = "선택",
placeholder = L.common.select,
hint,
error,
value,
Expand Down
7 changes: 4 additions & 3 deletions frontend/src/components/elements/Pagination.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { cn } from "@/utils/cn";
import { L } from "@/locales";

const styles = {
wrap: "flex w-fit items-center gap-2 font-mono text-sm",
Expand Down Expand Up @@ -29,11 +30,11 @@ const Pagination = ({
className,
}: PaginationProps) => {
return (
<nav className={cn(styles.wrap, className)} aria-label="페이지네이션">
<nav className={cn(styles.wrap, className)} aria-label={L.elements.pagination}>
<button
type="button"
className={styles.arrow}
aria-label="이전 페이지"
aria-label={L.elements.prevPage}
disabled={page <= 1}
onClick={() => onChange(page - 1)}
>
Expand All @@ -53,7 +54,7 @@ const Pagination = ({
<button
type="button"
className={styles.arrow}
aria-label="다음 페이지"
aria-label={L.elements.nextPage}
disabled={page >= totalPages}
onClick={() => onChange(page + 1)}
>
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/components/elements/SearchInput.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Search } from "lucide-react";

import { cn } from "@/utils/cn";
import { L } from "@/locales";

const styles = {
wrap: "relative inline-flex h-8 w-full items-center rounded-[8px] border border-border-strong bg-well/70 transition-[border-color,box-shadow] duration-[160ms] focus-within:border-mint/60 focus-within:shadow-[0_0_0_3px] focus-within:shadow-mint/10",
Expand Down Expand Up @@ -29,7 +30,7 @@ interface SearchInputProps {
const SearchInput = ({
value,
onChange,
placeholder = "검색",
placeholder = L.elements.search,
maxLength,
disabled = false,
ariaLabel,
Expand Down Expand Up @@ -60,7 +61,7 @@ const SearchInput = ({
<button
type="button"
className={styles.clear}
aria-label="지우기"
aria-label={L.elements.clear}
onClick={() => onChange("")}
>
Expand Down
29 changes: 29 additions & 0 deletions frontend/src/components/navigation/LanguageToggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Globe } from "lucide-react";

import { getLanguage, setLanguage } from "@/locales";

/**
* LanguageToggle is the always-visible navbar language switch (globe + label,
* the top-right convention users scan for). The label names the language it
* switches TO, in that language's own script, so a user stuck in the wrong
* language can still recognize the way out. setLanguage switches i18next in
* place (no reload — LocaleProvider re-renders). Reads getLanguage() at render
* (not the load-time const) so after a switch it offers the other direction.
*/
const LanguageToggle = () => {
const nextLanguage = getLanguage() === "ko" ? "en" : "ko";
const nextLanguageLabel = getLanguage() === "ko" ? "English" : "한국어";

return (
<button
type="button"
className="text-muted-foreground hover:text-foreground flex cursor-pointer items-center gap-1.5 rounded px-2 py-1 text-sm"
onClick={() => setLanguage(nextLanguage)}
>
<Globe className="size-4" aria-hidden="true" />
{nextLanguageLabel}
</button>
);
};

export default LanguageToggle;
13 changes: 9 additions & 4 deletions frontend/src/components/navigation/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -13,6 +14,7 @@ import {
PATH_LIST,
QUERY_KEYS,
} from "@/constants/commonConstants";
import { L } from "@/locales";
import { useWorkspaceStore } from "@/stores/workspaceStore";

/**
Expand Down Expand Up @@ -64,21 +66,23 @@ const Navbar = () => {
}`}
onClick={workspace ? openModal : undefined}
>
<span className="text-foreground text-sm">워크스페이스</span>
<span className="text-foreground text-sm">
{L.workspace.badgeLabel}
</span>
{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. */
<span className="border-negative text-negative rounded border px-2 py-1 text-xs">
재생성 필요
{L.workspace.badgeRecreate}
</span>
) : 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. */
<span className="border-warning text-warning rounded border px-2 py-1 text-xs">
재연결 필요
{L.workspace.badgeReconnect}
</span>
) : (
<WorkspaceStatus status={workspace.status} />
Expand All @@ -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}
</button>
) : null}
</div>
<LanguageToggle />
{me && <ProfileMenu me={me} onSignOut={handleSignOut} />}
</div>
</div>
Expand Down
7 changes: 4 additions & 3 deletions frontend/src/components/navigation/ProfileMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)}
>
Expand All @@ -68,7 +69,7 @@ const ProfileMenu = ({ me, onSignOut }: ProfileMenuProps) => {
) : (
<img
src={me.avatar}
alt="프로필 이미지"
alt={L.profile.image}
className="size-8 rounded-full object-cover"
onError={() => setImgError(true)}
/>
Expand All @@ -83,7 +84,7 @@ const ProfileMenu = ({ me, onSignOut }: ProfileMenuProps) => {
<div className="text-foreground text-md truncate font-medium">
{me.email}
</div>
<div className="text-subtle text-sm">플랜: Free</div>
<div className="text-subtle text-sm">{L.profile.plan}</div>
<hr className="border-border my-2" />
<TextButton
btnText={BTN_TEXT.signOut}
Expand Down
22 changes: 13 additions & 9 deletions frontend/src/components/navigation/PublicNavbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Link, useLocation, useNavigate } from "react-router";

import Button from "@/components/elements/Button";
import RuneMark from "@/components/elements/RuneMark";
import LanguageToggle from "@/components/navigation/LanguageToggle";
import {
BRAND_WORDMARK,
BTN_TEXT,
Expand Down Expand Up @@ -31,15 +32,18 @@ const PublicNavbar = () => {
<RuneMark />
{BRAND_WORDMARK}
</Link>
{!onLoginPage && (
<Button
btnText={BTN_TEXT.getStarted}
btnSize="sm"
btnColor="mintFilled"
handleClick={() => navigate(PATH_LIST.login)}
className="w-auto"
/>
)}
<div className="flex items-center gap-4">
<LanguageToggle />
{!onLoginPage && (
<Button
btnText={BTN_TEXT.getStarted}
btnSize="sm"
btnColor="mintFilled"
handleClick={() => navigate(PATH_LIST.login)}
className="w-auto"
/>
)}
</div>
</div>
</header>
);
Expand Down
Loading
Loading