create-pardx-scaffold 0.1.10 → 0.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/template/apps/api/libs/domain/auth/src/auth.service.ts +27 -1
- package/template/apps/api/libs/infra/clients/internal/email/dto/email.dto.ts +3 -3
- package/template/apps/api/libs/infra/clients/internal/volcengine-tts/dto/tts.dto.ts +4 -4
- package/template/apps/api/libs/infra/common/common.module.ts +10 -0
- package/template/apps/api/libs/infra/common/config/validation/env.validation.ts +1 -1
- package/template/apps/api/libs/infra/common/config/validation/keys.validation.ts +2 -2
- package/template/apps/api/libs/infra/common/config/validation/yaml.validation.ts +3 -3
- package/template/apps/api/libs/infra/common/decorators/device-info.decorator.ts +58 -0
- package/template/apps/api/libs/infra/common/decorators/team-info.decorator.ts +122 -0
- package/template/apps/api/libs/infra/common/encryption.service.ts +70 -0
- package/template/apps/api/libs/infra/common/index.ts +9 -0
- package/template/apps/api/libs/infra/shared-services/email/dto/email.dto.ts +3 -3
- package/template/apps/api/libs/infra/shared-services/email/email.service.ts +5 -1
- package/template/apps/api/libs/infra/shared-services/notification/index.ts +13 -0
- package/template/apps/api/libs/infra/shared-services/notification/notification.module.ts +10 -0
- package/template/apps/api/libs/infra/shared-services/notification/notification.service.ts +791 -0
- package/template/apps/web/components/client-only.tsx +28 -0
- package/template/apps/web/components/index.ts +23 -0
- package/template/apps/web/components/layout/app-navbar.tsx +109 -0
- package/template/apps/web/components/layout/app-shell.tsx +30 -0
- package/template/apps/web/components/layout/app-sidebar.tsx +206 -0
- package/template/apps/web/components/layout/index.ts +4 -0
- package/template/apps/web/components/layout/locale-switcher.tsx +57 -0
- package/template/apps/web/components/runtime-i18n-bridge.tsx +32 -0
- package/template/apps/web/components/state-components.tsx +214 -0
- package/template/apps/web/config.ts +22 -2
- package/template/apps/web/lib/api/cache-config.ts +32 -0
- package/template/apps/web/lib/api/contracts/client.ts +43 -1
- package/template/apps/web/lib/api/contracts/hooks/analytics.ts +32 -0
- package/template/apps/web/lib/api/contracts/hooks/index.ts +41 -2
- package/template/apps/web/lib/api/contracts/hooks/message.ts +60 -0
- package/template/apps/web/lib/api/contracts/hooks/system.ts +42 -0
- package/template/apps/web/lib/api/contracts/hooks/task.ts +54 -0
- package/template/apps/web/lib/api/contracts/hooks/user.ts +45 -0
- package/template/apps/web/lib/api/contracts/server-client.ts +1 -1
- package/template/apps/web/lib/api/prefetch.ts +128 -0
- package/template/apps/web/lib/api/query-client.ts +37 -0
- package/template/apps/web/lib/i18n/runtime-translator.ts +48 -0
- package/template/apps/web/lib/requests.ts +1 -1
- package/template/apps/web/providers/app-provider.tsx +1 -1
- package/template/apps/web/providers/auth-provider.tsx +228 -0
- package/template/apps/web/providers/index.tsx +28 -9
- package/template/apps/web/providers/intl-client-provider.tsx +43 -0
- package/template/apps/web/vitest.config.ts +4 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
interface ClientOnlyProps {
|
|
6
|
+
children: React.ReactNode;
|
|
7
|
+
/** Optional fallback during SSR and before mount. Use to avoid layout shift. */
|
|
8
|
+
fallback?: React.ReactNode;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Renders children only after the component has mounted on the client.
|
|
13
|
+
* Use to avoid hydration mismatches for components that rely on client-only
|
|
14
|
+
* APIs or generate different markup on server vs client (e.g. Radix UI useId).
|
|
15
|
+
*/
|
|
16
|
+
export function ClientOnly({ children, fallback = null }: ClientOnlyProps) {
|
|
17
|
+
const [mounted, setMounted] = useState(false);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
setMounted(true);
|
|
21
|
+
}, []);
|
|
22
|
+
|
|
23
|
+
if (!mounted) {
|
|
24
|
+
return <>{fallback}</>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return <>{children}</>;
|
|
28
|
+
}
|
|
@@ -29,3 +29,26 @@ export {
|
|
|
29
29
|
withSuspense,
|
|
30
30
|
withAsyncBoundary,
|
|
31
31
|
} from './suspense-utils';
|
|
32
|
+
|
|
33
|
+
// Client-only rendering
|
|
34
|
+
export { ClientOnly } from './client-only';
|
|
35
|
+
|
|
36
|
+
// Runtime i18n bridge
|
|
37
|
+
export { RuntimeI18nBridge } from './runtime-i18n-bridge';
|
|
38
|
+
|
|
39
|
+
// State components
|
|
40
|
+
export {
|
|
41
|
+
LoadingSpinner,
|
|
42
|
+
ErrorState,
|
|
43
|
+
EmptyState,
|
|
44
|
+
PageLoading,
|
|
45
|
+
PageError,
|
|
46
|
+
} from './state-components';
|
|
47
|
+
|
|
48
|
+
// Layout components
|
|
49
|
+
export {
|
|
50
|
+
AppShell,
|
|
51
|
+
AppSidebar,
|
|
52
|
+
AppNavbar,
|
|
53
|
+
LocaleSwitcher,
|
|
54
|
+
} from './layout';
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { ClientOnly } from '@/components/client-only';
|
|
4
|
+
import { useAuth } from '@/providers';
|
|
5
|
+
import {
|
|
6
|
+
Avatar,
|
|
7
|
+
AvatarFallback,
|
|
8
|
+
AvatarImage,
|
|
9
|
+
Button,
|
|
10
|
+
DropdownMenu,
|
|
11
|
+
DropdownMenuContent,
|
|
12
|
+
DropdownMenuItem,
|
|
13
|
+
DropdownMenuLabel,
|
|
14
|
+
DropdownMenuSeparator,
|
|
15
|
+
DropdownMenuTrigger,
|
|
16
|
+
Separator,
|
|
17
|
+
SidebarTrigger,
|
|
18
|
+
} from '@repo/ui';
|
|
19
|
+
import { LogOut, Search, Settings, User } from 'lucide-react';
|
|
20
|
+
import { useTranslations } from 'next-intl';
|
|
21
|
+
import { Link } from '@/i18n/navigation';
|
|
22
|
+
import { LocaleSwitcher } from './locale-switcher';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Get initials from nickname
|
|
26
|
+
* Returns first character of nickname (uppercase)
|
|
27
|
+
*/
|
|
28
|
+
function getInitials(nickname: string | null | undefined): string {
|
|
29
|
+
if (!nickname) return '';
|
|
30
|
+
return nickname.charAt(0).toUpperCase();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function AppNavbar() {
|
|
34
|
+
const t = useTranslations('navigation.menu');
|
|
35
|
+
const { user, logout } = useAuth();
|
|
36
|
+
|
|
37
|
+
const handleLogout = () => {
|
|
38
|
+
logout();
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const initials = getInitials(user?.nickname);
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<header className="flex h-14 shrink-0 items-center justify-between border-b px-4">
|
|
45
|
+
<div className="flex items-center gap-3">
|
|
46
|
+
{/* Mobile sidebar trigger - only visible on mobile */}
|
|
47
|
+
<SidebarTrigger className="md:hidden -ml-2" />
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<div className="flex items-center gap-1">
|
|
51
|
+
<Button variant="ghost" size="icon" className="size-8">
|
|
52
|
+
<Search className="size-4" />
|
|
53
|
+
<span className="sr-only">{t('search')}</span>
|
|
54
|
+
</Button>
|
|
55
|
+
<Separator orientation="vertical" className="mx-1 h-4" />
|
|
56
|
+
<LocaleSwitcher />
|
|
57
|
+
<Separator orientation="vertical" className="mx-1 h-4" />
|
|
58
|
+
<ClientOnly
|
|
59
|
+
fallback={
|
|
60
|
+
<Button variant="ghost" size="icon" className="size-8 rounded-full">
|
|
61
|
+
<Avatar className="size-8">
|
|
62
|
+
<AvatarFallback className="text-xs">
|
|
63
|
+
<User className="size-4" />
|
|
64
|
+
</AvatarFallback>
|
|
65
|
+
</Avatar>
|
|
66
|
+
</Button>
|
|
67
|
+
}
|
|
68
|
+
>
|
|
69
|
+
<DropdownMenu>
|
|
70
|
+
<DropdownMenuTrigger asChild>
|
|
71
|
+
<Button variant="ghost" size="icon" className="size-8 rounded-full">
|
|
72
|
+
<Avatar className="size-8">
|
|
73
|
+
<AvatarImage
|
|
74
|
+
src={user?.headerImg || ''}
|
|
75
|
+
alt={user?.nickname || 'User'}
|
|
76
|
+
/>
|
|
77
|
+
<AvatarFallback className="text-xs">
|
|
78
|
+
{initials || <User className="size-4" />}
|
|
79
|
+
</AvatarFallback>
|
|
80
|
+
</Avatar>
|
|
81
|
+
</Button>
|
|
82
|
+
</DropdownMenuTrigger>
|
|
83
|
+
<DropdownMenuContent align="end" className="w-48">
|
|
84
|
+
<DropdownMenuLabel>{user?.nickname || t('account')}</DropdownMenuLabel>
|
|
85
|
+
<DropdownMenuSeparator />
|
|
86
|
+
<DropdownMenuItem asChild>
|
|
87
|
+
<Link href="/settings">
|
|
88
|
+
<User className="mr-2 size-4" />
|
|
89
|
+
{t('profile')}
|
|
90
|
+
</Link>
|
|
91
|
+
</DropdownMenuItem>
|
|
92
|
+
<DropdownMenuItem asChild>
|
|
93
|
+
<Link href="/settings">
|
|
94
|
+
<Settings className="mr-2 size-4" />
|
|
95
|
+
{t('settings')}
|
|
96
|
+
</Link>
|
|
97
|
+
</DropdownMenuItem>
|
|
98
|
+
<DropdownMenuSeparator />
|
|
99
|
+
<DropdownMenuItem variant="destructive" onClick={handleLogout}>
|
|
100
|
+
<LogOut className="mr-2 size-4" />
|
|
101
|
+
{t('logout')}
|
|
102
|
+
</DropdownMenuItem>
|
|
103
|
+
</DropdownMenuContent>
|
|
104
|
+
</DropdownMenu>
|
|
105
|
+
</ClientOnly>
|
|
106
|
+
</div>
|
|
107
|
+
</header>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { SidebarInset, SidebarProvider } from '@repo/ui';
|
|
4
|
+
import { AppNavbar } from './app-navbar';
|
|
5
|
+
import { AppSidebar } from './app-sidebar';
|
|
6
|
+
|
|
7
|
+
interface AppShellProps {
|
|
8
|
+
children: React.ReactNode;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function AppShell({ children }: AppShellProps) {
|
|
12
|
+
return (
|
|
13
|
+
<SidebarProvider defaultOpen={true}>
|
|
14
|
+
<div className="flex h-screen flex-col w-full">
|
|
15
|
+
{/* Full-width navbar at top */}
|
|
16
|
+
<AppNavbar />
|
|
17
|
+
|
|
18
|
+
{/* Sidebar and content below navbar */}
|
|
19
|
+
<div className="flex flex-1 overflow-hidden [&_[data-slot=sidebar-container]]:top-14 [&_[data-slot=sidebar-container]]:h-[calc(100svh-3.5rem)] [&_[data-slot=sidebar-wrapper]]:min-h-0">
|
|
20
|
+
<AppSidebar />
|
|
21
|
+
<SidebarInset>
|
|
22
|
+
<main className="flex h-full flex-1 flex-col overflow-hidden">
|
|
23
|
+
<div className="h-full flex-1 overflow-auto">{children}</div>
|
|
24
|
+
</main>
|
|
25
|
+
</SidebarInset>
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
</SidebarProvider>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useMemo, useCallback, memo } from 'react';
|
|
4
|
+
import type { ComponentType } from 'react';
|
|
5
|
+
import {
|
|
6
|
+
Sidebar,
|
|
7
|
+
SidebarContent,
|
|
8
|
+
SidebarFooter,
|
|
9
|
+
SidebarGroup,
|
|
10
|
+
SidebarGroupContent,
|
|
11
|
+
SidebarGroupLabel,
|
|
12
|
+
SidebarMenu,
|
|
13
|
+
SidebarMenuButton,
|
|
14
|
+
SidebarMenuItem,
|
|
15
|
+
SidebarTrigger,
|
|
16
|
+
} from '@repo/ui';
|
|
17
|
+
import { cn } from '@repo/ui/lib/utils';
|
|
18
|
+
import {
|
|
19
|
+
LayoutDashboard,
|
|
20
|
+
Settings,
|
|
21
|
+
type LucideIcon,
|
|
22
|
+
} from 'lucide-react';
|
|
23
|
+
import { useTranslations } from 'next-intl';
|
|
24
|
+
import { Link, usePathname } from '@/i18n/navigation';
|
|
25
|
+
import { useApp, useIsAdmin } from '@/providers';
|
|
26
|
+
|
|
27
|
+
interface NavGroup {
|
|
28
|
+
groupKey: string;
|
|
29
|
+
items: NavItem[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface NavItem {
|
|
33
|
+
titleKey?: string;
|
|
34
|
+
title?: string;
|
|
35
|
+
href: string;
|
|
36
|
+
icon: ComponentType<{ className?: string }>;
|
|
37
|
+
adminOnly?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Navigation configuration for sidebar
|
|
42
|
+
* Customize this for your application
|
|
43
|
+
*/
|
|
44
|
+
const navGroups: NavGroup[] = [
|
|
45
|
+
{
|
|
46
|
+
groupKey: 'main',
|
|
47
|
+
items: [
|
|
48
|
+
{
|
|
49
|
+
titleKey: 'dashboard',
|
|
50
|
+
href: '/dashboard',
|
|
51
|
+
icon: LayoutDashboard,
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
groupKey: 'settings',
|
|
57
|
+
items: [
|
|
58
|
+
{
|
|
59
|
+
titleKey: 'settings',
|
|
60
|
+
href: '/settings',
|
|
61
|
+
icon: Settings,
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
// Memoized nav item component
|
|
68
|
+
const NavItemComponent = memo(function NavItemComponent({
|
|
69
|
+
item,
|
|
70
|
+
isActive,
|
|
71
|
+
title,
|
|
72
|
+
}: {
|
|
73
|
+
item: NavItem;
|
|
74
|
+
isActive: boolean;
|
|
75
|
+
title: string;
|
|
76
|
+
}) {
|
|
77
|
+
return (
|
|
78
|
+
<SidebarMenuItem key={item.href}>
|
|
79
|
+
<SidebarMenuButton
|
|
80
|
+
asChild
|
|
81
|
+
isActive={isActive}
|
|
82
|
+
tooltip={title}
|
|
83
|
+
className={cn(
|
|
84
|
+
'relative h-9 rounded-md transition-all duration-150',
|
|
85
|
+
isActive
|
|
86
|
+
? ['bg-primary/20', 'font-medium', 'shadow-sm']
|
|
87
|
+
: ['hover:bg-accent'],
|
|
88
|
+
)}
|
|
89
|
+
>
|
|
90
|
+
<Link
|
|
91
|
+
href={item.href}
|
|
92
|
+
className="flex items-center gap-2 group-data-[collapsible=icon]:justify-center"
|
|
93
|
+
>
|
|
94
|
+
<item.icon
|
|
95
|
+
className={cn(
|
|
96
|
+
'size-4 shrink-0',
|
|
97
|
+
isActive ? 'text-primary' : '',
|
|
98
|
+
)}
|
|
99
|
+
/>
|
|
100
|
+
<span className="truncate group-data-[collapsible=icon]:hidden">
|
|
101
|
+
{title}
|
|
102
|
+
</span>
|
|
103
|
+
{isActive && (
|
|
104
|
+
<span className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-5 bg-primary rounded-r-full" />
|
|
105
|
+
)}
|
|
106
|
+
</Link>
|
|
107
|
+
</SidebarMenuButton>
|
|
108
|
+
</SidebarMenuItem>
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
export function AppSidebar() {
|
|
113
|
+
const t = useTranslations('navigation.menu');
|
|
114
|
+
const pathname = usePathname();
|
|
115
|
+
const { brandName, brandLogo } = useApp();
|
|
116
|
+
const isAdmin = useIsAdmin();
|
|
117
|
+
|
|
118
|
+
const currentPath = useMemo(() => pathname || '/', [pathname]);
|
|
119
|
+
|
|
120
|
+
// Memoize filtered groups based on admin permission
|
|
121
|
+
const filteredGroups = useMemo(() => {
|
|
122
|
+
return navGroups
|
|
123
|
+
.map((group) => ({
|
|
124
|
+
...group,
|
|
125
|
+
items: group.items.filter((item) => !item.adminOnly || isAdmin),
|
|
126
|
+
}))
|
|
127
|
+
.filter((group) => group.items.length > 0);
|
|
128
|
+
}, [isAdmin]);
|
|
129
|
+
|
|
130
|
+
// Memoize title getter
|
|
131
|
+
const getItemTitle = useCallback(
|
|
132
|
+
(item: NavItem) => item.title || (item.titleKey ? t(item.titleKey) : ''),
|
|
133
|
+
[t],
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
// Memoize active state checker
|
|
137
|
+
const isItemActive = useCallback(
|
|
138
|
+
(href: string) =>
|
|
139
|
+
currentPath === href || currentPath.startsWith(`${href}/`),
|
|
140
|
+
[currentPath],
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
// Render a navigation group
|
|
144
|
+
const renderNavGroup = useCallback(
|
|
145
|
+
(group: NavGroup & { items: NavItem[] }) => (
|
|
146
|
+
<SidebarGroup key={group.groupKey}>
|
|
147
|
+
<SidebarGroupLabel className="uppercase tracking-widest text-[10px] font-medium px-3 mb-1">
|
|
148
|
+
{t(
|
|
149
|
+
`group${group.groupKey.charAt(0).toUpperCase()}${group.groupKey.slice(1)}` as Parameters<
|
|
150
|
+
typeof t
|
|
151
|
+
>[0],
|
|
152
|
+
)}
|
|
153
|
+
</SidebarGroupLabel>
|
|
154
|
+
<SidebarGroupContent>
|
|
155
|
+
<SidebarMenu className="gap-1 px-2">
|
|
156
|
+
{group.items.map((item) => (
|
|
157
|
+
<NavItemComponent
|
|
158
|
+
key={item.href}
|
|
159
|
+
item={item}
|
|
160
|
+
isActive={isItemActive(item.href)}
|
|
161
|
+
title={getItemTitle(item)}
|
|
162
|
+
/>
|
|
163
|
+
))}
|
|
164
|
+
</SidebarMenu>
|
|
165
|
+
</SidebarGroupContent>
|
|
166
|
+
</SidebarGroup>
|
|
167
|
+
),
|
|
168
|
+
[t, isItemActive, getItemTitle],
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
return (
|
|
172
|
+
<Sidebar collapsible="icon" variant="sidebar">
|
|
173
|
+
<SidebarContent className="pt-4">
|
|
174
|
+
{filteredGroups.map(renderNavGroup)}
|
|
175
|
+
</SidebarContent>
|
|
176
|
+
|
|
177
|
+
<SidebarFooter className="border-t border-sidebar-border/50 relative">
|
|
178
|
+
<div className="p-3">
|
|
179
|
+
<div className="flex items-center">
|
|
180
|
+
{/* Expanded state: show logo + brand name */}
|
|
181
|
+
<div className="flex items-center gap-2 group-data-[collapsible=icon]:hidden">
|
|
182
|
+
<span className="font-semibold truncate">
|
|
183
|
+
{brandName}
|
|
184
|
+
</span>
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
{/* Collapsed state: only show brand name initial */}
|
|
188
|
+
<div className="hidden group-data-[collapsible=icon]:flex items-center justify-center w-full">
|
|
189
|
+
<span className="font-semibold">
|
|
190
|
+
{brandName.charAt(0)}
|
|
191
|
+
</span>
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
|
|
196
|
+
{/* Collapse/expand button, fixed at bottom right */}
|
|
197
|
+
<div
|
|
198
|
+
className="absolute -right-3"
|
|
199
|
+
style={{ bottom: 'calc(0.75rem + 50px)' }}
|
|
200
|
+
>
|
|
201
|
+
<SidebarTrigger className="size-8 bg-accent" />
|
|
202
|
+
</div>
|
|
203
|
+
</SidebarFooter>
|
|
204
|
+
</Sidebar>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useLocale, useTranslations } from 'next-intl';
|
|
4
|
+
import { Globe } from 'lucide-react';
|
|
5
|
+
import {
|
|
6
|
+
Button,
|
|
7
|
+
DropdownMenu,
|
|
8
|
+
DropdownMenuContent,
|
|
9
|
+
DropdownMenuItem,
|
|
10
|
+
DropdownMenuTrigger,
|
|
11
|
+
} from '@repo/ui';
|
|
12
|
+
import { useRouter, usePathname } from '@/i18n/navigation';
|
|
13
|
+
import { locales, localeNames, localeFlags, type Locale } from '@/i18n/config';
|
|
14
|
+
import { ClientOnly } from '@/components/client-only';
|
|
15
|
+
|
|
16
|
+
export function LocaleSwitcher() {
|
|
17
|
+
const locale = useLocale() as Locale;
|
|
18
|
+
const ta = useTranslations('navigation.a11y');
|
|
19
|
+
const router = useRouter();
|
|
20
|
+
const pathname = usePathname();
|
|
21
|
+
|
|
22
|
+
const handleLocaleChange = (newLocale: Locale) => {
|
|
23
|
+
router.replace(pathname, { locale: newLocale });
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const triggerButton = (
|
|
27
|
+
<Button variant="ghost" size="icon" className="size-8">
|
|
28
|
+
<Globe className="size-4" />
|
|
29
|
+
<span className="sr-only">{ta('switchLanguage')}</span>
|
|
30
|
+
</Button>
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<ClientOnly fallback={triggerButton}>
|
|
35
|
+
<DropdownMenu>
|
|
36
|
+
<DropdownMenuTrigger asChild>
|
|
37
|
+
<Button variant="ghost" size="icon" className="size-8">
|
|
38
|
+
<Globe className="size-4" />
|
|
39
|
+
<span className="sr-only">{ta('switchLanguage')}</span>
|
|
40
|
+
</Button>
|
|
41
|
+
</DropdownMenuTrigger>
|
|
42
|
+
<DropdownMenuContent align="end">
|
|
43
|
+
{locales.map((loc) => (
|
|
44
|
+
<DropdownMenuItem
|
|
45
|
+
key={loc}
|
|
46
|
+
onClick={() => handleLocaleChange(loc)}
|
|
47
|
+
className={locale === loc ? 'bg-accent' : ''}
|
|
48
|
+
>
|
|
49
|
+
<span className="mr-2">{localeFlags[loc]}</span>
|
|
50
|
+
{localeNames[loc]}
|
|
51
|
+
</DropdownMenuItem>
|
|
52
|
+
))}
|
|
53
|
+
</DropdownMenuContent>
|
|
54
|
+
</DropdownMenu>
|
|
55
|
+
</ClientOnly>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react';
|
|
4
|
+
import { useTranslations } from 'next-intl';
|
|
5
|
+
import { registerIntlNamespace } from '@/lib/i18n/runtime-translator';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 向非 React 模块(如 ts-rest fetch、deprecation-warning)注册 errors / common 的翻译函数。
|
|
9
|
+
*/
|
|
10
|
+
export function RuntimeI18nBridge() {
|
|
11
|
+
const tErrors = useTranslations('errors');
|
|
12
|
+
const tCommon = useTranslations('common');
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
registerIntlNamespace('errors', (key, values) =>
|
|
16
|
+
values && Object.keys(values).length > 0
|
|
17
|
+
? tErrors(key, values)
|
|
18
|
+
: tErrors(key),
|
|
19
|
+
);
|
|
20
|
+
registerIntlNamespace('common', (key, values) =>
|
|
21
|
+
values && Object.keys(values).length > 0
|
|
22
|
+
? tCommon(key, values)
|
|
23
|
+
: tCommon(key),
|
|
24
|
+
);
|
|
25
|
+
return () => {
|
|
26
|
+
registerIntlNamespace('errors', null);
|
|
27
|
+
registerIntlNamespace('common', null);
|
|
28
|
+
};
|
|
29
|
+
}, [tErrors, tCommon]);
|
|
30
|
+
|
|
31
|
+
return null;
|
|
32
|
+
}
|