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.
Files changed (45) hide show
  1. package/package.json +1 -1
  2. package/template/apps/api/libs/domain/auth/src/auth.service.ts +27 -1
  3. package/template/apps/api/libs/infra/clients/internal/email/dto/email.dto.ts +3 -3
  4. package/template/apps/api/libs/infra/clients/internal/volcengine-tts/dto/tts.dto.ts +4 -4
  5. package/template/apps/api/libs/infra/common/common.module.ts +10 -0
  6. package/template/apps/api/libs/infra/common/config/validation/env.validation.ts +1 -1
  7. package/template/apps/api/libs/infra/common/config/validation/keys.validation.ts +2 -2
  8. package/template/apps/api/libs/infra/common/config/validation/yaml.validation.ts +3 -3
  9. package/template/apps/api/libs/infra/common/decorators/device-info.decorator.ts +58 -0
  10. package/template/apps/api/libs/infra/common/decorators/team-info.decorator.ts +122 -0
  11. package/template/apps/api/libs/infra/common/encryption.service.ts +70 -0
  12. package/template/apps/api/libs/infra/common/index.ts +9 -0
  13. package/template/apps/api/libs/infra/shared-services/email/dto/email.dto.ts +3 -3
  14. package/template/apps/api/libs/infra/shared-services/email/email.service.ts +5 -1
  15. package/template/apps/api/libs/infra/shared-services/notification/index.ts +13 -0
  16. package/template/apps/api/libs/infra/shared-services/notification/notification.module.ts +10 -0
  17. package/template/apps/api/libs/infra/shared-services/notification/notification.service.ts +791 -0
  18. package/template/apps/web/components/client-only.tsx +28 -0
  19. package/template/apps/web/components/index.ts +23 -0
  20. package/template/apps/web/components/layout/app-navbar.tsx +109 -0
  21. package/template/apps/web/components/layout/app-shell.tsx +30 -0
  22. package/template/apps/web/components/layout/app-sidebar.tsx +206 -0
  23. package/template/apps/web/components/layout/index.ts +4 -0
  24. package/template/apps/web/components/layout/locale-switcher.tsx +57 -0
  25. package/template/apps/web/components/runtime-i18n-bridge.tsx +32 -0
  26. package/template/apps/web/components/state-components.tsx +214 -0
  27. package/template/apps/web/config.ts +22 -2
  28. package/template/apps/web/lib/api/cache-config.ts +32 -0
  29. package/template/apps/web/lib/api/contracts/client.ts +43 -1
  30. package/template/apps/web/lib/api/contracts/hooks/analytics.ts +32 -0
  31. package/template/apps/web/lib/api/contracts/hooks/index.ts +41 -2
  32. package/template/apps/web/lib/api/contracts/hooks/message.ts +60 -0
  33. package/template/apps/web/lib/api/contracts/hooks/system.ts +42 -0
  34. package/template/apps/web/lib/api/contracts/hooks/task.ts +54 -0
  35. package/template/apps/web/lib/api/contracts/hooks/user.ts +45 -0
  36. package/template/apps/web/lib/api/contracts/server-client.ts +1 -1
  37. package/template/apps/web/lib/api/prefetch.ts +128 -0
  38. package/template/apps/web/lib/api/query-client.ts +37 -0
  39. package/template/apps/web/lib/i18n/runtime-translator.ts +48 -0
  40. package/template/apps/web/lib/requests.ts +1 -1
  41. package/template/apps/web/providers/app-provider.tsx +1 -1
  42. package/template/apps/web/providers/auth-provider.tsx +228 -0
  43. package/template/apps/web/providers/index.tsx +28 -9
  44. package/template/apps/web/providers/intl-client-provider.tsx +43 -0
  45. 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,4 @@
1
+ export { AppShell } from './app-shell';
2
+ export { AppSidebar } from './app-sidebar';
3
+ export { AppNavbar } from './app-navbar';
4
+ export { LocaleSwitcher } from './locale-switcher';
@@ -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
+ }