@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,68 @@
1
+ // Skeleton.jsx
2
+
3
+ // Inject shimmer keyframes once
4
+ (function() {
5
+ if (document.getElementById('skeleton-shimmer-style')) return;
6
+ const style = document.createElement('style');
7
+ style.id = 'skeleton-shimmer-style';
8
+ style.textContent = '@keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }';
9
+ document.head.appendChild(style);
10
+ })();
11
+
12
+ function Skeleton({ rows = 1, type = 'row' }) {
13
+ const shimmerStyle = {
14
+ background: 'linear-gradient(90deg, var(--bg-tertiary) 25%, var(--bg-secondary) 50%, var(--bg-tertiary) 75%)',
15
+ backgroundSize: '200% 100%',
16
+ animation: 'shimmer 1.5s infinite',
17
+ borderRadius: '6px',
18
+ };
19
+
20
+ if (type === 'card') {
21
+ return React.createElement('div', {
22
+ style: { display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '16px' }
23
+ },
24
+ Array.from({ length: rows }).map((_, i) =>
25
+ React.createElement('div', {
26
+ key: i,
27
+ style: {
28
+ background: 'var(--bg-primary)',
29
+ border: '1px solid var(--border)',
30
+ borderRadius: '12px',
31
+ padding: '16px',
32
+ }
33
+ },
34
+ React.createElement('div', { style: { display: 'flex', gap: '10px', marginBottom: '12px' } },
35
+ React.createElement('div', { style: { ...shimmerStyle, width: '32px', height: '32px', borderRadius: '6px', flexShrink: 0 } }),
36
+ React.createElement('div', { style: { ...shimmerStyle, flex: 1, height: '18px', marginTop: '7px' } })
37
+ ),
38
+ React.createElement('div', { style: { ...shimmerStyle, height: '12px', marginBottom: '8px', width: '60%' } }),
39
+ React.createElement('div', { style: { ...shimmerStyle, height: '13px', marginBottom: '6px' } }),
40
+ React.createElement('div', { style: { ...shimmerStyle, height: '13px', marginBottom: '12px', width: '80%' } }),
41
+ React.createElement('div', { style: { display: 'flex', gap: '6px' } },
42
+ React.createElement('div', { style: { ...shimmerStyle, width: '40px', height: '20px', borderRadius: '4px' } }),
43
+ React.createElement('div', { style: { ...shimmerStyle, width: '40px', height: '20px', borderRadius: '4px' } })
44
+ )
45
+ )
46
+ )
47
+ );
48
+ }
49
+
50
+ return React.createElement('div', null,
51
+ Array.from({ length: rows }).map((_, i) =>
52
+ React.createElement('div', {
53
+ key: i,
54
+ style: {
55
+ display: 'flex', alignItems: 'center', gap: '12px',
56
+ padding: '12px 0',
57
+ borderBottom: '1px solid var(--border)',
58
+ }
59
+ },
60
+ React.createElement('div', { style: { ...shimmerStyle, width: '24px', height: '24px', borderRadius: '4px', flexShrink: 0 } }),
61
+ React.createElement('div', { style: { ...shimmerStyle, width: `${60 + (i % 3) * 15}%`, height: '16px' } }),
62
+ React.createElement('div', { style: { ...shimmerStyle, width: '80px', height: '16px', marginLeft: 'auto' } })
63
+ )
64
+ )
65
+ );
66
+ }
67
+
68
+ window.Skeleton = Skeleton;
@@ -0,0 +1,80 @@
1
+ // Toast.jsx
2
+ const { useEffect, useRef } = React;
3
+
4
+ // Single toast item
5
+ function ToastItem({ toast, onRemove }) {
6
+ const { X, CheckCircle, AlertCircle, Info } = lucide;
7
+ const timerRef = useRef(null);
8
+ const [visible, setVisible] = React.useState(false);
9
+
10
+ useEffect(() => {
11
+ // Trigger enter animation
12
+ requestAnimationFrame(() => setVisible(true));
13
+ timerRef.current = setTimeout(() => {
14
+ setVisible(false);
15
+ setTimeout(() => onRemove(toast.id), 300);
16
+ }, 3000);
17
+ return () => clearTimeout(timerRef.current);
18
+ }, []);
19
+
20
+ const iconMap = {
21
+ success: React.createElement(CheckCircle, { size: 16, style: { color: 'var(--success)', flexShrink: 0 } }),
22
+ error: React.createElement(AlertCircle, { size: 16, style: { color: 'var(--danger)', flexShrink: 0 } }),
23
+ info: React.createElement(Info, { size: 16, style: { color: 'var(--brand)', flexShrink: 0 } }),
24
+ };
25
+
26
+ return React.createElement('div', {
27
+ style: {
28
+ display: 'flex',
29
+ alignItems: 'center',
30
+ gap: '10px',
31
+ width: '320px',
32
+ padding: '12px 16px',
33
+ background: 'var(--bg-primary)',
34
+ border: '1px solid var(--border)',
35
+ borderRadius: '10px',
36
+ boxShadow: 'var(--shadow-dropdown)',
37
+ marginTop: '8px',
38
+ transform: visible ? 'translateX(0)' : 'translateX(120%)',
39
+ opacity: visible ? 1 : 0,
40
+ transition: 'transform 300ms cubic-bezier(0.4,0,0.2,1), opacity 300ms cubic-bezier(0.4,0,0.2,1)',
41
+ }
42
+ },
43
+ iconMap[toast.type] || iconMap.info,
44
+ React.createElement('span', {
45
+ style: { flex: 1, fontSize: '14px', color: 'var(--text-primary)' }
46
+ }, toast.message),
47
+ React.createElement('button', {
48
+ onClick: () => { setVisible(false); setTimeout(() => onRemove(toast.id), 300); },
49
+ style: { background: 'none', border: 'none', cursor: 'pointer', padding: '2px', color: 'var(--text-secondary)' }
50
+ }, React.createElement(X, { size: 14 }))
51
+ );
52
+ }
53
+
54
+ // Container - reads from AppContext
55
+ function ToastContainer() {
56
+ const state = window.useAppState();
57
+ const dispatch = window.useAppDispatch();
58
+ if (!state) return null;
59
+
60
+ const removeToast = (id) => dispatch({ type: 'REMOVE_TOAST', id });
61
+
62
+ return React.createElement('div', {
63
+ style: {
64
+ position: 'fixed',
65
+ bottom: '24px',
66
+ right: '24px',
67
+ zIndex: 9999,
68
+ display: 'flex',
69
+ flexDirection: 'column',
70
+ alignItems: 'flex-end',
71
+ }
72
+ },
73
+ state.toasts.map(toast =>
74
+ React.createElement(ToastItem, { key: toast.id, toast, onRemove: removeToast })
75
+ )
76
+ );
77
+ }
78
+
79
+ window.ToastContainer = ToastContainer;
80
+ window.ToastItem = ToastItem;
@@ -0,0 +1,55 @@
1
+ function TooltipIconButton({ label, children, buttonStyle, tooltipOffset = 8, placement = 'top', ...buttonProps }) {
2
+ const [visible, setVisible] = React.useState(false);
3
+ const tooltipPositionStyle = placement === 'left'
4
+ ? {
5
+ right: `calc(100% + ${tooltipOffset}px)`,
6
+ top: '50%',
7
+ transform: 'translateY(-50%)',
8
+ }
9
+ : {
10
+ bottom: `calc(100% + ${tooltipOffset}px)`,
11
+ left: '50%',
12
+ transform: 'translateX(-50%)',
13
+ };
14
+
15
+ return (
16
+ <div style={{ position: 'relative', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
17
+ <button
18
+ aria-label={label}
19
+ title={label}
20
+ onMouseEnter={() => setVisible(true)}
21
+ onMouseLeave={() => setVisible(false)}
22
+ onFocus={() => setVisible(true)}
23
+ onBlur={() => setVisible(false)}
24
+ {...buttonProps}
25
+ style={buttonStyle}
26
+ >
27
+ {children}
28
+ </button>
29
+ {visible && (
30
+ <div
31
+ role="tooltip"
32
+ style={{
33
+ position: 'absolute',
34
+ ...tooltipPositionStyle,
35
+ padding: '6px 10px',
36
+ borderRadius: '10px',
37
+ background: 'color-mix(in srgb, var(--surface-elevated) 94%, var(--control-bg-muted))',
38
+ border: '1px solid var(--control-border)',
39
+ color: 'var(--text-primary)',
40
+ fontSize: '12px',
41
+ fontWeight: 600,
42
+ whiteSpace: 'nowrap',
43
+ pointerEvents: 'none',
44
+ boxShadow: 'var(--shadow-control-hover)',
45
+ zIndex: 20,
46
+ }}
47
+ >
48
+ {label}
49
+ </div>
50
+ )}
51
+ </div>
52
+ );
53
+ }
54
+
55
+ window.TooltipIconButton = TooltipIconButton;
@@ -0,0 +1,314 @@
1
+ // AppContext — global state via useReducer
2
+ const { createContext, useContext, useReducer } = React;
3
+
4
+ const AppStateContext = createContext(null);
5
+ const AppDispatchContext = createContext(null);
6
+
7
+ const initialUiPreferences = window.uiPreferences?.getInitialUiState?.() || {
8
+ locale: window.i18n?.detectBrowserLocale?.() || 'zh-Hans',
9
+ theme: localStorage.getItem('rh_theme') || 'system',
10
+ viewMode: 'card',
11
+ sortBy: 'hot',
12
+ quickAccessFilter: null,
13
+ };
14
+
15
+ function persistUiPreferencesForState(state) {
16
+ if (!window.uiPreferences?.savePreferencesForActor) return;
17
+ window.uiPreferences.savePreferencesForActor(state.currentUser || null, {
18
+ locale: state.locale,
19
+ theme: state.theme,
20
+ viewMode: state.viewMode,
21
+ sortBy: state.sortBy,
22
+ quickAccessFilter: state.quickAccessFilter,
23
+ });
24
+ }
25
+
26
+ const initialState = {
27
+ currentUser: null,
28
+ token: sessionStorage.getItem('rh_token'),
29
+ resources: [],
30
+ categories: [],
31
+ tags: [],
32
+ config: null,
33
+ favorites: [],
34
+ history: [],
35
+ mine: [],
36
+ locale: initialUiPreferences.locale,
37
+ theme: initialUiPreferences.theme,
38
+ searchQuery: '',
39
+ selectedCategory: null,
40
+ selectedTags: [],
41
+ quickAccessFilter: initialUiPreferences.quickAccessFilter,
42
+ viewMode: initialUiPreferences.viewMode,
43
+ sortBy: initialUiPreferences.sortBy,
44
+ homeMode: 'overview',
45
+ toasts: [],
46
+ activeModal: null,
47
+ emailPreview: null,
48
+ };
49
+
50
+ function appReducer(state, action) {
51
+ switch (action.type) {
52
+ case 'LOGIN': {
53
+ sessionStorage.setItem('rh_token', action.token);
54
+ const authenticatedPreferences = window.uiPreferences?.getUserPreferences?.(action.user?.id) || initialUiPreferences;
55
+ if (window.i18n?.applyLocale) window.i18n.applyLocale(authenticatedPreferences.locale);
56
+ if (window.applyTheme) window.applyTheme(authenticatedPreferences.theme);
57
+ return {
58
+ ...state,
59
+ currentUser: action.user,
60
+ token: action.token,
61
+ locale: authenticatedPreferences.locale,
62
+ theme: authenticatedPreferences.theme,
63
+ viewMode: authenticatedPreferences.viewMode,
64
+ sortBy: authenticatedPreferences.sortBy,
65
+ quickAccessFilter: null,
66
+ homeMode: 'overview',
67
+ };
68
+ }
69
+
70
+ case 'LOGOUT': {
71
+ sessionStorage.removeItem('rh_token');
72
+ const guestPreferences = window.uiPreferences?.getGuestPreferences?.() || initialUiPreferences;
73
+ if (window.i18n?.applyLocale) window.i18n.applyLocale(guestPreferences.locale);
74
+ if (window.applyTheme) window.applyTheme(guestPreferences.theme);
75
+ return {
76
+ ...state,
77
+ currentUser: null,
78
+ token: null,
79
+ favorites: [],
80
+ history: [],
81
+ mine: [],
82
+ locale: guestPreferences.locale,
83
+ theme: guestPreferences.theme,
84
+ viewMode: guestPreferences.viewMode,
85
+ sortBy: guestPreferences.sortBy,
86
+ quickAccessFilter: guestPreferences.quickAccessFilter,
87
+ homeMode: 'overview',
88
+ };
89
+ }
90
+
91
+ case 'SET_CURRENT_USER':
92
+ return { ...state, currentUser: action.user };
93
+
94
+ case 'SET_RESOURCES':
95
+ return { ...state, resources: action.resources };
96
+
97
+ case 'SET_CATEGORIES':
98
+ return { ...state, categories: action.categories };
99
+
100
+ case 'SET_TAGS':
101
+ return { ...state, tags: action.tags };
102
+
103
+ case 'SET_CONFIG':
104
+ return { ...state, config: action.config };
105
+
106
+ case 'SET_FAVORITES':
107
+ return { ...state, favorites: action.favorites };
108
+
109
+ case 'SET_HISTORY':
110
+ return { ...state, history: action.history };
111
+
112
+ case 'SET_MINE':
113
+ return { ...state, mine: action.mine };
114
+
115
+ case 'ADD_RESOURCE':
116
+ return { ...state, resources: [action.resource, ...state.resources] };
117
+
118
+ case 'UPDATE_RESOURCE':
119
+ return {
120
+ ...state,
121
+ resources: state.resources.map(r => r.id === action.resource.id ? action.resource : r),
122
+ favorites: state.favorites.map(r => r.id === action.resource.id ? action.resource : r),
123
+ history: state.history.map(r => r.id === action.resource.id ? action.resource : r),
124
+ mine: state.mine.map(r => r.id === action.resource.id ? action.resource : r),
125
+ };
126
+
127
+ case 'SET_RESOURCE_VISIT_COUNT': {
128
+ const applyVisitCount = (resource) => (
129
+ resource.id === action.id
130
+ ? { ...resource, visitCount: action.visitCount }
131
+ : resource
132
+ );
133
+ return {
134
+ ...state,
135
+ resources: state.resources.map(applyVisitCount),
136
+ favorites: state.favorites.map(applyVisitCount),
137
+ history: state.history.map(applyVisitCount),
138
+ mine: state.mine.map(applyVisitCount),
139
+ };
140
+ }
141
+
142
+ case 'DELETE_RESOURCE':
143
+ return {
144
+ ...state,
145
+ resources: state.resources.filter(r => r.id !== action.id),
146
+ favorites: state.favorites.filter(r => r.id !== action.id),
147
+ history: state.history.filter(r => r.id !== action.id),
148
+ mine: state.mine.filter(r => r.id !== action.id),
149
+ };
150
+
151
+ case 'ADD_CATEGORY':
152
+ return { ...state, categories: [...state.categories, action.category] };
153
+
154
+ case 'UPDATE_CATEGORY':
155
+ return {
156
+ ...state,
157
+ categories: state.categories.map(c => c.id === action.category.id ? action.category : c),
158
+ };
159
+
160
+ case 'DELETE_CATEGORY':
161
+ return {
162
+ ...state,
163
+ categories: state.categories.filter(c => c.id !== action.id),
164
+ };
165
+
166
+ case 'TOGGLE_FAVORITE': {
167
+ const exists = state.favorites.some(r => r.id === action.resource.id);
168
+ return {
169
+ ...state,
170
+ favorites: exists
171
+ ? state.favorites.filter(r => r.id !== action.resource.id)
172
+ : [action.resource, ...state.favorites],
173
+ };
174
+ }
175
+
176
+ case 'SET_THEME': {
177
+ if (window.applyTheme) window.applyTheme(action.theme);
178
+ const nextThemeState = { ...state, theme: action.theme };
179
+ persistUiPreferencesForState(nextThemeState);
180
+ return nextThemeState;
181
+ }
182
+
183
+ case 'SET_LOCALE': {
184
+ if (window.i18n?.applyLocale) window.i18n.applyLocale(action.locale);
185
+ const nextLocaleState = { ...state, locale: action.locale };
186
+ persistUiPreferencesForState(nextLocaleState);
187
+ return nextLocaleState;
188
+ }
189
+
190
+ case 'SET_SEARCH':
191
+ return { ...state, searchQuery: action.query };
192
+
193
+ case 'SET_CATEGORY': {
194
+ const nextCategoryState = {
195
+ ...state,
196
+ selectedCategory: action.category,
197
+ selectedTags: [],
198
+ };
199
+ persistUiPreferencesForState(nextCategoryState);
200
+ return nextCategoryState;
201
+ }
202
+
203
+ case 'TOGGLE_TAG': {
204
+ const tags = state.selectedTags.includes(action.tag)
205
+ ? state.selectedTags.filter(t => t !== action.tag)
206
+ : [...state.selectedTags, action.tag];
207
+ const nextTagState = { ...state, selectedTags: tags };
208
+ persistUiPreferencesForState(nextTagState);
209
+ return nextTagState;
210
+ }
211
+
212
+ case 'SET_QUICK_ACCESS_FILTER': {
213
+ const nextQuickAccessState = {
214
+ ...state,
215
+ quickAccessFilter: action.filter,
216
+ };
217
+ persistUiPreferencesForState(nextQuickAccessState);
218
+ return nextQuickAccessState;
219
+ }
220
+
221
+ case 'CLEAR_FILTERS': {
222
+ const nextClearState = {
223
+ ...state,
224
+ searchQuery: '',
225
+ selectedCategory: null,
226
+ selectedTags: [],
227
+ quickAccessFilter: null,
228
+ };
229
+ persistUiPreferencesForState(nextClearState);
230
+ return nextClearState;
231
+ }
232
+
233
+ case 'SET_VIEW_MODE': {
234
+ const nextViewModeState = { ...state, viewMode: action.viewMode };
235
+ persistUiPreferencesForState(nextViewModeState);
236
+ return nextViewModeState;
237
+ }
238
+
239
+ case 'SET_SORT': {
240
+ const nextSortState = { ...state, sortBy: action.sortBy };
241
+ persistUiPreferencesForState(nextSortState);
242
+ return nextSortState;
243
+ }
244
+
245
+ case 'SET_HOME_MODE':
246
+ return { ...state, homeMode: action.mode };
247
+
248
+ case 'HYDRATE_UI_PREFERENCES': {
249
+ const basePreferences = {
250
+ locale: state.locale,
251
+ theme: state.theme,
252
+ viewMode: state.viewMode,
253
+ sortBy: state.sortBy,
254
+ quickAccessFilter: state.quickAccessFilter,
255
+ };
256
+ const nextHydratedPreferences = window.uiPreferences?.sanitizePreferencesForActor?.(
257
+ Object.prototype.hasOwnProperty.call(action, 'user') ? action.user : state.currentUser,
258
+ { ...basePreferences, ...(action.preferences || {}) },
259
+ basePreferences,
260
+ ) || { ...basePreferences, ...(action.preferences || {}) };
261
+ if (window.i18n?.applyLocale) window.i18n.applyLocale(nextHydratedPreferences.locale);
262
+ if (window.applyTheme) window.applyTheme(nextHydratedPreferences.theme);
263
+ return {
264
+ ...state,
265
+ locale: nextHydratedPreferences.locale,
266
+ theme: nextHydratedPreferences.theme,
267
+ viewMode: nextHydratedPreferences.viewMode,
268
+ sortBy: nextHydratedPreferences.sortBy,
269
+ quickAccessFilter: nextHydratedPreferences.quickAccessFilter,
270
+ };
271
+ }
272
+
273
+ case 'ADD_TOAST': {
274
+ const toast = { id: Date.now() + Math.random(), type: action.toastType || 'info', message: action.message };
275
+ return { ...state, toasts: [...state.toasts, toast] };
276
+ }
277
+
278
+ case 'REMOVE_TOAST':
279
+ return { ...state, toasts: state.toasts.filter(t => t.id !== action.id) };
280
+
281
+ case 'OPEN_MODAL':
282
+ return { ...state, activeModal: action.modal };
283
+
284
+ case 'CLOSE_MODAL':
285
+ return { ...state, activeModal: null };
286
+
287
+ case 'SET_EMAIL_PREVIEW':
288
+ return { ...state, emailPreview: action.emailPreview };
289
+
290
+ default:
291
+ return state;
292
+ }
293
+ }
294
+
295
+ function AppProvider({ children }) {
296
+ const [state, dispatch] = useReducer(appReducer, initialState);
297
+ return React.createElement(
298
+ AppStateContext.Provider,
299
+ { value: state },
300
+ React.createElement(AppDispatchContext.Provider, { value: dispatch }, children)
301
+ );
302
+ }
303
+
304
+ function useAppState() {
305
+ return useContext(AppStateContext);
306
+ }
307
+
308
+ function useAppDispatch() {
309
+ return useContext(AppDispatchContext);
310
+ }
311
+
312
+ window.AppProvider = AppProvider;
313
+ window.useAppState = useAppState;
314
+ window.useAppDispatch = useAppDispatch;