@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,287 @@
1
+ function ResourceRow({ resource, onEdit, isLast = 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
+
10
+ const { getCategoryTone, getLogoFallbackColor, getDomain, formatDate, recordResourceVisit } = window.helpers;
11
+ const currentUser = state?.currentUser;
12
+ const theme = state?.theme || window.getTheme?.() || 'system';
13
+ const isDarkTheme = theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
14
+ const isLightTheme = !isDarkTheme;
15
+ const isFavorited = state?.favorites?.some(f => f.id === resource.id) ?? false;
16
+ const canEdit = currentUser && (currentUser.id === resource.ownerId || currentUser.role === 'admin');
17
+ const category = resource.category || (resource.categoryName ? { name: resource.categoryName, color: resource.categoryColor } : null);
18
+ const categoryTone = category ? getCategoryTone(category, resource.id || resource.name) : null;
19
+ const fallbackColor = getLogoFallbackColor(resource.name, category);
20
+ const domain = getDomain(resource.url);
21
+ const updatedLabel = formatDate(resource.updatedAt || resource.createdAt) || '刚刚更新';
22
+ const compactLayout = viewportWidth < 1120;
23
+ const mobileLayout = viewportWidth < 760;
24
+
25
+ const handleRowClick = (e) => {
26
+ if (e.target.closest('button')) return;
27
+ recordResourceVisit({ resource, request, dispatch });
28
+ window.open(resource.url, '_blank', 'noopener');
29
+ };
30
+
31
+ const handleFavorite = async (e) => {
32
+ e.stopPropagation();
33
+ if (!currentUser) {
34
+ dispatch({ type: 'ADD_TOAST', toastType: 'info', message: '请登录后操作' });
35
+ return;
36
+ }
37
+ setHeartScale(1.3);
38
+ setTimeout(() => setHeartScale(1), 200);
39
+ const { ok } = await request(`/api/resources/${resource.id}/favorite`, { method: 'POST' });
40
+ if (ok) dispatch({ type: 'TOGGLE_FAVORITE', resource });
41
+ };
42
+
43
+ const displayTags = resource.tags?.slice(0, compactLayout ? 2 : 3) || [];
44
+ const extraTags = (resource.tags?.length || 0) - displayTags.length;
45
+ const showFavoriteAction = isFavorited || isHovered || mobileLayout;
46
+ const showEditAction = canEdit && (isHovered || mobileLayout);
47
+ const rowDividerColor = isLightTheme
48
+ ? '1px solid color-mix(in srgb, var(--control-border) 62%, transparent)'
49
+ : '1px solid color-mix(in srgb, var(--outline-strong) 16%, transparent)';
50
+ const rowHoverBackground = isLightTheme
51
+ ? 'var(--surface-hover)'
52
+ : 'color-mix(in srgb, var(--surface-elevated) 82%, var(--control-bg-muted))';
53
+ const rowActionButtonStyle = (active = false, visible = true) => ({
54
+ width: '32px',
55
+ height: '32px',
56
+ borderRadius: '11px',
57
+ border: `1px solid ${active ? 'color-mix(in srgb, var(--danger) 30%, var(--control-border))' : 'color-mix(in srgb, var(--control-border) 64%, transparent)'}`,
58
+ background: active
59
+ ? 'color-mix(in srgb, var(--danger) 12%, var(--control-bg))'
60
+ : isLightTheme
61
+ ? 'var(--surface-elevated)'
62
+ : 'color-mix(in srgb, var(--surface-elevated) 82%, var(--control-bg-muted))',
63
+ color: active ? 'var(--danger)' : 'var(--text-secondary)',
64
+ cursor: visible ? 'pointer' : 'default',
65
+ display: 'inline-flex',
66
+ alignItems: 'center',
67
+ justifyContent: 'center',
68
+ opacity: visible ? 1 : 0,
69
+ pointerEvents: visible ? 'auto' : 'none',
70
+ transform: `scale(${active ? heartScale : 1})`,
71
+ boxShadow: active ? '0 8px 14px color-mix(in srgb, var(--danger) 10%, transparent)' : 'none',
72
+ transition: 'transform 180ms, opacity 150ms, border-color 150ms, background 150ms, box-shadow 150ms',
73
+ });
74
+ const infoPillStyle = {
75
+ display: 'inline-flex',
76
+ alignItems: 'center',
77
+ minHeight: '22px',
78
+ padding: '0 7px',
79
+ borderRadius: '999px',
80
+ border: '1px solid color-mix(in srgb, var(--control-border) 54%, transparent)',
81
+ background: isLightTheme
82
+ ? 'var(--surface-muted)'
83
+ : 'color-mix(in srgb, var(--surface-elevated) 76%, var(--surface-muted))',
84
+ color: 'var(--text-secondary)',
85
+ fontSize: '10px',
86
+ fontWeight: 600,
87
+ whiteSpace: 'nowrap',
88
+ };
89
+
90
+ return (
91
+ <div
92
+ data-rh-resource-row
93
+ data-rh-resource-row-compact={compactLayout ? 'true' : 'false'}
94
+ onClick={handleRowClick}
95
+ onMouseEnter={() => setIsHovered(true)}
96
+ onMouseLeave={() => setIsHovered(false)}
97
+ style={{
98
+ display: 'grid',
99
+ gridTemplateColumns: mobileLayout ? 'minmax(0, 1fr) auto' : compactLayout ? 'minmax(0, 1.2fr) minmax(0, 0.95fr) auto' : 'minmax(0, 1.2fr) minmax(0, 1fr) minmax(0, 0.9fr) auto',
100
+ gap: mobileLayout ? '12px' : '16px',
101
+ alignItems: 'center',
102
+ minHeight: mobileLayout ? '78px' : '72px',
103
+ padding: mobileLayout ? '12px 14px' : '12px 16px',
104
+ borderBottom: isLast ? 'none' : rowDividerColor,
105
+ background: isHovered ? rowHoverBackground : 'transparent',
106
+ cursor: 'pointer',
107
+ transition: 'background 150ms, box-shadow 150ms',
108
+ boxShadow: isHovered ? 'inset 0 1px 0 color-mix(in srgb, var(--surface-elevated) 42%, transparent)' : 'none',
109
+ }}
110
+ >
111
+ <div style={{ display: 'flex', gap: '12px', minWidth: 0, alignItems: 'flex-start' }}>
112
+ {resource.logoUrl && !imgError ? (
113
+ <img
114
+ src={resource.logoUrl}
115
+ onError={() => setImgError(true)}
116
+ style={{
117
+ width: mobileLayout ? '36px' : '38px',
118
+ height: mobileLayout ? '36px' : '38px',
119
+ borderRadius: '11px',
120
+ objectFit: 'contain',
121
+ flexShrink: 0,
122
+ background: 'var(--surface-muted)',
123
+ border: '1px solid color-mix(in srgb, var(--control-border) 70%, transparent)',
124
+ }}
125
+ />
126
+ ) : (
127
+ <div
128
+ style={{
129
+ width: mobileLayout ? '36px' : '38px',
130
+ height: mobileLayout ? '36px' : '38px',
131
+ borderRadius: '11px',
132
+ background: categoryTone?.accent || fallbackColor,
133
+ color: '#fff',
134
+ display: 'flex',
135
+ alignItems: 'center',
136
+ justifyContent: 'center',
137
+ fontSize: mobileLayout ? '13px' : '14px',
138
+ fontWeight: 800,
139
+ flexShrink: 0,
140
+ boxShadow: '0 6px 14px color-mix(in srgb, var(--text-primary) 8%, transparent)',
141
+ }}
142
+ >
143
+ {(resource.name || '?')[0].toUpperCase()}
144
+ </div>
145
+ )}
146
+
147
+ <div style={{ minWidth: 0, flex: 1, display: 'grid', gap: '6px' }}>
148
+ <div style={{ display: 'grid', gap: '3px', minWidth: 0 }}>
149
+ <div
150
+ style={{
151
+ fontSize: '14px',
152
+ lineHeight: 1.3,
153
+ fontWeight: 700,
154
+ color: 'var(--text-primary)',
155
+ whiteSpace: 'nowrap',
156
+ overflow: 'hidden',
157
+ textOverflow: 'ellipsis',
158
+ }}
159
+ >
160
+ {resource.name}
161
+ </div>
162
+ <div
163
+ style={{
164
+ fontSize: '11px',
165
+ color: isHovered ? 'var(--text-secondary)' : 'var(--text-tertiary)',
166
+ whiteSpace: 'nowrap',
167
+ overflow: 'hidden',
168
+ textOverflow: 'ellipsis',
169
+ }}
170
+ >
171
+ {domain}
172
+ </div>
173
+ </div>
174
+
175
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: '5px', alignItems: 'center' }}>
176
+ {category && (
177
+ <span
178
+ style={{
179
+ ...infoPillStyle,
180
+ background: categoryTone
181
+ ? categoryTone.soft
182
+ : infoPillStyle.background,
183
+ color: categoryTone?.accent || 'var(--text-secondary)',
184
+ border: categoryTone
185
+ ? `1px solid ${categoryTone.border}`
186
+ : infoPillStyle.border,
187
+ }}
188
+ >
189
+ {category.name}
190
+ </span>
191
+ )}
192
+ <span
193
+ style={{
194
+ ...infoPillStyle,
195
+ background: resource.visibility === 'private'
196
+ ? 'color-mix(in srgb, var(--danger) 14%, var(--surface-elevated))'
197
+ : 'var(--surface-muted)',
198
+ color: resource.visibility === 'private' ? 'var(--danger)' : 'var(--text-secondary)',
199
+ border: resource.visibility === 'private'
200
+ ? '1px solid color-mix(in srgb, var(--danger) 24%, var(--outline-strong))'
201
+ : '1px solid color-mix(in srgb, var(--control-border) 72%, transparent)',
202
+ }}
203
+ >
204
+ {resource.visibility === 'private' ? '私有' : '公开'}
205
+ </span>
206
+ </div>
207
+ </div>
208
+ </div>
209
+
210
+ {!mobileLayout && (
211
+ <div style={{ minWidth: 0, display: 'grid', gap: '6px' }}>
212
+ <div
213
+ style={{
214
+ fontSize: '12px',
215
+ lineHeight: 1.45,
216
+ color: 'var(--text-secondary)',
217
+ whiteSpace: 'nowrap',
218
+ overflow: 'hidden',
219
+ textOverflow: 'ellipsis',
220
+ }}
221
+ >
222
+ {resource.description || '暂无描述'}
223
+ </div>
224
+ <div style={{ display: 'inline-flex', flexWrap: 'wrap', gap: '5px', minWidth: 0, alignItems: 'center' }}>
225
+ <span style={{ fontSize: '10px', color: 'var(--text-tertiary)', whiteSpace: 'nowrap' }}>
226
+ 更新于 {updatedLabel}
227
+ </span>
228
+ <span style={{ fontSize: '10px', color: 'color-mix(in srgb, var(--text-tertiary) 58%, transparent)' }}>•</span>
229
+ <span style={{ fontSize: '10px', color: 'var(--text-tertiary)', whiteSpace: 'nowrap' }}>
230
+ 访问 {resource.visitCount || 0}
231
+ </span>
232
+ </div>
233
+ </div>
234
+ )}
235
+
236
+ {!compactLayout && (
237
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: '5px', alignContent: 'flex-start', minWidth: 0, overflow: 'hidden' }}>
238
+ {displayTags.map((tag) => (
239
+ <span
240
+ key={tag}
241
+ style={{
242
+ ...infoPillStyle,
243
+ background: isLightTheme
244
+ ? 'color-mix(in srgb, var(--surface-muted) 82%, var(--surface-elevated))'
245
+ : 'color-mix(in srgb, var(--surface-elevated) 72%, var(--surface-muted))',
246
+ }}
247
+ >
248
+ #{tag}
249
+ </span>
250
+ ))}
251
+ {extraTags > 0 && (
252
+ <span style={{ ...infoPillStyle, color: 'var(--text-secondary)' }}>
253
+ +{extraTags}
254
+ </span>
255
+ )}
256
+ </div>
257
+ )}
258
+
259
+ <div style={{ width: mobileLayout ? '68px' : '76px', display: 'flex', gap: '6px', justifyContent: 'flex-end', flexShrink: 0, alignItems: 'center' }}>
260
+ <window.TooltipIconButton
261
+ label={isFavorited ? '取消收藏' : '收藏资源'}
262
+ data-rh-resource-favorite
263
+ data-active={isFavorited ? 'true' : 'false'}
264
+ onClick={handleFavorite}
265
+ buttonStyle={rowActionButtonStyle(isFavorited, showFavoriteAction)}
266
+ >
267
+ <lucide.Heart
268
+ size={15}
269
+ fill={isFavorited ? 'var(--danger)' : 'none'}
270
+ style={{ color: isFavorited ? 'var(--danger)' : 'var(--text-secondary)' }}
271
+ />
272
+ </window.TooltipIconButton>
273
+ {showEditAction ? (
274
+ <window.TooltipIconButton
275
+ label="编辑资源"
276
+ onClick={(e) => { e.stopPropagation(); onEdit && onEdit(resource); }}
277
+ buttonStyle={rowActionButtonStyle(false, true)}
278
+ >
279
+ <lucide.Edit2 size={15} style={{ color: 'var(--text-secondary)' }} />
280
+ </window.TooltipIconButton>
281
+ ) : null}
282
+ </div>
283
+ </div>
284
+ );
285
+ }
286
+
287
+ window.ResourceRow = ResourceRow;