@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,472 @@
1
+ function ResourceTimeline({ resources, onEdit }) {
2
+ const state = window.useAppState();
3
+ const dispatch = window.useAppDispatch();
4
+ const { request } = window.useApi();
5
+ const viewportWidth = window.useViewportWidth();
6
+ const { getCategoryTone, getLogoFallbackColor, getDomain, formatDate, formatMonth, recordResourceVisit } = window.helpers;
7
+
8
+ const currentUser = state?.currentUser;
9
+ const favorites = state?.favorites || [];
10
+ const theme = state?.theme || window.getTheme?.() || 'system';
11
+ const isDarkTheme = theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
12
+ const isLightTheme = !isDarkTheme;
13
+ const mobileLayout = viewportWidth < 760;
14
+ const compactLayout = viewportWidth < 1120;
15
+
16
+ const groups = React.useMemo(() => {
17
+ const map = {};
18
+
19
+ (resources || []).forEach((resource) => {
20
+ const anchorTimestamp = resource.createdAt || resource.updatedAt || 0;
21
+ const date = anchorTimestamp ? new Date(anchorTimestamp * 1000) : new Date();
22
+ const year = date.getFullYear();
23
+ const month = date.getMonth();
24
+ const sortKey = year * 100 + month;
25
+
26
+ if (!map[sortKey]) {
27
+ map[sortKey] = {
28
+ key: `${year}-${month + 1}`,
29
+ sortKey,
30
+ label: formatMonth(anchorTimestamp) || `${year}年${month + 1}月`,
31
+ items: [],
32
+ };
33
+ }
34
+
35
+ map[sortKey].items.push(resource);
36
+ });
37
+
38
+ return Object.values(map)
39
+ .map((group) => ({
40
+ ...group,
41
+ items: [...group.items].sort((a, b) => (b.createdAt || b.updatedAt || 0) - (a.createdAt || a.updatedAt || 0)),
42
+ }))
43
+ .sort((a, b) => b.sortKey - a.sortKey);
44
+ }, [formatMonth, resources]);
45
+
46
+ if (!resources || resources.length === 0) {
47
+ return <window.EmptyState icon="Clock" title="暂无资源" description="还没有任何资源记录" />;
48
+ }
49
+
50
+ const monthBadgeStyle = {
51
+ display: 'inline-flex',
52
+ alignItems: 'center',
53
+ minHeight: '30px',
54
+ padding: mobileLayout ? '0 10px' : '0 12px',
55
+ borderRadius: '999px',
56
+ border: isLightTheme
57
+ ? '1px solid color-mix(in srgb, var(--control-border) 72%, transparent)'
58
+ : '1px solid color-mix(in srgb, var(--outline-strong) 24%, transparent)',
59
+ background: isLightTheme
60
+ ? 'var(--surface-elevated)'
61
+ : 'color-mix(in srgb, var(--surface-elevated) 82%, var(--surface-tint))',
62
+ color: 'var(--text-primary)',
63
+ fontSize: '12px',
64
+ fontWeight: 700,
65
+ letterSpacing: '0.01em',
66
+ };
67
+ const monthMetaStyle = {
68
+ fontSize: '11px',
69
+ color: 'var(--text-secondary)',
70
+ whiteSpace: 'nowrap',
71
+ };
72
+ const timelineRailStyle = {
73
+ position: 'absolute',
74
+ left: mobileLayout ? '7px' : '9px',
75
+ top: '8px',
76
+ bottom: '8px',
77
+ width: '2px',
78
+ borderRadius: '999px',
79
+ background: isLightTheme
80
+ ? 'linear-gradient(180deg, color-mix(in srgb, var(--brand) 26%, transparent) 0%, color-mix(in srgb, var(--control-border) 44%, transparent) 100%)'
81
+ : 'linear-gradient(180deg, color-mix(in srgb, var(--brand) 30%, transparent) 0%, color-mix(in srgb, var(--outline-strong) 28%, transparent) 100%)',
82
+ };
83
+
84
+ return (
85
+ <div data-rh-resource-timeline style={{ display: 'grid', gap: mobileLayout ? '18px' : '22px' }}>
86
+ {groups.map((group) => (
87
+ <section key={group.key} style={{ display: 'grid', gap: mobileLayout ? '10px' : '12px' }}>
88
+ <div
89
+ style={{
90
+ display: 'flex',
91
+ justifyContent: 'space-between',
92
+ alignItems: 'center',
93
+ gap: '12px',
94
+ flexWrap: 'wrap',
95
+ paddingLeft: mobileLayout ? '2px' : '4px',
96
+ }}
97
+ >
98
+ <div style={{ display: 'inline-flex', alignItems: 'center', gap: '10px', flexWrap: 'wrap' }}>
99
+ <span style={monthBadgeStyle}>{group.label}</span>
100
+ <span style={monthMetaStyle}>{`${group.items.length} 条记录`}</span>
101
+ </div>
102
+ {!mobileLayout && (
103
+ <span style={monthMetaStyle}>
104
+ 按创建时间归档
105
+ </span>
106
+ )}
107
+ </div>
108
+
109
+ <div style={{ position: 'relative', paddingLeft: mobileLayout ? '18px' : '24px' }}>
110
+ <div style={timelineRailStyle} />
111
+ <div style={{ display: 'grid', gap: mobileLayout ? '10px' : '12px' }}>
112
+ {group.items.map((resource) => (
113
+ <TimelineItem
114
+ key={resource.id}
115
+ resource={resource}
116
+ currentUser={currentUser}
117
+ favorites={favorites}
118
+ dispatch={dispatch}
119
+ request={request}
120
+ onEdit={onEdit}
121
+ isLightTheme={isLightTheme}
122
+ mobileLayout={mobileLayout}
123
+ compactLayout={compactLayout}
124
+ recordResourceVisit={recordResourceVisit}
125
+ getCategoryTone={getCategoryTone}
126
+ getLogoFallbackColor={getLogoFallbackColor}
127
+ getDomain={getDomain}
128
+ formatDate={formatDate}
129
+ />
130
+ ))}
131
+ </div>
132
+ </div>
133
+ </section>
134
+ ))}
135
+ </div>
136
+ );
137
+ }
138
+
139
+ function TimelineItem({
140
+ resource,
141
+ currentUser,
142
+ favorites,
143
+ dispatch,
144
+ request,
145
+ onEdit,
146
+ isLightTheme,
147
+ mobileLayout,
148
+ compactLayout,
149
+ recordResourceVisit,
150
+ getCategoryTone,
151
+ getLogoFallbackColor,
152
+ getDomain,
153
+ formatDate,
154
+ }) {
155
+ const [imgError, setImgError] = React.useState(false);
156
+ const [isHovered, setIsHovered] = React.useState(false);
157
+ const [heartScale, setHeartScale] = React.useState(1);
158
+
159
+ const isFavorited = favorites.some((item) => item.id === resource.id);
160
+ const category = resource.category || (resource.categoryName ? { name: resource.categoryName, color: resource.categoryColor } : null);
161
+ const categoryTone = category ? getCategoryTone(category, resource.id || resource.name) : null;
162
+ const fallbackColor = getLogoFallbackColor(resource.name, category);
163
+ const canEdit = currentUser && (currentUser.id === resource.ownerId || currentUser.role === 'admin');
164
+ const domain = getDomain(resource.url);
165
+ const hasDescription = Boolean(resource.description && resource.description.trim());
166
+ const summaryDate = formatDate(resource.updatedAt || resource.createdAt) || '刚刚更新';
167
+ const summaryPrefix = resource.updatedAt && resource.createdAt && resource.updatedAt !== resource.createdAt ? '更新于' : '创建于';
168
+ const displayTags = (resource.tags || []).slice(0, compactLayout ? 1 : 2);
169
+ const extraTags = Math.max((resource.tags || []).length - displayTags.length, 0);
170
+ const showFavoriteAction = isFavorited || isHovered || mobileLayout;
171
+ const showEditAction = canEdit && (isHovered || mobileLayout);
172
+ const actionButtonStyle = (active = false, visible = true) => ({
173
+ width: mobileLayout ? '30px' : '32px',
174
+ height: mobileLayout ? '30px' : '32px',
175
+ borderRadius: mobileLayout ? '10px' : '11px',
176
+ border: `1px solid ${active ? 'color-mix(in srgb, var(--danger) 30%, var(--control-border))' : 'color-mix(in srgb, var(--control-border) 62%, transparent)'}`,
177
+ background: active
178
+ ? 'color-mix(in srgb, var(--danger) 12%, var(--control-bg))'
179
+ : isLightTheme
180
+ ? 'var(--surface-elevated)'
181
+ : 'color-mix(in srgb, var(--surface-elevated) 82%, var(--control-bg-muted))',
182
+ color: active ? 'var(--danger)' : 'var(--text-secondary)',
183
+ cursor: visible ? 'pointer' : 'default',
184
+ display: 'inline-flex',
185
+ alignItems: 'center',
186
+ justifyContent: 'center',
187
+ opacity: visible ? 1 : 0,
188
+ pointerEvents: visible ? 'auto' : 'none',
189
+ transform: `scale(${active ? heartScale : 1})`,
190
+ boxShadow: active ? '0 8px 14px color-mix(in srgb, var(--danger) 10%, transparent)' : 'none',
191
+ transition: 'transform 180ms, opacity 150ms, border-color 150ms, background 150ms, box-shadow 150ms',
192
+ });
193
+ const pillStyle = {
194
+ display: 'inline-flex',
195
+ alignItems: 'center',
196
+ minHeight: '22px',
197
+ padding: '0 7px',
198
+ borderRadius: '999px',
199
+ border: '1px solid color-mix(in srgb, var(--control-border) 54%, transparent)',
200
+ background: isLightTheme
201
+ ? 'var(--surface-muted)'
202
+ : 'color-mix(in srgb, var(--surface-elevated) 74%, var(--surface-muted))',
203
+ color: 'var(--text-secondary)',
204
+ fontSize: '10px',
205
+ fontWeight: 600,
206
+ whiteSpace: 'nowrap',
207
+ };
208
+
209
+ const handleVisit = (e) => {
210
+ if (e.target.closest('button')) return;
211
+ recordResourceVisit({ resource, request, dispatch });
212
+ window.open(resource.url, '_blank', 'noopener');
213
+ };
214
+
215
+ const handleFavorite = async (e) => {
216
+ e.stopPropagation();
217
+ if (!currentUser) {
218
+ dispatch({ type: 'ADD_TOAST', toastType: 'info', message: '请登录后操作' });
219
+ return;
220
+ }
221
+ setHeartScale(1.3);
222
+ setTimeout(() => setHeartScale(1), 200);
223
+ const { ok } = await request(`/api/resources/${resource.id}/favorite`, { method: 'POST' });
224
+ if (ok) dispatch({ type: 'TOGGLE_FAVORITE', resource });
225
+ };
226
+
227
+ return (
228
+ <article
229
+ data-rh-resource-timeline-item
230
+ onClick={handleVisit}
231
+ onMouseEnter={() => setIsHovered(true)}
232
+ onMouseLeave={() => setIsHovered(false)}
233
+ style={{
234
+ position: 'relative',
235
+ display: 'grid',
236
+ gridTemplateColumns: mobileLayout
237
+ ? 'minmax(0, 1fr) auto'
238
+ : compactLayout
239
+ ? 'minmax(0, 1fr) auto'
240
+ : 'minmax(0, 1.1fr) minmax(0, 0.8fr) auto',
241
+ gap: mobileLayout ? '12px' : '14px',
242
+ alignItems: 'center',
243
+ minHeight: mobileLayout ? '84px' : '88px',
244
+ padding: mobileLayout ? '12px 12px 12px 14px' : '14px 16px',
245
+ borderRadius: mobileLayout ? '16px' : '18px',
246
+ border: isLightTheme
247
+ ? (isHovered
248
+ ? '1px solid color-mix(in srgb, var(--brand) 24%, var(--border))'
249
+ : '1px solid var(--border)')
250
+ : '1px solid color-mix(in srgb, var(--outline-strong) 24%, transparent)',
251
+ background: isHovered
252
+ ? (isLightTheme
253
+ ? 'var(--surface-elevated)'
254
+ : 'color-mix(in srgb, var(--surface-elevated) 88%, var(--surface-tint))')
255
+ : (isLightTheme
256
+ ? 'var(--surface-elevated)'
257
+ : 'color-mix(in srgb, var(--surface-elevated) 90%, var(--surface-tint))'),
258
+ boxShadow: isHovered
259
+ ? (isLightTheme
260
+ ? 'var(--shadow-card-hover)'
261
+ : '0 14px 24px color-mix(in srgb, var(--bg-primary) 16%, transparent), inset 0 1px 0 color-mix(in srgb, var(--surface-elevated) 18%, transparent)')
262
+ : (isLightTheme ? 'var(--shadow-card)' : 'none'),
263
+ cursor: 'pointer',
264
+ transition: 'background 150ms, border-color 150ms, box-shadow 150ms, transform 150ms',
265
+ transform: isHovered ? 'translateY(-1px)' : 'translateY(0)',
266
+ }}
267
+ >
268
+ <div
269
+ style={{
270
+ position: 'absolute',
271
+ left: mobileLayout ? '-16px' : '-20px',
272
+ top: '50%',
273
+ transform: 'translateY(-50%)',
274
+ width: mobileLayout ? '10px' : '12px',
275
+ height: mobileLayout ? '10px' : '12px',
276
+ borderRadius: '50%',
277
+ background: 'var(--brand)',
278
+ border: isLightTheme
279
+ ? '3px solid color-mix(in srgb, var(--surface-elevated) 96%, var(--surface-tint))'
280
+ : '3px solid color-mix(in srgb, var(--surface-elevated) 90%, var(--surface-tint))',
281
+ boxShadow: '0 0 0 1px color-mix(in srgb, var(--brand) 14%, transparent)',
282
+ }}
283
+ />
284
+
285
+ <div style={{ display: 'flex', gap: '12px', minWidth: 0, alignItems: 'flex-start' }}>
286
+ {resource.logoUrl && !imgError ? (
287
+ <img
288
+ src={resource.logoUrl}
289
+ onError={() => setImgError(true)}
290
+ style={{
291
+ width: mobileLayout ? '36px' : '38px',
292
+ height: mobileLayout ? '36px' : '38px',
293
+ borderRadius: '11px',
294
+ objectFit: 'contain',
295
+ flexShrink: 0,
296
+ background: 'var(--surface-muted)',
297
+ border: '1px solid color-mix(in srgb, var(--control-border) 70%, transparent)',
298
+ }}
299
+ />
300
+ ) : (
301
+ <div
302
+ style={{
303
+ width: mobileLayout ? '36px' : '38px',
304
+ height: mobileLayout ? '36px' : '38px',
305
+ borderRadius: '11px',
306
+ background: categoryTone?.accent || fallbackColor,
307
+ color: '#fff',
308
+ display: 'flex',
309
+ alignItems: 'center',
310
+ justifyContent: 'center',
311
+ fontSize: mobileLayout ? '13px' : '14px',
312
+ fontWeight: 800,
313
+ flexShrink: 0,
314
+ boxShadow: '0 6px 14px color-mix(in srgb, var(--text-primary) 8%, transparent)',
315
+ }}
316
+ >
317
+ {(resource.name || '?')[0].toUpperCase()}
318
+ </div>
319
+ )}
320
+
321
+ <div style={{ minWidth: 0, flex: 1, display: 'grid', gap: '6px' }}>
322
+ <div style={{ display: 'grid', gap: '3px', minWidth: 0 }}>
323
+ <div
324
+ style={{
325
+ fontSize: mobileLayout ? '14px' : '15px',
326
+ lineHeight: 1.3,
327
+ fontWeight: 700,
328
+ color: 'var(--text-primary)',
329
+ whiteSpace: 'nowrap',
330
+ overflow: 'hidden',
331
+ textOverflow: 'ellipsis',
332
+ }}
333
+ >
334
+ {resource.name}
335
+ </div>
336
+ <div
337
+ style={{
338
+ fontSize: '11px',
339
+ color: isHovered ? 'var(--text-secondary)' : 'var(--text-tertiary)',
340
+ whiteSpace: 'nowrap',
341
+ overflow: 'hidden',
342
+ textOverflow: 'ellipsis',
343
+ }}
344
+ >
345
+ {domain}
346
+ </div>
347
+ </div>
348
+
349
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: '5px', alignItems: 'center' }}>
350
+ {category && (
351
+ <span
352
+ style={{
353
+ ...pillStyle,
354
+ background: categoryTone
355
+ ? categoryTone.soft
356
+ : pillStyle.background,
357
+ color: categoryTone?.accent || 'var(--text-secondary)',
358
+ border: categoryTone
359
+ ? `1px solid ${categoryTone.border}`
360
+ : pillStyle.border,
361
+ }}
362
+ >
363
+ {category.name}
364
+ </span>
365
+ )}
366
+ <span
367
+ style={{
368
+ ...pillStyle,
369
+ background: resource.visibility === 'private'
370
+ ? 'color-mix(in srgb, var(--danger) 14%, var(--surface-elevated))'
371
+ : 'var(--surface-muted)',
372
+ color: resource.visibility === 'private' ? 'var(--danger)' : 'var(--text-secondary)',
373
+ border: resource.visibility === 'private'
374
+ ? '1px solid color-mix(in srgb, var(--danger) 24%, var(--outline-strong))'
375
+ : '1px solid color-mix(in srgb, var(--control-border) 72%, transparent)',
376
+ }}
377
+ >
378
+ {resource.visibility === 'private' ? '私有' : '公开'}
379
+ </span>
380
+ {!mobileLayout && displayTags.map((tag) => (
381
+ <span key={tag} style={pillStyle}>#{tag}</span>
382
+ ))}
383
+ {!mobileLayout && extraTags > 0 && (
384
+ <span style={pillStyle}>+{extraTags}</span>
385
+ )}
386
+ </div>
387
+ </div>
388
+ </div>
389
+
390
+ {!mobileLayout && (
391
+ <div style={{ minWidth: 0, display: 'grid', gap: '6px' }}>
392
+ {hasDescription ? (
393
+ <div
394
+ style={{
395
+ fontSize: '12px',
396
+ lineHeight: 1.45,
397
+ color: 'var(--text-secondary)',
398
+ whiteSpace: 'nowrap',
399
+ overflow: 'hidden',
400
+ textOverflow: 'ellipsis',
401
+ }}
402
+ >
403
+ {resource.description}
404
+ </div>
405
+ ) : (
406
+ <div
407
+ style={{
408
+ fontSize: '12px',
409
+ lineHeight: 1.45,
410
+ color: 'var(--text-tertiary)',
411
+ whiteSpace: 'nowrap',
412
+ overflow: 'hidden',
413
+ textOverflow: 'ellipsis',
414
+ }}
415
+ >
416
+ {domain}
417
+ </div>
418
+ )}
419
+
420
+ <div style={{ display: 'inline-flex', flexWrap: 'wrap', gap: '6px', minWidth: 0, alignItems: 'center' }}>
421
+ <span style={{ fontSize: '10px', color: 'var(--text-tertiary)', whiteSpace: 'nowrap' }}>
422
+ {summaryPrefix} {summaryDate}
423
+ </span>
424
+ <span style={{ fontSize: '10px', color: 'color-mix(in srgb, var(--text-tertiary) 58%, transparent)' }}>•</span>
425
+ <span style={{ fontSize: '10px', color: 'var(--text-tertiary)', whiteSpace: 'nowrap' }}>
426
+ 访问 {resource.visitCount || 0}
427
+ </span>
428
+ </div>
429
+ </div>
430
+ )}
431
+
432
+ <div
433
+ style={{
434
+ width: mobileLayout ? '68px' : '76px',
435
+ display: 'flex',
436
+ gap: '6px',
437
+ justifyContent: 'flex-end',
438
+ flexShrink: 0,
439
+ alignItems: 'center',
440
+ }}
441
+ >
442
+ <window.TooltipIconButton
443
+ label={isFavorited ? '取消收藏' : '收藏资源'}
444
+ placement="left"
445
+ onClick={handleFavorite}
446
+ buttonStyle={actionButtonStyle(isFavorited, showFavoriteAction)}
447
+ >
448
+ <lucide.Heart
449
+ size={15}
450
+ fill={isFavorited ? 'var(--danger)' : 'none'}
451
+ style={{ color: isFavorited ? 'var(--danger)' : 'var(--text-secondary)' }}
452
+ />
453
+ </window.TooltipIconButton>
454
+ {showEditAction ? (
455
+ <window.TooltipIconButton
456
+ label="编辑资源"
457
+ placement="left"
458
+ onClick={(e) => {
459
+ e.stopPropagation();
460
+ onEdit && onEdit(resource);
461
+ }}
462
+ buttonStyle={actionButtonStyle(false, true)}
463
+ >
464
+ <lucide.Edit2 size={15} style={{ color: 'var(--text-secondary)' }} />
465
+ </window.TooltipIconButton>
466
+ ) : null}
467
+ </div>
468
+ </article>
469
+ );
470
+ }
471
+
472
+ window.ResourceTimeline = ResourceTimeline;
@@ -0,0 +1,26 @@
1
+ // useApi hook - wraps fetch with auth header and emailPreview detection
2
+ const { useCallback } = React;
3
+
4
+ function useApi() {
5
+ const state = window.useAppState?.();
6
+ const locale = state?.locale || window.i18n?.getCurrentLocale?.() || window.i18n?.detectBrowserLocale?.() || 'zh-Hans';
7
+
8
+ const request = useCallback(async (url, options = {}) => {
9
+ const token = sessionStorage.getItem('rh_token');
10
+ const hasBody = options.body !== undefined && options.body !== null;
11
+ const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData;
12
+ const headers = {
13
+ ...(hasBody && !isFormData ? { 'Content-Type': 'application/json' } : {}),
14
+ 'Accept-Language': locale,
15
+ ...(token ? { 'Authorization': `Bearer ${token}` } : {}),
16
+ ...(options.headers || {})
17
+ };
18
+ const response = await fetch(url, { ...options, headers });
19
+ const data = await response.json();
20
+ return { ok: response.ok, status: response.status, data };
21
+ }, [locale]);
22
+
23
+ return { request };
24
+ }
25
+
26
+ window.useApi = useApi;
@@ -0,0 +1,35 @@
1
+ // Hash router hook
2
+ const { useState, useEffect, useCallback } = React;
3
+
4
+ function useRouter() {
5
+ const getHash = () => {
6
+ const hash = window.location.hash || '#/';
7
+ // Separate path and query string
8
+ const [path, ...queryParts] = hash.slice(1).split('?');
9
+ const queryString = queryParts.join('?');
10
+ const params = {};
11
+ if (queryString) {
12
+ queryString.split('&').forEach(part => {
13
+ const [key, val] = part.split('=');
14
+ if (key) params[decodeURIComponent(key)] = decodeURIComponent(val || '');
15
+ });
16
+ }
17
+ return { path: path || '/', params };
18
+ };
19
+
20
+ const [route, setRoute] = useState(getHash);
21
+
22
+ useEffect(() => {
23
+ const handler = () => setRoute(getHash());
24
+ window.addEventListener('hashchange', handler);
25
+ return () => window.removeEventListener('hashchange', handler);
26
+ }, []);
27
+
28
+ const navigate = useCallback((hash) => {
29
+ window.location.hash = hash;
30
+ }, []);
31
+
32
+ return { route, navigate };
33
+ }
34
+
35
+ window.useRouter = useRouter;