@zhang_libo/resource-hub 1.0.2

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 (118) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +80 -0
  3. package/README.ja.md +80 -0
  4. package/README.md +79 -0
  5. package/README.zh-TW.md +80 -0
  6. package/bin/cli.js +10 -0
  7. package/dist/app.d.ts +2 -0
  8. package/dist/app.d.ts.map +1 -0
  9. package/dist/app.js +59 -0
  10. package/dist/app.js.map +1 -0
  11. package/dist/db/index.js +12 -0
  12. package/dist/db/index.js.map +1 -0
  13. package/dist/db/migrate.d.ts +3 -0
  14. package/dist/db/migrate.d.ts.map +1 -0
  15. package/dist/db/migrate.js +169 -0
  16. package/dist/db/migrate.js.map +1 -0
  17. package/dist/db/schema.d.ts +743 -0
  18. package/dist/db/schema.d.ts.map +1 -0
  19. package/dist/db/schema.js +88 -0
  20. package/dist/db/schema.js.map +1 -0
  21. package/dist/i18n.js +309 -0
  22. package/dist/i18n.js.map +1 -0
  23. package/dist/plugins/admin.d.ts +4 -0
  24. package/dist/plugins/admin.d.ts.map +1 -0
  25. package/dist/plugins/admin.js +19 -0
  26. package/dist/plugins/admin.js.map +1 -0
  27. package/dist/plugins/auth.d.ts +4 -0
  28. package/dist/plugins/auth.d.ts.map +1 -0
  29. package/dist/plugins/auth.js +35 -0
  30. package/dist/plugins/auth.js.map +1 -0
  31. package/dist/routes/auth.d.ts +4 -0
  32. package/dist/routes/auth.d.ts.map +1 -0
  33. package/dist/routes/auth.js +352 -0
  34. package/dist/routes/auth.js.map +1 -0
  35. package/dist/routes/categories.d.ts +4 -0
  36. package/dist/routes/categories.d.ts.map +1 -0
  37. package/dist/routes/categories.js +112 -0
  38. package/dist/routes/categories.js.map +1 -0
  39. package/dist/routes/config.d.ts +4 -0
  40. package/dist/routes/config.d.ts.map +1 -0
  41. package/dist/routes/config.js +227 -0
  42. package/dist/routes/config.js.map +1 -0
  43. package/dist/routes/resources.d.ts +4 -0
  44. package/dist/routes/resources.d.ts.map +1 -0
  45. package/dist/routes/resources.js +474 -0
  46. package/dist/routes/resources.js.map +1 -0
  47. package/dist/routes/tags.d.ts +4 -0
  48. package/dist/routes/tags.d.ts.map +1 -0
  49. package/dist/routes/tags.js +37 -0
  50. package/dist/routes/tags.js.map +1 -0
  51. package/dist/routes/users.d.ts +4 -0
  52. package/dist/routes/users.d.ts.map +1 -0
  53. package/dist/routes/users.js +181 -0
  54. package/dist/routes/users.js.map +1 -0
  55. package/dist/services/crypto.js +49 -0
  56. package/dist/services/crypto.js.map +1 -0
  57. package/dist/services/mail.d.ts +16 -0
  58. package/dist/services/mail.d.ts.map +1 -0
  59. package/dist/services/mail.js +33 -0
  60. package/dist/services/mail.js.map +1 -0
  61. package/dist/services/rsa.js +49 -0
  62. package/dist/services/rsa.js.map +1 -0
  63. package/dist/services/token.d.ts +9 -0
  64. package/dist/services/token.d.ts.map +1 -0
  65. package/dist/services/token.js +29 -0
  66. package/dist/services/token.js.map +1 -0
  67. package/dist/types.d.ts +80 -0
  68. package/dist/types.d.ts.map +1 -0
  69. package/dist/types.js +2 -0
  70. package/dist/types.js.map +1 -0
  71. package/package.json +73 -0
  72. package/public/admin/AdminCategories.jsx +310 -0
  73. package/public/admin/AdminConfig.jsx +254 -0
  74. package/public/admin/AdminEmail.jsx +279 -0
  75. package/public/admin/AdminTags.jsx +263 -0
  76. package/public/admin/AdminUsers.jsx +452 -0
  77. package/public/app.jsx +186 -0
  78. package/public/components/ConfirmDialog.jsx +78 -0
  79. package/public/components/DropdownSelect.jsx +281 -0
  80. package/public/components/EmailPreviewModal.jsx +104 -0
  81. package/public/components/EmptyState.jsx +50 -0
  82. package/public/components/Modal.jsx +127 -0
  83. package/public/components/PasswordStrength.jsx +45 -0
  84. package/public/components/Skeleton.jsx +68 -0
  85. package/public/components/Toast.jsx +80 -0
  86. package/public/components/TooltipIconButton.jsx +55 -0
  87. package/public/context/AppContext.jsx +314 -0
  88. package/public/features/BatchResourceModal.jsx +606 -0
  89. package/public/features/ChangePasswordModal.jsx +187 -0
  90. package/public/features/ProfileModal.jsx +170 -0
  91. package/public/features/ResourceCard.jsx +422 -0
  92. package/public/features/ResourceFormModal.jsx +915 -0
  93. package/public/features/ResourceRow.jsx +287 -0
  94. package/public/features/ResourceTimeline.jsx +472 -0
  95. package/public/hooks/useApi.jsx +26 -0
  96. package/public/hooks/useRouter.jsx +35 -0
  97. package/public/index.html +258 -0
  98. package/public/layout/AdminLayout.jsx +167 -0
  99. package/public/layout/AppLayout.jsx +119 -0
  100. package/public/layout/AuthLayout.jsx +503 -0
  101. package/public/layout/Header.jsx +543 -0
  102. package/public/layout/Sidebar.jsx +175 -0
  103. package/public/pages/AdminPage.jsx +30 -0
  104. package/public/pages/ForgotPasswordPage.jsx +93 -0
  105. package/public/pages/HomePage.jsx +2297 -0
  106. package/public/pages/LoginPage.jsx +191 -0
  107. package/public/pages/RegisterPage.jsx +137 -0
  108. package/public/pages/ResetPasswordPage.jsx +169 -0
  109. package/public/pages/SetupPage.jsx +157 -0
  110. package/public/utils/helpers.jsx +152 -0
  111. package/public/utils/i18n.jsx +1374 -0
  112. package/public/utils/preferences.jsx +220 -0
  113. package/public/utils/security.jsx +88 -0
  114. package/public/utils/theme.jsx +24 -0
  115. package/public/vendor/babel.min.js +2 -0
  116. package/public/vendor/lucide-react.min.js +9 -0
  117. package/public/vendor/react-dom.development.js +29869 -0
  118. package/public/vendor/react.development.js +3342 -0
