@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,422 @@
1
+ function ResourceCard({ resource, onEdit, featured = false, compact = false }) {
2
+ const state = window.useAppState();
3
+ const dispatch = window.useAppDispatch();
4
+ const { request } = window.useApi();
5
+ const viewportWidth = window.useViewportWidth();
6
+ const [imgError, setImgError] = React.useState(false);
7
+ const [isHovered, setIsHovered] = React.useState(false);
8
+ const [heartScale, setHeartScale] = React.useState(1);
9
+ const [favoriteLoading, setFavoriteLoading] = React.useState(false);
10
+
11
+ const { getCategoryTone, getLogoFallbackColor, getDomain, formatDate, recordResourceVisit } = window.helpers;
12
+ const currentUser = state?.currentUser;
13
+ const theme = state?.theme || window.getTheme?.() || 'system';
14
+ const isDarkTheme = theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
15
+ const isLightTheme = !isDarkTheme;
16
+ const mobileLayout = viewportWidth < 760;
17
+ const isFavorited = state?.favorites?.some((item) => item.id === resource.id) ?? resource.isFavorited ?? false;
18
+ const canEdit = currentUser && (currentUser.id === resource.ownerId || currentUser.role === 'admin');
19
+
20
+ const category = resource.category || (resource.categoryName ? { name: resource.categoryName, color: resource.categoryColor } : null);
21
+ const categoryTone = category ? getCategoryTone(category, resource.id || resource.name) : null;
22
+ const fallbackColor = getLogoFallbackColor(resource.name, category);
23
+ const domain = getDomain(resource.url);
24
+ const updatedLabel = formatDate(resource.updatedAt || resource.createdAt) || '刚刚更新';
25
+ const hasDescription = Boolean(resource.description && resource.description.trim());
26
+ const featuredCompact = featured && !hasDescription;
27
+ const resultCompact = !featured && compact;
28
+ const displayTags = resource.tags?.slice(0, featured ? (featuredCompact ? 2 : 3) : resultCompact ? 1 : 2) || [];
29
+ const extraTags = (resource.tags?.length || 0) - displayTags.length;
30
+ const hasVisibleTags = displayTags.length > 0 || extraTags > 0;
31
+ const compactCard = !featured && !hasDescription;
32
+ const cardGap = featured ? (featuredCompact ? '7px' : '10px') : resultCompact ? (hasDescription ? '4px' : '3px') : compactCard ? '5px' : '7px';
33
+ const cardMinHeight = featured
34
+ ? (featuredCompact ? (hasVisibleTags ? '158px' : '146px') : '188px')
35
+ : resultCompact
36
+ ? (hasDescription ? (hasVisibleTags ? '124px' : '118px') : (hasVisibleTags ? '114px' : '108px'))
37
+ : compactCard
38
+ ? (hasVisibleTags ? '138px' : '130px')
39
+ : '150px';
40
+ const cardPadding = featured ? (featuredCompact ? '12px' : '14px') : resultCompact ? '9px' : '10px';
41
+ const mediaSize = featured ? (featuredCompact ? '40px' : '46px') : resultCompact ? '36px' : '40px';
42
+ const mediaRadius = featured ? (featuredCompact ? '12px' : '14px') : resultCompact ? '10px' : '12px';
43
+ const headerGap = resultCompact ? '6px' : compactCard ? '6px' : featured ? (featuredCompact ? '6px' : '8px') : '7px';
44
+ const leadGap = resultCompact ? '7px' : compactCard ? '8px' : featured ? (featuredCompact ? '8px' : '10px') : '8px';
45
+ const contentGap = resultCompact ? '3px' : compactCard ? '4px' : featured ? (featuredCompact ? '3px' : '5px') : '4px';
46
+ const titleSize = featured ? (featuredCompact ? '16px' : '18px') : resultCompact ? '13px' : '14px';
47
+ const titleLineClamp = featured ? 2 : 1;
48
+ const domainSize = featured ? (featuredCompact ? '9px' : '11px') : resultCompact ? '8px' : '9px';
49
+ const actionButtonSize = featured ? (featuredCompact ? '30px' : '34px') : resultCompact ? '30px' : '34px';
50
+ const actionIconSize = featured ? (featuredCompact ? 14 : 16) : resultCompact ? 14 : 15;
51
+ const actionRailWidth = featured ? (featuredCompact ? '32px' : '38px') : resultCompact ? '32px' : '36px';
52
+ const tagGap = resultCompact ? '4px' : compactCard ? '4px' : featured ? (featuredCompact ? '4px' : '5px') : '5px';
53
+ const metaFontSize = featured ? (featuredCompact ? '9px' : '10px') : resultCompact ? '8px' : '9px';
54
+ const metaTextColor = featured
55
+ ? 'var(--text-secondary)'
56
+ : resultCompact
57
+ ? 'var(--text-tertiary)'
58
+ : 'var(--text-secondary)';
59
+ const metaDividerColor = featured
60
+ ? 'color-mix(in srgb, var(--text-tertiary) 72%, transparent)'
61
+ : resultCompact
62
+ ? 'color-mix(in srgb, var(--text-tertiary) 58%, transparent)'
63
+ : 'color-mix(in srgb, var(--text-tertiary) 64%, transparent)';
64
+ const badgePadding = resultCompact ? '1px 6px' : featuredCompact ? '2px 6px' : '2px 7px';
65
+ const badgeFontSize = resultCompact ? '9px' : featuredCompact ? '9px' : '10px';
66
+ const alwaysShowActions = mobileLayout && !featured;
67
+ const showFavoriteAction = alwaysShowActions || isFavorited || isHovered || favoriteLoading;
68
+ const showEditAction = alwaysShowActions || isHovered;
69
+ const resultCardBorder = isLightTheme
70
+ ? 'var(--border)'
71
+ : 'color-mix(in srgb, var(--outline-strong) 52%, var(--border))';
72
+ const overviewCardBorder = isLightTheme
73
+ ? 'color-mix(in srgb, var(--control-border) 76%, transparent)'
74
+ : 'color-mix(in srgb, var(--outline-strong) 40%, var(--border))';
75
+ const resultCardBackground = isLightTheme
76
+ ? 'var(--surface-elevated)'
77
+ : 'color-mix(in srgb, var(--surface-elevated) 94%, var(--bg-primary))';
78
+ const overviewCardBackground = isLightTheme
79
+ ? 'var(--surface-elevated)'
80
+ : 'color-mix(in srgb, var(--surface-elevated) 94%, var(--bg-primary))';
81
+
82
+ const handleCardClick = (e) => {
83
+ if (e.target.closest('button')) return;
84
+ recordResourceVisit({ resource, request, dispatch });
85
+ window.open(resource.url, '_blank', 'noopener');
86
+ };
87
+
88
+ const handleFavorite = async (e) => {
89
+ e.stopPropagation();
90
+ if (!currentUser) {
91
+ dispatch({ type: 'ADD_TOAST', toastType: 'info', message: '请登录后操作' });
92
+ return;
93
+ }
94
+ if (favoriteLoading) return;
95
+ setFavoriteLoading(true);
96
+ setHeartScale(1.18);
97
+ setTimeout(() => setHeartScale(1), 180);
98
+ try {
99
+ const { ok } = await request(`/api/resources/${resource.id}/favorite`, { method: 'POST' });
100
+ if (ok) {
101
+ dispatch({ type: 'TOGGLE_FAVORITE', resource });
102
+ dispatch({ type: 'UPDATE_RESOURCE', resource: { ...resource, isFavorited: !isFavorited } });
103
+ }
104
+ } finally {
105
+ setFavoriteLoading(false);
106
+ }
107
+ };
108
+
109
+ const actionButtonStyle = (active = false, visible = true) => ({
110
+ width: actionButtonSize,
111
+ height: actionButtonSize,
112
+ borderRadius: '12px',
113
+ border: `1px solid ${active ? 'color-mix(in srgb, var(--danger) 34%, var(--control-border))' : 'var(--control-border)'}`,
114
+ background: active
115
+ ? 'color-mix(in srgb, var(--danger) 12%, var(--control-bg))'
116
+ : isLightTheme
117
+ ? 'var(--surface-elevated)'
118
+ : 'color-mix(in srgb, var(--surface-elevated) 86%, var(--control-bg-muted))',
119
+ color: active ? 'var(--danger)' : 'var(--text-secondary)',
120
+ cursor: visible ? 'pointer' : 'default',
121
+ display: 'inline-flex',
122
+ alignItems: 'center',
123
+ justifyContent: 'center',
124
+ transition: 'transform 150ms, opacity 150ms, border-color 150ms, background 150ms, box-shadow 150ms',
125
+ transform: `${visible ? 'translateY(0)' : 'translateY(-3px)'} scale(${active ? heartScale : 1})`,
126
+ opacity: visible ? (favoriteLoading && active ? 0.7 : 1) : 0,
127
+ flexShrink: 0,
128
+ pointerEvents: visible ? 'auto' : 'none',
129
+ boxShadow: visible
130
+ ? active
131
+ ? '0 8px 14px color-mix(in srgb, var(--danger) 12%, transparent)'
132
+ : featured
133
+ ? '0 8px 16px color-mix(in srgb, var(--text-primary) 4%, transparent)'
134
+ : 'var(--shadow-control)'
135
+ : 'none',
136
+ });
137
+
138
+ const tagStyle = {
139
+ padding: featured ? (featuredCompact ? '2px 7px' : '3px 8px') : resultCompact ? '1px 6px' : '2px 7px',
140
+ borderRadius: '999px',
141
+ background: isLightTheme
142
+ ? 'color-mix(in srgb, var(--surface-muted) 82%, var(--surface-elevated))'
143
+ : 'color-mix(in srgb, var(--surface-muted) 74%, var(--surface-elevated))',
144
+ color: 'var(--text-secondary)',
145
+ fontSize: featured ? (featuredCompact ? '9px' : '10px') : resultCompact ? '8px' : '9px',
146
+ border: '1px solid color-mix(in srgb, var(--control-border) 52%, transparent)',
147
+ whiteSpace: 'nowrap',
148
+ };
149
+
150
+ return (
151
+ <div
152
+ data-rh-resource-card-compact={compactCard ? 'true' : 'false'}
153
+ data-rh-resource-card-featured-compact={featuredCompact ? 'true' : 'false'}
154
+ data-rh-resource-card-result-compact={resultCompact ? 'true' : 'false'}
155
+ data-rh-resource-card-mode={featured ? 'overview' : 'result'}
156
+ onClick={handleCardClick}
157
+ onMouseEnter={() => setIsHovered(true)}
158
+ onMouseLeave={() => setIsHovered(false)}
159
+ style={{
160
+ position: 'relative',
161
+ display: 'flex',
162
+ flexDirection: 'column',
163
+ gap: cardGap,
164
+ minHeight: cardMinHeight,
165
+ height: '100%',
166
+ padding: cardPadding,
167
+ borderRadius: featured ? '20px' : resultCompact ? '13px' : '14px',
168
+ border: `1px solid ${isHovered
169
+ ? isLightTheme
170
+ ? 'color-mix(in srgb, var(--brand) 24%, var(--border))'
171
+ : 'color-mix(in srgb, var(--brand) 24%, var(--border))'
172
+ : featured
173
+ ? overviewCardBorder
174
+ : resultCardBorder}`,
175
+ background: isHovered
176
+ ? featured
177
+ ? (isLightTheme
178
+ ? 'var(--surface-elevated)'
179
+ : 'color-mix(in srgb, var(--surface-elevated) 90%, var(--surface-tint))')
180
+ : resultCardBackground
181
+ : featured
182
+ ? overviewCardBackground
183
+ : resultCardBackground,
184
+ cursor: 'pointer',
185
+ boxShadow: isHovered
186
+ ? isLightTheme
187
+ ? 'var(--shadow-card-hover)'
188
+ : 'var(--shadow-dropdown)'
189
+ : featured
190
+ ? isLightTheme
191
+ ? 'var(--shadow-card)'
192
+ : '0 14px 28px color-mix(in srgb, var(--bg-primary) 20%, transparent)'
193
+ : isLightTheme
194
+ ? 'var(--shadow-card)'
195
+ : 'var(--shadow-card)',
196
+ transform: isHovered ? 'translateY(-2px)' : 'translateY(0)',
197
+ transition: 'transform 160ms, border-color 160ms, background 160ms, box-shadow 160ms',
198
+ overflow: 'hidden',
199
+ }}
200
+ >
201
+ <div
202
+ style={{
203
+ display: 'grid',
204
+ gridTemplateColumns: 'minmax(0, 1fr) auto',
205
+ gap: headerGap,
206
+ alignItems: 'start',
207
+ }}
208
+ >
209
+ <div style={{ display: 'flex', gap: leadGap, minWidth: 0, alignItems: 'flex-start' }}>
210
+ {resource.logoUrl && !imgError ? (
211
+ <img
212
+ src={resource.logoUrl}
213
+ onError={() => setImgError(true)}
214
+ style={{
215
+ width: mediaSize,
216
+ height: mediaSize,
217
+ borderRadius: mediaRadius,
218
+ objectFit: 'contain',
219
+ flexShrink: 0,
220
+ background: 'var(--surface-muted)',
221
+ border: '1px solid color-mix(in srgb, var(--control-border) 72%, transparent)',
222
+ }}
223
+ />
224
+ ) : (
225
+ <div
226
+ style={{
227
+ width: mediaSize,
228
+ height: mediaSize,
229
+ borderRadius: mediaRadius,
230
+ background: categoryTone?.accent || fallbackColor,
231
+ color: '#fff',
232
+ display: 'flex',
233
+ alignItems: 'center',
234
+ justifyContent: 'center',
235
+ fontSize: featured ? (featuredCompact ? '16px' : '17px') : resultCompact ? '13px' : '14px',
236
+ fontWeight: 800,
237
+ flexShrink: 0,
238
+ boxShadow: '0 6px 14px color-mix(in srgb, var(--text-primary) 8%, transparent)',
239
+ }}
240
+ >
241
+ {(resource.name || '?')[0].toUpperCase()}
242
+ </div>
243
+ )}
244
+
245
+ <div style={{ minWidth: 0, flex: 1, display: 'grid', gap: contentGap }}>
246
+ <div style={{ display: 'grid', gap: resultCompact ? '1px' : compactCard ? '1px' : '2px', minWidth: 0 }}>
247
+ <div
248
+ style={{
249
+ fontSize: titleSize,
250
+ lineHeight: 1.22,
251
+ fontWeight: 800,
252
+ color: 'var(--text-primary)',
253
+ letterSpacing: featured ? '-0.02em' : '-0.01em',
254
+ display: '-webkit-box',
255
+ WebkitLineClamp: titleLineClamp,
256
+ WebkitBoxOrient: 'vertical',
257
+ overflow: 'hidden',
258
+ wordBreak: 'break-word',
259
+ maxWidth: '100%',
260
+ }}
261
+ >
262
+ {resource.name}
263
+ </div>
264
+ <div
265
+ style={{
266
+ display: 'block',
267
+ maxWidth: '100%',
268
+ fontSize: domainSize,
269
+ color: isHovered ? 'var(--text-secondary)' : 'var(--text-tertiary)',
270
+ whiteSpace: 'nowrap',
271
+ overflow: 'hidden',
272
+ textOverflow: 'ellipsis',
273
+ letterSpacing: '0.01em',
274
+ }}
275
+ >
276
+ {domain}
277
+ </div>
278
+ </div>
279
+
280
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: resultCompact ? '3px' : compactCard ? '3px' : '4px', alignItems: 'center' }}>
281
+ {category && (
282
+ <span
283
+ style={{
284
+ padding: badgePadding,
285
+ borderRadius: '999px',
286
+ background: categoryTone
287
+ ? categoryTone.soft
288
+ : 'var(--surface-muted)',
289
+ color: categoryTone?.accent || 'var(--text-primary)',
290
+ fontSize: badgeFontSize,
291
+ fontWeight: featured ? 800 : 700,
292
+ border: categoryTone
293
+ ? `1px solid ${categoryTone.border}`
294
+ : '1px solid color-mix(in srgb, var(--control-border) 52%, transparent)',
295
+ }}
296
+ >
297
+ {category.name}
298
+ </span>
299
+ )}
300
+ <span
301
+ style={{
302
+ padding: badgePadding,
303
+ borderRadius: '999px',
304
+ background: resource.visibility === 'private'
305
+ ? 'color-mix(in srgb, var(--danger) 14%, var(--surface-elevated))'
306
+ : isLightTheme
307
+ ? 'var(--surface-muted)'
308
+ : 'color-mix(in srgb, var(--surface-muted) 72%, var(--surface-elevated))',
309
+ color: resource.visibility === 'private' ? 'var(--danger)' : 'var(--text-secondary)',
310
+ fontSize: badgeFontSize,
311
+ fontWeight: 600,
312
+ border: resource.visibility === 'private'
313
+ ? '1px solid color-mix(in srgb, var(--danger) 22%, var(--outline-strong))'
314
+ : '1px solid color-mix(in srgb, var(--control-border) 72%, transparent)',
315
+ }}
316
+ >
317
+ {resource.visibility === 'private' ? '私有' : '公开'}
318
+ </span>
319
+ </div>
320
+
321
+ {(hasDescription || (featured && domain)) ? (
322
+ <div
323
+ style={{
324
+ fontSize: resultCompact ? '10px' : featuredCompact ? '10px' : '11px',
325
+ lineHeight: resultCompact ? 1.4 : 1.45,
326
+ color: 'var(--text-secondary)',
327
+ display: '-webkit-box',
328
+ WebkitLineClamp: resultCompact ? 1 : 2,
329
+ WebkitBoxOrient: 'vertical',
330
+ overflow: 'hidden',
331
+ minHeight: featured ? (featuredCompact ? '26px' : '34px') : resultCompact ? '14px' : '26px',
332
+ }}
333
+ >
334
+ {hasDescription ? resource.description : domain || '暂无描述'}
335
+ </div>
336
+ ) : null}
337
+ </div>
338
+ </div>
339
+
340
+ <div
341
+ style={{
342
+ display: 'grid',
343
+ gap: resultCompact ? '4px' : compactCard ? '4px' : featuredCompact ? '4px' : '5px',
344
+ justifyItems: 'end',
345
+ alignContent: 'start',
346
+ minWidth: actionRailWidth,
347
+ }}
348
+ >
349
+ <window.TooltipIconButton
350
+ label={isFavorited ? '取消收藏' : '收藏资源'}
351
+ placement="left"
352
+ data-rh-resource-favorite
353
+ data-active={isFavorited ? 'true' : 'false'}
354
+ onClick={handleFavorite}
355
+ buttonStyle={actionButtonStyle(isFavorited, showFavoriteAction)}
356
+ >
357
+ <lucide.Heart
358
+ size={actionIconSize}
359
+ fill={isFavorited ? 'var(--danger)' : 'none'}
360
+ style={{ color: isFavorited ? 'var(--danger)' : 'var(--text-secondary)' }}
361
+ />
362
+ </window.TooltipIconButton>
363
+ {canEdit && onEdit && (
364
+ <window.TooltipIconButton
365
+ label="编辑资源"
366
+ placement="left"
367
+ onClick={(e) => {
368
+ e.stopPropagation();
369
+ onEdit(resource);
370
+ }}
371
+ buttonStyle={actionButtonStyle(false, showEditAction)}
372
+ >
373
+ <lucide.Edit2 size={actionIconSize} />
374
+ </window.TooltipIconButton>
375
+ )}
376
+ </div>
377
+ </div>
378
+
379
+ {hasVisibleTags ? (
380
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: tagGap, alignContent: 'flex-start' }}>
381
+ {displayTags.map((tag) => (
382
+ <span key={tag} style={tagStyle}>
383
+ #{tag}
384
+ </span>
385
+ ))}
386
+ {extraTags > 0 && (
387
+ <span style={tagStyle}>
388
+ +{extraTags}
389
+ </span>
390
+ )}
391
+ </div>
392
+ ) : null}
393
+
394
+ <div
395
+ style={{
396
+ marginTop: 'auto',
397
+ display: 'flex',
398
+ justifyContent: 'space-between',
399
+ gap: resultCompact ? '6px' : '8px',
400
+ alignItems: 'center',
401
+ flexWrap: 'wrap',
402
+ paddingTop: hasVisibleTags ? (resultCompact ? 0 : compactCard ? 0 : featured ? (featuredCompact ? '3px' : '6px') : '1px') : 0,
403
+ borderTop: featured && hasVisibleTags
404
+ ? '1px solid color-mix(in srgb, var(--outline-strong) 12%, transparent)'
405
+ : 'none',
406
+ }}
407
+ >
408
+ <div style={{ display: 'inline-flex', flexWrap: 'wrap', gap: resultCompact ? '4px' : '5px', minWidth: 0, alignItems: 'center' }}>
409
+ <span style={{ fontSize: metaFontSize, color: metaTextColor, whiteSpace: 'nowrap' }}>
410
+ 更新于 {updatedLabel}
411
+ </span>
412
+ <span style={{ fontSize: metaFontSize, color: metaDividerColor }}>•</span>
413
+ <span style={{ fontSize: metaFontSize, color: metaTextColor, whiteSpace: 'nowrap' }}>
414
+ 访问 {resource.visitCount || 0}
415
+ </span>
416
+ </div>
417
+ </div>
418
+ </div>
419
+ );
420
+ }
421
+
422
+ window.ResourceCard = ResourceCard;