Skip to content

Commit 9ed4e68

Browse files
committed
2 parents 82f0867 + 405fd47 commit 9ed4e68

10 files changed

Lines changed: 315 additions & 46 deletions

File tree

apps/account/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"preview": "vite preview"
1818
},
1919
"dependencies": {
20+
"@object-ui/i18n": "^3.3.2",
2021
"@objectstack/client": "workspace:*",
2122
"@objectstack/client-react": "workspace:*",
2223
"@objectstack/spec": "workspace:*",

apps/account/src/components/account-sidebar.tsx

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
User,
3737
Users,
3838
} from 'lucide-react';
39+
import { useObjectTranslation } from '@object-ui/i18n';
3940
import {
4041
Sidebar,
4142
SidebarContent,
@@ -64,13 +65,14 @@ interface NavItem {
6465
}
6566

6667
const ACCOUNT_ITEMS: NavItem[] = [
67-
{ to: '/account/profile', label: 'Profile', icon: User },
68-
{ to: '/account/security', label: 'Security', icon: Shield },
69-
{ to: '/account/sessions', label: 'Sessions', icon: Monitor },
70-
{ to: '/account/two-factor', label: 'Two-Factor', icon: ShieldCheck },
68+
{ to: '/account/profile', label: 'profile', icon: User },
69+
{ to: '/account/security', label: 'security', icon: Shield },
70+
{ to: '/account/sessions', label: 'sessions', icon: Monitor },
71+
{ to: '/account/two-factor', label: 'twoFactor', icon: ShieldCheck },
7172
];
7273

7374
export function AccountSidebar() {
75+
const { t } = useObjectTranslation();
7476
const { pathname } = useLocation();
7577
const { organizations } = useOrganizations();
7678

@@ -87,18 +89,18 @@ export function AccountSidebar() {
8789
<Sidebar collapsible="icon" className="h-full">
8890
<SidebarContent>
8991
<SidebarGroup>
90-
<SidebarGroupLabel>Account</SidebarGroupLabel>
92+
<SidebarGroupLabel>{t('sidebar.groups.account')}</SidebarGroupLabel>
9193
<SidebarGroupContent>
9294
<SidebarMenu>
9395
{ACCOUNT_ITEMS.map((item) => {
9496
const Icon = item.icon;
9597
const isActive = normalised === item.to || normalised.startsWith(`${item.to}/`);
9698
return (
9799
<SidebarMenuItem key={item.to}>
98-
<SidebarMenuButton asChild isActive={isActive} tooltip={item.label}>
100+
<SidebarMenuButton asChild isActive={isActive} tooltip={t(`sidebar.items.${item.label}`)}>
99101
<Link to={item.to}>
100102
<Icon className="size-4" />
101-
<span>{item.label}</span>
103+
<span>{t(`sidebar.items.${item.label}`)}</span>
102104
</Link>
103105
</SidebarMenuButton>
104106
</SidebarMenuItem>
@@ -111,18 +113,18 @@ export function AccountSidebar() {
111113
<SidebarSeparator />
112114

113115
<SidebarGroup>
114-
<SidebarGroupLabel>Organization</SidebarGroupLabel>
116+
<SidebarGroupLabel>{t('sidebar.groups.organization')}</SidebarGroupLabel>
115117
<SidebarGroupContent>
116118
<SidebarMenu>
117119
<SidebarMenuItem>
118120
<SidebarMenuButton
119121
asChild
120122
isActive={pathname === '/organizations'}
121-
tooltip="Overview"
123+
tooltip={t('sidebar.items.overview')}
122124
>
123125
<Link to="/organizations">
124126
<Building2 className="size-4" />
125-
<span>Overview</span>
127+
<span>{t('sidebar.items.overview')}</span>
126128
</Link>
127129
</SidebarMenuButton>
128130
</SidebarMenuItem>
@@ -132,23 +134,23 @@ export function AccountSidebar() {
132134
<SidebarMenuButton
133135
asChild
134136
isActive={pathname === `/organizations/${activeOrgId}/general`}
135-
tooltip="General"
137+
tooltip={t('sidebar.items.general')}
136138
>
137139
<Link to="/organizations/$orgId/general" params={{ orgId: activeOrgId }}>
138140
<Settings className="size-4" />
139-
<span>General</span>
141+
<span>{t('sidebar.items.general')}</span>
140142
</Link>
141143
</SidebarMenuButton>
142144
</SidebarMenuItem>
143145
<SidebarMenuItem>
144146
<SidebarMenuButton
145147
asChild
146148
isActive={pathname === `/organizations/${activeOrgId}/members`}
147-
tooltip="Members"
149+
tooltip={t('sidebar.items.members')}
148150
>
149151
<Link to="/organizations/$orgId/members" params={{ orgId: activeOrgId }}>
150152
<Users className="size-4" />
151-
<span>Members</span>
153+
<span>{t('sidebar.items.members')}</span>
152154
</Link>
153155
</SidebarMenuButton>
154156
</SidebarMenuItem>
@@ -161,18 +163,18 @@ export function AccountSidebar() {
161163
<SidebarSeparator />
162164

163165
<SidebarGroup>
164-
<SidebarGroupLabel>Developer</SidebarGroupLabel>
166+
<SidebarGroupLabel>{t('sidebar.groups.developer')}</SidebarGroupLabel>
165167
<SidebarGroupContent>
166168
<SidebarMenu>
167169
<SidebarMenuItem>
168170
<SidebarMenuButton
169171
asChild
170172
isActive={pathname.startsWith('/account/oauth-applications')}
171-
tooltip="OAuth Apps"
173+
tooltip={t('sidebar.items.oauthApps')}
172174
>
173175
<Link to="/account/oauth-applications">
174176
<KeyRound className="size-4" />
175-
<span>OAuth Apps</span>
177+
<span>{t('sidebar.items.oauthApps')}</span>
176178
</Link>
177179
</SidebarMenuButton>
178180
</SidebarMenuItem>
@@ -192,13 +194,15 @@ export function AccountSidebar() {
192194
}
193195

194196
function CollapseButton() {
197+
const { t } = useObjectTranslation();
195198
const { state, toggleSidebar } = useSidebar();
196199
const collapsed = state === 'collapsed';
200+
const label = collapsed ? t('sidebar.expand') : t('sidebar.collapse');
197201
return (
198202
<SidebarMenuButton
199203
onClick={toggleSidebar}
200-
tooltip={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
201-
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
204+
tooltip={label}
205+
aria-label={label}
202206
>
203207
<PanelLeft className="size-4" />
204208
</SidebarMenuButton>
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* LocaleToggle — language switcher button in the top bar.
5+
*
6+
* Mirrors the ThemeToggle pattern. Persists the selected language to
7+
* localStorage under `account.lang` so the choice survives reloads.
8+
*/
9+
10+
import { Languages } from 'lucide-react';
11+
import { useObjectTranslation } from '@object-ui/i18n';
12+
import { Button } from '@/components/ui/button';
13+
import {
14+
DropdownMenu,
15+
DropdownMenuContent,
16+
DropdownMenuItem,
17+
DropdownMenuTrigger,
18+
} from '@/components/ui/dropdown-menu';
19+
import { SUPPORTED_LANGUAGES, type SupportedLanguage } from '@/i18n';
20+
21+
const LABELS: Record<SupportedLanguage, string> = {
22+
en: 'English',
23+
'zh-CN': '中文',
24+
};
25+
26+
export function LocaleToggle() {
27+
const { t, language, changeLanguage } = useObjectTranslation();
28+
29+
async function handleSelect(lang: SupportedLanguage) {
30+
await changeLanguage(lang);
31+
try {
32+
localStorage.setItem('account.lang', lang);
33+
} catch {
34+
/* ignore storage errors */
35+
}
36+
}
37+
38+
return (
39+
<DropdownMenu>
40+
<DropdownMenuTrigger asChild>
41+
<Button
42+
variant="ghost"
43+
size="icon"
44+
className="h-8 w-8"
45+
aria-label={t('common.language')}
46+
>
47+
<Languages className="h-4 w-4" />
48+
<span className="sr-only">{t('common.language')}</span>
49+
</Button>
50+
</DropdownMenuTrigger>
51+
<DropdownMenuContent align="end">
52+
{SUPPORTED_LANGUAGES.map((lang) => (
53+
<DropdownMenuItem
54+
key={lang}
55+
onSelect={() => void handleSelect(lang)}
56+
className={lang === language ? 'font-medium' : undefined}
57+
>
58+
{LABELS[lang]}
59+
</DropdownMenuItem>
60+
))}
61+
</DropdownMenuContent>
62+
</DropdownMenu>
63+
);
64+
}

apps/account/src/components/top-bar.tsx

Lines changed: 28 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@
55
* route. Mirrors the Studio TopBar layout:
66
*
77
* - Left: brand → mobile sidebar toggle → breadcrumb (inferred from URL)
8-
* - Right: theme toggle + UserMenu (avatar dropdown w/ sign-out)
8+
* - Right: locale toggle + theme toggle + UserMenu (avatar dropdown w/ sign-out)
99
*/
1010

1111
import { Link, useLocation, useParams } from '@tanstack/react-router';
1212
import { useMemo } from 'react';
1313
import { UserCircle2 } from 'lucide-react';
14+
import { useObjectTranslation } from '@object-ui/i18n';
1415
import {
1516
Breadcrumb,
1617
BreadcrumbItem,
@@ -20,16 +21,17 @@ import {
2021
} from '@/components/ui/breadcrumb';
2122
import { Separator } from '@/components/ui/separator';
2223
import { SidebarTrigger } from '@/components/ui/sidebar';
24+
import { LocaleToggle } from '@/components/locale-toggle';
2325
import { ThemeToggle } from '@/components/theme-toggle';
2426
import { UserMenu } from '@/components/user-menu';
2527
import { OrganizationSwitcher } from '@/components/organization-switcher';
2628

27-
function AccountBrand() {
29+
function AccountBrand({ label }: { label: string }) {
2830
return (
2931
<Link
3032
to="/account"
3133
className="flex h-7 w-7 items-center justify-center rounded-md bg-primary text-primary-foreground hover:opacity-90"
32-
aria-label="Account home"
34+
aria-label={label}
3335
>
3436
<UserCircle2 className="h-4 w-4" />
3537
</Link>
@@ -41,33 +43,35 @@ function SlashDivider() {
4143
}
4244

4345
export function TopBar() {
46+
const { t } = useObjectTranslation();
4447
const location = useLocation();
4548
const params = useParams({ strict: false }) as { orgId?: string; invitationId?: string };
4649

4750
const breadcrumbs = useMemo<Array<{ label: string }>>(() => {
4851
const p = location.pathname;
52+
const tb = (k: string) => t(`topBar.breadcrumb.${k}`);
4953
if (p.startsWith('/account')) {
50-
const items: Array<{ label: string }> = [{ label: 'Account' }];
51-
if (p === '/account/profile') items.push({ label: 'Profile' });
52-
else if (p === '/account/security') items.push({ label: 'Security' });
53-
else if (p === '/account/sessions') items.push({ label: 'Sessions' });
54-
else if (p === '/account/two-factor') items.push({ label: 'Two-Factor' });
54+
const items: Array<{ label: string }> = [{ label: tb('account') }];
55+
if (p === '/account/profile') items.push({ label: tb('profile') });
56+
else if (p === '/account/security') items.push({ label: tb('security') });
57+
else if (p === '/account/sessions') items.push({ label: tb('sessions') });
58+
else if (p === '/account/two-factor') items.push({ label: tb('twoFactor') });
5559
return items;
5660
}
57-
if (p === '/organizations/new') return [{ label: 'Organizations' }, { label: 'New' }];
61+
if (p === '/organizations/new') return [{ label: tb('organizations') }, { label: tb('new') }];
5862
if (params.orgId) {
5963
const tail = p.endsWith('/general')
60-
? 'General'
64+
? tb('general')
6165
: p.endsWith('/members')
62-
? 'Members'
63-
: 'Settings';
64-
return [{ label: 'Organizations' }, { label: tail }];
66+
? tb('members')
67+
: tb('settings');
68+
return [{ label: tb('organizations') }, { label: tail }];
6569
}
66-
if (p === '/organizations' || p.startsWith('/organizations/')) return [{ label: 'Organizations' }];
67-
if (p.startsWith('/accept-invitation/')) return [{ label: 'Accept invitation' }];
68-
if (p.startsWith('/auth/device')) return [{ label: 'Device authorization' }];
69-
return [{ label: 'Account' }];
70-
}, [location.pathname, params.orgId]);
70+
if (p === '/organizations' || p.startsWith('/organizations/')) return [{ label: tb('organizations') }];
71+
if (p.startsWith('/accept-invitation/')) return [{ label: tb('acceptInvitation') }];
72+
if (p.startsWith('/auth/device')) return [{ label: tb('deviceAuthorization') }];
73+
return [{ label: tb('account') }];
74+
}, [location.pathname, params.orgId, t]);
7175

7276
return (
7377
<header className="flex h-12 shrink-0 items-center justify-between gap-2 border-b px-2 sm:px-4">
@@ -76,9 +80,9 @@ export function TopBar() {
7680
<div className="sm:hidden">
7781
<SidebarTrigger className="h-9 w-9" />
7882
</div>
79-
<AccountBrand />
83+
<AccountBrand label={t('topBar.accountHome')} />
8084
<SlashDivider />
81-
<span className="hidden text-sm font-medium sm:inline">ObjectStack Account</span>
85+
<span className="hidden text-sm font-medium sm:inline">{t('topBar.brand')}</span>
8286
<div className="hidden items-center gap-1 sm:flex">
8387
<Separator orientation="vertical" className="mx-2 h-4" />
8488
<OrganizationSwitcher />
@@ -106,8 +110,11 @@ export function TopBar() {
106110
</div>
107111
</div>
108112

109-
{/* Right: theme + user */}
113+
{/* Right: locale + theme + user */}
110114
<div className="flex items-center gap-1 sm:gap-2">
115+
<div className="hidden sm:block">
116+
<LocaleToggle />
117+
</div>
111118
<div className="hidden sm:block">
112119
<ThemeToggle />
113120
</div>

apps/account/src/components/user-menu.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import { useNavigate } from '@tanstack/react-router';
99
import { LogOut, User as UserIcon, Building2 } from 'lucide-react';
10+
import { useObjectTranslation } from '@object-ui/i18n';
1011
import {
1112
DropdownMenu,
1213
DropdownMenuContent,
@@ -27,6 +28,7 @@ function initials(name?: string | null, email?: string | null): string {
2728
}
2829

2930
export function UserMenu() {
31+
const { t } = useObjectTranslation();
3032
const navigate = useNavigate();
3133
const { user, loading, logout } = useSession();
3234

@@ -42,7 +44,7 @@ export function UserMenu() {
4244
className="h-8"
4345
onClick={() => navigate({ to: '/login' })}
4446
>
45-
Sign in
47+
{t('userMenu.signIn')}
4648
</Button>
4749
);
4850
}
@@ -60,7 +62,7 @@ export function UserMenu() {
6062
<DropdownMenuTrigger asChild>
6163
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full">
6264
<Avatar className="h-7 w-7">
63-
{user.image ? <AvatarImage src={user.image} alt={user.name ?? user.email ?? 'User'} /> : null}
65+
{user.image ? <AvatarImage src={user.image} alt={user.name ?? user.email ?? t('userMenu.user')} /> : null}
6466
<AvatarFallback className="text-[11px]">
6567
{initials(user.name, user.email)}
6668
</AvatarFallback>
@@ -79,16 +81,16 @@ export function UserMenu() {
7981
<DropdownMenuSeparator />
8082
<DropdownMenuItem onSelect={() => navigate({ to: '/account' })}>
8183
<UserIcon className="mr-2 h-3.5 w-3.5" />
82-
Account
84+
{t('userMenu.account')}
8385
</DropdownMenuItem>
8486
<DropdownMenuItem onSelect={() => navigate({ to: '/organizations' })}>
8587
<Building2 className="mr-2 h-3.5 w-3.5" />
86-
Organizations
88+
{t('userMenu.organizations')}
8789
</DropdownMenuItem>
8890
<DropdownMenuSeparator />
8991
<DropdownMenuItem onSelect={handleLogout}>
9092
<LogOut className="mr-2 h-3.5 w-3.5" />
91-
Sign out
93+
{t('userMenu.signOut')}
9294
</DropdownMenuItem>
9395
</DropdownMenuContent>
9496
</DropdownMenu>

0 commit comments

Comments
 (0)