@@ -0,0 +1,258 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-Hans">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>资源导航系统</title>
7
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%230071E3'/%3E%3Ctext x='50%25' y='53%25' text-anchor='middle' dominant-baseline='middle' font-family='Arial,sans-serif' font-size='34' font-weight='700' fill='white'%3ER%3C/text%3E%3C/svg%3E" />
8
+
9
+ <!-- React 18 -->
10
+ <script src="/vendor/react.development.js"></script>
11
+ <script src="/vendor/react-dom.development.js"></script>
12
+
13
+ <!-- Babel Standalone for JSX transpilation -->
14
+ <script src="/vendor/babel.min.js"></script>
15
+
16
+ <!-- Lucide React icons (UMD) -->
17
+ <script>window.react = window.React;</script>
18
+ <script src="/vendor/lucide-react.min.js"></script>
19
+ <script>
20
+ // Expose as `lucide` so all components can use `const { Plus, ... } = lucide`
21
+ window.lucide = window.LucideReact || {};
22
+ </script>
23
+
24
+ <style>
25
+ *, *::before, *::after { box-sizing: border-box; }
26
+
27
+ body {
28
+ margin: 0;
29
+ font-family: 'Inter', 'DM Sans', -apple-system, 'Noto Sans SC', sans-serif;
30
+ background:
31
+ linear-gradient(180deg, color-mix(in srgb, var(--surface-tint) 18%, var(--bg-primary)) 0%, var(--bg-primary) 240px),
32
+ var(--bg-primary);
33
+ color: var(--text-primary);
34
+ min-height: 100vh;
35
+ }
36
+
37
+ :root {
38
+ --bg-primary: #F6F8FB;
39
+ --bg-secondary: #FFFFFF;
40
+ --bg-tertiary: #FBFCFE;
41
+ --surface-elevated: #FFFFFF;
42
+ --surface-muted: #F7F9FC;
43
+ --surface-tint: #EAF1FF;
44
+ --surface-hover: #F4F7FB;
45
+ --text-primary: #132238;
46
+ --text-secondary:#5F6C80;
47
+ --text-tertiary: #74839A;
48
+ --brand: #2563EB;
49
+ --brand-soft: #EAF1FF;
50
+ --brand-strong: #1D4ED8;
51
+ --border: #D7E1EC;
52
+ --border-strong: #C7D3E0;
53
+ --outline-strong:#B8C7D8;
54
+ --control-bg: #FFFFFF;
55
+ --control-bg-muted:#F4F7FB;
56
+ --control-border:#D7E1EC;
57
+ --control-border-strong:#C7D3E0;
58
+ --shadow-control: 0 2px 8px rgba(19,34,56,0.04);
59
+ --shadow-control-hover: 0 8px 20px rgba(19,34,56,0.08);
60
+ --danger: #E5484D;
61
+ --success: #16A34A;
62
+ --shadow-card: 0 6px 16px rgba(19,34,56,0.05);
63
+ --shadow-card-hover: 0 12px 24px rgba(19,34,56,0.08);
64
+ --shadow-modal: 0 28px 72px rgba(19,34,56,0.18);
65
+ --shadow-dropdown: 0 12px 28px rgba(19,34,56,0.10);
66
+ }
67
+
68
+ .dark {
69
+ --bg-primary: #0D141D;
70
+ --bg-secondary: #131C26;
71
+ --bg-tertiary: #1C2734;
72
+ --surface-elevated: #131C26;
73
+ --surface-muted: #1A2633;
74
+ --surface-tint: #132A4D;
75
+ --surface-hover: #1A2431;
76
+ --text-primary: #E6EDF3;
77
+ --text-secondary:#96A3B4;
78
+ --text-tertiary: #7E8C9F;
79
+ --brand: #4A86FF;
80
+ --brand-soft: #193762;
81
+ --brand-strong: #8CB4FF;
82
+ --border: #2C3643;
83
+ --border-strong: #566677;
84
+ --outline-strong:#6C7F95;
85
+ --control-bg: #1A2431;
86
+ --control-bg-muted:#212D3A;
87
+ --control-border:#5D748D;
88
+ --control-border-strong:#7F96B3;
89
+ --shadow-control: 0 2px 6px rgba(0,0,0,0.24);
90
+ --shadow-control-hover: 0 4px 12px rgba(0,0,0,0.34);
91
+ --danger: #FF667A;
92
+ --success: #49C46E;
93
+ --shadow-card: 0 1px 3px rgba(0,0,0,0.32), 0 1px 2px rgba(0,0,0,0.22);
94
+ --shadow-card-hover: 0 18px 32px rgba(0,0,0,0.36);
95
+ --shadow-modal: 0 20px 60px rgba(0,0,0,0.5);
96
+ --shadow-dropdown: 0 4px 16px rgba(0,0,0,0.4);
97
+ }
98
+
99
+ /* Shimmer animation for skeleton screens */
100
+ @keyframes shimmer {
101
+ 0% { background-position: -400px 0; }
102
+ 100% { background-position: 400px 0; }
103
+ }
104
+ .shimmer {
105
+ background: linear-gradient(
106
+ 90deg,
107
+ var(--bg-tertiary) 0%,
108
+ var(--bg-secondary) 50%,
109
+ var(--bg-tertiary) 100%
110
+ );
111
+ background-size: 800px 100%;
112
+ animation: shimmer 1.5s infinite linear;
113
+ }
114
+
115
+ /* Spinner for loading buttons */
116
+ @keyframes spin { to { transform: rotate(360deg); } }
117
+
118
+ /* Smooth scrollbar */
119
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
120
+ ::-webkit-scrollbar-track { background: transparent; }
121
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
122
+
123
+ /* Input / select focus ring */
124
+ input:focus, select:focus, textarea:focus {
125
+ outline: none;
126
+ border-color: var(--brand) !important;
127
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--brand) 16%, transparent);
128
+ }
129
+
130
+ /* Button focus for accessibility (e.g. 注册账号 / 忘记密码) */
131
+ button:focus-visible {
132
+ outline: 2px solid var(--brand);
133
+ outline-offset: 2px;
134
+ }
135
+
136
+ .rh-auth-text-link {
137
+ display: inline-flex;
138
+ align-items: center;
139
+ min-height: 24px;
140
+ padding: 2px 1px;
141
+ border-radius: 6px;
142
+ color: var(--brand-strong);
143
+ font-size: 13px;
144
+ font-weight: 700;
145
+ text-decoration-line: underline;
146
+ text-decoration-thickness: 1px;
147
+ text-underline-offset: 3px;
148
+ text-decoration-color: color-mix(in srgb, var(--brand) 46%, transparent);
149
+ white-space: nowrap;
150
+ transition: color 150ms, text-decoration-color 150ms, background 150ms;
151
+ }
152
+
153
+ .rh-auth-text-link:hover {
154
+ color: var(--brand);
155
+ text-decoration-color: currentColor;
156
+ }
157
+
158
+ .rh-auth-text-link:focus-visible {
159
+ outline: 2px solid var(--brand);
160
+ outline-offset: 2px;
161
+ background: color-mix(in srgb, var(--brand-soft) 58%, transparent);
162
+ }
163
+
164
+ .rh-auth-text-link--strong {
165
+ font-weight: 800;
166
+ text-decoration-thickness: 1.5px;
167
+ }
168
+
169
+ input::placeholder, textarea::placeholder {
170
+ color: color-mix(in srgb, var(--text-tertiary) 92%, transparent);
171
+ opacity: 1;
172
+ }
173
+
174
+ /* 覆盖浏览器 autofill 背景,使后台表单输入框与编辑类别等弹窗输入框视觉一致 */
175
+ input:-webkit-autofill,
176
+ input:-webkit-autofill:hover,
177
+ input:-webkit-autofill:focus {
178
+ -webkit-box-shadow: 0 0 0 30px var(--bg-secondary) inset !important;
179
+ box-shadow: 0 0 0 30px var(--bg-secondary) inset !important;
180
+ }
181
+
182
+ /* 后台系统配置、邮件服务等表单输入框强制白底,与编辑类别弹窗一致 */
183
+ .rh-admin-input {
184
+ background: var(--bg-secondary) !important;
185
+ background-color: var(--bg-secondary) !important;
186
+ }
187
+
188
+ /* 登录/注册/忘记密码/设置向导等认证页输入框强制白底 */
189
+ .rh-auth-input {
190
+ background: var(--bg-secondary) !important;
191
+ background-color: var(--bg-secondary) !important;
192
+ }
193
+ </style>
194
+ </head>
195
+ <body>
196
+ <div id="root"></div>
197
+
198
+ <!-- ① utils -->
199
+ <script type="text/babel" src="/utils/i18n.jsx"></script>
200
+ <script type="text/babel" src="/utils/preferences.jsx"></script>
201
+ <script type="text/babel" src="/utils/theme.jsx"></script>
202
+ <script type="text/babel" src="/utils/helpers.jsx"></script>
203
+ <script type="text/babel" src="/utils/security.jsx"></script>
204
+
205
+ <!-- ② hooks -->
206
+ <script type="text/babel" src="/hooks/useApi.jsx"></script>
207
+ <script type="text/babel" src="/hooks/useRouter.jsx"></script>
208
+
209
+ <!-- ③ context -->
210
+ <script type="text/babel" src="/context/AppContext.jsx"></script>
211
+
212
+ <!-- ④ shared components -->
213
+ <script type="text/babel" src="/components/Toast.jsx"></script>
214
+ <script type="text/babel" src="/components/Modal.jsx"></script>
215
+ <script type="text/babel" src="/components/ConfirmDialog.jsx"></script>
216
+ <script type="text/babel" src="/components/EmailPreviewModal.jsx"></script>
217
+ <script type="text/babel" src="/components/Skeleton.jsx"></script>
218
+ <script type="text/babel" src="/components/EmptyState.jsx"></script>
219
+ <script type="text/babel" src="/components/PasswordStrength.jsx"></script>
220
+ <script type="text/babel" src="/components/DropdownSelect.jsx"></script>
221
+ <script type="text/babel" src="/components/TooltipIconButton.jsx"></script>
222
+
223
+ <!-- ⑤ layout -->
224
+ <script type="text/babel" src="/layout/Header.jsx"></script>
225
+ <script type="text/babel" src="/layout/Sidebar.jsx"></script>
226
+ <script type="text/babel" src="/layout/AppLayout.jsx"></script>
227
+ <script type="text/babel" src="/layout/AuthLayout.jsx"></script>
228
+ <script type="text/babel" src="/layout/AdminLayout.jsx"></script>
229
+
230
+ <!-- ⑥ features -->
231
+ <script type="text/babel" src="/features/ResourceCard.jsx"></script>
232
+ <script type="text/babel" src="/features/ResourceRow.jsx"></script>
233
+ <script type="text/babel" src="/features/ResourceTimeline.jsx"></script>
234
+ <script type="text/babel" src="/features/ResourceFormModal.jsx"></script>
235
+ <script type="text/babel" src="/features/BatchResourceModal.jsx"></script>
236
+ <script type="text/babel" src="/features/ProfileModal.jsx"></script>
237
+ <script type="text/babel" src="/features/ChangePasswordModal.jsx"></script>
238
+
239
+ <!-- ⑦ admin panels -->
240
+ <script type="text/babel" src="/admin/AdminCategories.jsx"></script>
241
+ <script type="text/babel" src="/admin/AdminTags.jsx"></script>
242
+ <script type="text/babel" src="/admin/AdminUsers.jsx"></script>
243
+ <script type="text/babel" src="/admin/AdminConfig.jsx"></script>
244
+ <script type="text/babel" src="/admin/AdminEmail.jsx"></script>
245
+
246
+ <!-- ⑧ pages -->
247
+ <script type="text/babel" src="/pages/SetupPage.jsx"></script>
248
+ <script type="text/babel" src="/pages/LoginPage.jsx"></script>
249
+ <script type="text/babel" src="/pages/RegisterPage.jsx"></script>
250
+ <script type="text/babel" src="/pages/ForgotPasswordPage.jsx"></script>
251
+ <script type="text/babel" src="/pages/ResetPasswordPage.jsx"></script>
252
+ <script type="text/babel" src="/pages/HomePage.jsx"></script>
253
+ <script type="text/babel" src="/pages/AdminPage.jsx"></script>
254
+
255
+ <!-- ⑨ app entry — mounts React root -->
256
+ <script type="text/babel" src="/app.jsx"></script>
257
+ </body>
258
+ </html>
@@ -0,0 +1,167 @@
1
+ // AdminLayout.jsx
2
+ function AdminLayout({ children, activeTab }) {
3
+ const { navigate } = window.useRouter();
4
+ const state = window.useAppState();
5
+ const dispatch = window.useAppDispatch();
6
+ const activeModal = state?.activeModal;
7
+ const closeModal = () => dispatch({ type: 'CLOSE_MODAL' });
8
+ const viewportWidth = window.useViewportWidth();
9
+ const isStacked = viewportWidth < 960;
10
+ const { LayoutGrid, Tag, Users, Settings, Mail, ArrowLeft } = lucide;
11
+
12
+ const navItems = [
13
+ { key: 'categories', label: '类别管理', Icon: LayoutGrid },
14
+ { key: 'tags', label: '标签管理', Icon: Tag },
15
+ { key: 'users', label: '用户管理', Icon: Users },
16
+ { key: 'config', label: '系统配置', Icon: Settings },
17
+ { key: 'email', label: '邮件服务', Icon: Mail },
18
+ ];
19
+
20
+ const sectionTitleStyle = {
21
+ fontSize: '10px',
22
+ fontWeight: 800,
23
+ textTransform: 'uppercase',
24
+ color: 'var(--text-secondary)',
25
+ letterSpacing: '0.08em',
26
+ padding: isStacked ? '0 8px 8px' : '8px 8px 10px',
27
+ };
28
+
29
+ const navButtonStyle = (isSelected) => ({
30
+ display: 'flex',
31
+ alignItems: 'center',
32
+ gap: '10px',
33
+ minHeight: '42px',
34
+ width: '100%',
35
+ borderRadius: '12px',
36
+ padding: '0 12px 0 10px',
37
+ margin: 0,
38
+ border: isSelected
39
+ ? '1px solid color-mix(in srgb, var(--brand) 18%, var(--control-border))'
40
+ : '1px solid transparent',
41
+ cursor: 'pointer',
42
+ textAlign: 'left',
43
+ fontSize: '14px',
44
+ background: isSelected ? 'color-mix(in srgb, var(--brand-soft) 88%, var(--surface-elevated))' : 'transparent',
45
+ color: isSelected ? 'var(--brand-strong)' : 'var(--text-primary)',
46
+ fontWeight: isSelected ? 700 : 600,
47
+ boxShadow: isSelected ? '0 4px 12px color-mix(in srgb, var(--brand) 10%, transparent)' : 'none',
48
+ transition: 'background 150ms, border-color 150ms, color 150ms, box-shadow 150ms',
49
+ });
50
+
51
+ const pageBackground = 'var(--bg-primary)';
52
+ return (
53
+ <div style={{ minHeight: '100vh', background: pageBackground }}>
54
+ <window.Header showSearch={false} />
55
+ <div style={{ display: 'flex', flexDirection: isStacked ? 'column' : 'row', minHeight: 'calc(100vh - 72px)' }}>
56
+ <div style={{
57
+ width: isStacked ? '100%' : '200px',
58
+ flexShrink: 0,
59
+ background: 'var(--bg-tertiary)',
60
+ borderRight: isStacked ? 'none' : '1px solid var(--border)',
61
+ borderBottom: isStacked ? '1px solid var(--border)' : 'none',
62
+ position: isStacked ? 'static' : 'sticky',
63
+ top: isStacked ? 'auto' : '72px',
64
+ height: isStacked ? 'auto' : 'calc(100vh - 72px)',
65
+ overflowY: 'auto',
66
+ }}>
67
+ <div style={{ padding: isStacked ? '12px 8px 0' : '16px 8px 8px' }}>
68
+ <button
69
+ onClick={() => navigate('#/')}
70
+ style={{
71
+ display: 'flex',
72
+ alignItems: 'center',
73
+ gap: '6px',
74
+ background: 'transparent',
75
+ border: '1px solid transparent',
76
+ cursor: 'pointer',
77
+ color: 'var(--text-secondary)',
78
+ fontSize: '13px',
79
+ fontWeight: 600,
80
+ padding: '10px 12px',
81
+ borderRadius: '10px',
82
+ marginBottom: '8px',
83
+ width: '100%',
84
+ textAlign: 'left',
85
+ transition: 'background 150ms, border-color 150ms, color 150ms',
86
+ }}
87
+ onMouseEnter={(e) => {
88
+ e.currentTarget.style.background = 'var(--surface-elevated)';
89
+ e.currentTarget.style.borderColor = 'var(--control-border)';
90
+ e.currentTarget.style.color = 'var(--text-primary)';
91
+ }}
92
+ onMouseLeave={(e) => {
93
+ e.currentTarget.style.background = 'transparent';
94
+ e.currentTarget.style.borderColor = 'transparent';
95
+ e.currentTarget.style.color = 'var(--text-secondary)';
96
+ }}
97
+ >
98
+ <ArrowLeft size={14} />
99
+ 返回首页
100
+ </button>
101
+ <div style={sectionTitleStyle}>后台管理</div>
102
+ </div>
103
+
104
+ <div style={{
105
+ display: isStacked ? 'grid' : 'block',
106
+ gridTemplateColumns: isStacked ? 'repeat(auto-fit, minmax(160px, 1fr))' : 'none',
107
+ gap: isStacked ? '6px' : 0,
108
+ padding: isStacked ? '0 8px 12px' : '0 8px 12px',
109
+ }}>
110
+ {navItems.map(({ key, label, Icon }) => {
111
+ const isSelected = activeTab === key;
112
+ return (
113
+ <button
114
+ key={key}
115
+ onClick={() => navigate('#/admin/' + key)}
116
+ style={navButtonStyle(isSelected)}
117
+ onMouseEnter={(e) => {
118
+ if (!isSelected) {
119
+ e.currentTarget.style.background = 'var(--surface-hover)';
120
+ e.currentTarget.style.borderColor = 'var(--control-border)';
121
+ }
122
+ }}
123
+ onMouseLeave={(e) => {
124
+ e.currentTarget.style.background = isSelected ? 'color-mix(in srgb, var(--brand-soft) 88%, var(--surface-elevated))' : 'transparent';
125
+ e.currentTarget.style.borderColor = isSelected ? 'color-mix(in srgb, var(--brand) 18%, var(--control-border))' : 'transparent';
126
+ }}
127
+ >
128
+ <span style={{ display: 'inline-flex', alignItems: 'center', gap: '10px', minWidth: 0, flex: 1 }}>
129
+ <span
130
+ style={{
131
+ width: '2px',
132
+ height: '16px',
133
+ borderRadius: '999px',
134
+ background: isSelected ? 'var(--brand)' : 'transparent',
135
+ flexShrink: 0,
136
+ }}
137
+ />
138
+ <Icon size={15} style={{ color: isSelected ? 'var(--brand-strong)' : 'var(--text-secondary)' }} />
139
+ <span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
140
+ {label}
141
+ </span>
142
+ </span>
143
+ </button>
144
+ );
145
+ })}
146
+ </div>
147
+ </div>
148
+
149
+ <main style={{
150
+ flex: 1,
151
+ minWidth: 0,
152
+ padding: viewportWidth < 640 ? '16px' : '24px',
153
+ overflowX: 'hidden',
154
+ background: 'transparent',
155
+ }}>
156
+ {children}
157
+ </main>
158
+ </div>
159
+ <window.ToastContainer />
160
+ <window.EmailPreviewModal />
161
+ <window.ProfileModal isOpen={activeModal === 'profile'} onClose={closeModal} />
162
+ <window.ChangePasswordModal isOpen={activeModal === 'changePassword'} onClose={closeModal} />
163
+ </div>
164
+ );
165
+ }
166
+
167
+ window.AdminLayout = AdminLayout;
@@ -0,0 +1,119 @@
1
+ // AppLayout.jsx
2
+ function AppLayout({ children, showSidebar = true, contentPaddingTop = null, headerVariant = 'default', showHeaderSearch = true }) {
3
+ const state = window.useAppState();
4
+ const dispatch = window.useAppDispatch();
5
+ const activeModal = state?.activeModal;
6
+ const closeModal = () => dispatch({ type: 'CLOSE_MODAL' });
7
+ const viewportWidth = window.useViewportWidth();
8
+ const isStacked = viewportWidth < 960;
9
+ const isHomeHeader = headerVariant === 'home';
10
+ const headerShellRef = React.useRef(null);
11
+ const [headerHeight, setHeaderHeight] = React.useState(isHomeHeader ? 72 : 72);
12
+ const layoutDirection = showSidebar && !isStacked ? 'row' : 'column';
13
+
14
+ React.useLayoutEffect(() => {
15
+ const headerShell = headerShellRef.current;
16
+ if (!headerShell) return undefined;
17
+
18
+ const syncHeaderHeight = () => {
19
+ const nextHeight = Math.max(Math.round(headerShell.getBoundingClientRect().height || 0), 1);
20
+ setHeaderHeight((current) => (current === nextHeight ? current : nextHeight));
21
+ };
22
+
23
+ syncHeaderHeight();
24
+
25
+ if (typeof ResizeObserver === 'undefined') {
26
+ window.addEventListener('resize', syncHeaderHeight);
27
+ return () => window.removeEventListener('resize', syncHeaderHeight);
28
+ }
29
+
30
+ const resizeObserver = new ResizeObserver(() => syncHeaderHeight());
31
+ resizeObserver.observe(headerShell);
32
+ window.addEventListener('resize', syncHeaderHeight);
33
+
34
+ return () => {
35
+ resizeObserver.disconnect();
36
+ window.removeEventListener('resize', syncHeaderHeight);
37
+ };
38
+ }, [isHomeHeader, showHeaderSearch, viewportWidth]);
39
+
40
+ const layoutBody = (
41
+ <>
42
+ <div
43
+ ref={headerShellRef}
44
+ data-rh-layout-header-shell
45
+ style={{
46
+ position: 'fixed',
47
+ inset: '0 0 auto 0',
48
+ zIndex: 120,
49
+ }}
50
+ >
51
+ <window.Header variant={headerVariant} showSearch={showHeaderSearch} />
52
+ <div
53
+ data-rh-layout-divider
54
+ style={{
55
+ width: '100%',
56
+ height: '1px',
57
+ background: 'color-mix(in srgb, var(--border-strong) 34%, transparent)',
58
+ opacity: 0.9,
59
+ }}
60
+ />
61
+ </div>
62
+ <div aria-hidden="true" style={{ height: 'var(--app-header-height, 73px)' }} />
63
+ <div style={{ display: 'flex', flexDirection: layoutDirection, minHeight: 'calc(100vh - var(--app-header-height, 73px))' }}>
64
+ {showSidebar && (
65
+ <window.Sidebar isCompact={isStacked} />
66
+ )}
67
+ <main style={{
68
+ flex: 1,
69
+ minWidth: 0,
70
+ position: 'relative',
71
+ paddingTop: contentPaddingTop ?? (viewportWidth < 640 ? '16px' : '24px'),
72
+ paddingRight: viewportWidth < 640 ? '16px' : '24px',
73
+ paddingBottom: viewportWidth < 640 ? '16px' : '24px',
74
+ paddingLeft: viewportWidth < 640 ? '16px' : '24px',
75
+ overflowX: 'hidden',
76
+ boxSizing: 'border-box',
77
+ }}>
78
+ {children}
79
+ </main>
80
+ </div>
81
+ </>
82
+ );
83
+
84
+ return (
85
+ <div
86
+ style={{
87
+ minHeight: '100vh',
88
+ background: 'var(--bg-primary)',
89
+ '--app-header-height': `${headerHeight}px`,
90
+ }}
91
+ >
92
+ {isHomeHeader ? (
93
+ <div
94
+ data-rh-home-top-surface
95
+ style={{
96
+ minHeight: '100vh',
97
+ background: `
98
+ linear-gradient(
99
+ 180deg,
100
+ color-mix(in srgb, var(--surface-tint) 16%, var(--surface-elevated)) 0%,
101
+ color-mix(in srgb, var(--surface-tint) 10%, var(--surface-elevated)) 72px,
102
+ color-mix(in srgb, var(--surface-muted) 8%, var(--bg-primary)) 132px,
103
+ var(--bg-primary) 220px
104
+ )
105
+ `,
106
+ }}
107
+ >
108
+ {layoutBody}
109
+ </div>
110
+ ) : layoutBody}
111
+ <window.ToastContainer />
112
+ <window.EmailPreviewModal />
113
+ <window.ProfileModal isOpen={activeModal === 'profile'} onClose={closeModal} />
114
+ <window.ChangePasswordModal isOpen={activeModal === 'changePassword'} onClose={closeModal} />
115
+ </div>
116
+ );
117
+ }
118
+
119
+ window.AppLayout = AppLayout;