@mdguggenbichler/slugbase-core 0.0.30 → 0.0.32

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 (35) hide show
  1. package/frontend/src/components/FilterChips.tsx +5 -3
  2. package/frontend/src/components/StatCard.tsx +82 -5
  3. package/frontend/src/components/bookmarks/BookmarkCard.tsx +317 -210
  4. package/frontend/src/components/bookmarks/BookmarkTableView.tsx +47 -23
  5. package/frontend/src/components/collections/CollectionToolbar.tsx +294 -0
  6. package/frontend/src/components/collections/README.md +44 -0
  7. package/frontend/src/components/collections/index.ts +2 -0
  8. package/frontend/src/components/dashboard/DashboardHeader.tsx +16 -0
  9. package/frontend/src/components/dashboard/MostUsedTagsSection.tsx +49 -0
  10. package/frontend/src/components/dashboard/PinnedSection.tsx +110 -0
  11. package/frontend/src/components/dashboard/QuickAccessSection.tsx +120 -0
  12. package/frontend/src/components/dashboard/README.md +35 -0
  13. package/frontend/src/components/dashboard/StatsCardsRow.tsx +78 -0
  14. package/frontend/src/components/dashboard/index.ts +17 -0
  15. package/frontend/src/config/docs.ts +19 -0
  16. package/frontend/src/config/mode.ts +6 -4
  17. package/frontend/src/locales/de.json +2 -0
  18. package/frontend/src/locales/en.json +1 -0
  19. package/frontend/src/locales/es.json +2 -0
  20. package/frontend/src/locales/fr.json +2 -0
  21. package/frontend/src/locales/it.json +2 -0
  22. package/frontend/src/locales/ja.json +2 -0
  23. package/frontend/src/locales/nl.json +2 -0
  24. package/frontend/src/locales/pl.json +2 -0
  25. package/frontend/src/locales/pt.json +2 -0
  26. package/frontend/src/locales/ru.json +2 -0
  27. package/frontend/src/locales/zh.json +2 -0
  28. package/frontend/src/pages/Bookmarks.tsx +97 -214
  29. package/frontend/src/pages/Dashboard.tsx +99 -216
  30. package/frontend/src/pages/Folders.tsx +181 -251
  31. package/frontend/src/pages/Profile.tsx +3 -2
  32. package/frontend/src/pages/Signup.tsx +3 -2
  33. package/frontend/src/pages/Tags.tsx +87 -145
  34. package/frontend/src/pages/admin/AdminLayout.tsx +2 -1
  35. package/package.json +1 -1
@@ -1,12 +1,16 @@
1
1
  import { Link } from 'react-router-dom';
2
2
  import { useTranslation } from 'react-i18next';
3
3
  import { useEffect, useState } from 'react';
4
- import { Bookmark, Share2, TrendingUp, Plus, ArrowRight, X, ChevronDown, ChevronRight, CheckCircle, Folder, Tag } from 'lucide-react';
5
- import Button from '../components/ui/Button';
4
+ import { Share2, X, ChevronDown, ChevronRight, CheckCircle } from 'lucide-react';
6
5
  import { Card, CardContent } from '../components/ui/card';
7
- import { Badge } from '../components/ui/badge';
8
6
  import { StatCard } from '../components/StatCard';
9
- import { EmptyState } from '../components/EmptyState';
7
+ import {
8
+ DashboardHeader,
9
+ StatsCardsRow,
10
+ PinnedSection,
11
+ QuickAccessSection,
12
+ MostUsedTagsSection,
13
+ } from '../components/dashboard';
10
14
  import api from '../api/client';
11
15
  import { useAppConfig } from '../contexts/AppConfigContext';
12
16
 
@@ -39,25 +43,9 @@ interface DashboardStats {
39
43
  pinnedBookmarks?: QuickAccessBookmark[];
40
44
  }
41
45
 
42
- function getDomain(url: string): string {
43
- try {
44
- return new URL(url).hostname.replace(/^www\./, '');
45
- } catch {
46
- return '';
47
- }
48
- }
49
-
50
- function getFaviconUrl(url: string): string {
51
- try {
52
- const host = new URL(url).hostname;
53
- return `https://www.google.com/s2/favicons?domain=${host}&sz=32`;
54
- } catch {
55
- return '';
56
- }
57
- }
58
-
59
46
  const PRO_TIP_DISMISSED_KEY = 'slugbase_dashboard_protip_dismissed';
60
47
  const ONBOARDING_DISMISSED_KEY = 'slugbase_dashboard_onboarding_dismissed';
48
+ const PINNED_QUICK_ACCESS_MAX_ITEMS = 6;
61
49
 
62
50
  function ProTipBanner({
63
51
  onDismiss,
@@ -72,7 +60,10 @@ function ProTipBanner({
72
60
  <div className="flex items-start gap-3 rounded-xl border border-border bg-card shadow-sm px-4 py-3">
73
61
  <p className="text-sm text-muted-foreground flex-1 min-w-0">
74
62
  {t('dashboard.proTipBody')}{' '}
75
- <Link to={`${pathPrefix}/search-engine-guide`.replace(/\/+/g, '/') || '/search-engine-guide'} className="text-primary font-medium hover:underline">
63
+ <Link
64
+ to={`${pathPrefix}/search-engine-guide`.replace(/\/+/g, '/') || '/search-engine-guide'}
65
+ className="text-primary font-medium hover:underline"
66
+ >
76
67
  {t('dashboard.proTipLink')}
77
68
  </Link>
78
69
  </p>
@@ -102,17 +93,35 @@ function OnboardingChecklist({
102
93
  t: (key: string) => string;
103
94
  }) {
104
95
  const [collapsed, setCollapsed] = useState(true);
105
- const [dismissed, setDismissed] = useState(() => typeof window !== 'undefined' && !!localStorage.getItem(ONBOARDING_DISMISSED_KEY));
96
+ const [dismissed, setDismissed] = useState(
97
+ () => typeof window !== 'undefined' && !!localStorage.getItem(ONBOARDING_DISMISSED_KEY)
98
+ );
106
99
  const allDone = totalBookmarks > 0 && totalFolders > 0 && topTagsCount > 0;
107
100
  const show = !dismissed && !allDone;
108
101
 
109
102
  if (!show) return null;
110
103
 
111
104
  const steps = [
112
- { done: totalBookmarks > 0, label: t('dashboard.onboardingImport'), to: `${pathPrefix}/bookmarks?import=true`.replace(/\/+/g, '/') || '/bookmarks?import=true' },
113
- { done: false, label: t('dashboard.onboardingSearchEngine'), to: `${pathPrefix}/search-engine-guide`.replace(/\/+/g, '/') || '/search-engine-guide' },
114
- { done: totalFolders > 0, label: t('dashboard.onboardingFolder'), to: `${pathPrefix}/folders`.replace(/\/+/g, '/') || '/folders' },
115
- { done: topTagsCount > 0, label: t('dashboard.onboardingTag'), to: `${pathPrefix}/bookmarks`.replace(/\/+/g, '/') || '/bookmarks' },
105
+ {
106
+ done: totalBookmarks > 0,
107
+ label: t('dashboard.onboardingImport'),
108
+ to: `${pathPrefix}/bookmarks?import=true`.replace(/\/+/g, '/') || '/bookmarks?import=true',
109
+ },
110
+ {
111
+ done: false,
112
+ label: t('dashboard.onboardingSearchEngine'),
113
+ to: `${pathPrefix}/search-engine-guide`.replace(/\/+/g, '/') || '/search-engine-guide',
114
+ },
115
+ {
116
+ done: totalFolders > 0,
117
+ label: t('dashboard.onboardingFolder'),
118
+ to: `${pathPrefix}/folders`.replace(/\/+/g, '/') || '/folders',
119
+ },
120
+ {
121
+ done: topTagsCount > 0,
122
+ label: t('dashboard.onboardingTag'),
123
+ to: `${pathPrefix}/bookmarks`.replace(/\/+/g, '/') || '/bookmarks',
124
+ },
116
125
  ];
117
126
 
118
127
  function handleDismiss() {
@@ -128,7 +137,11 @@ function OnboardingChecklist({
128
137
  onClick={() => setCollapsed(!collapsed)}
129
138
  className="w-full flex items-center gap-2 p-4 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset rounded-t-xl"
130
139
  >
131
- {collapsed ? <ChevronRight className="h-5 w-5 text-muted-foreground" /> : <ChevronDown className="h-5 w-5 text-muted-foreground" />}
140
+ {collapsed ? (
141
+ <ChevronRight className="h-5 w-5 text-muted-foreground" />
142
+ ) : (
143
+ <ChevronDown className="h-5 w-5 text-muted-foreground" />
144
+ )}
132
145
  <span className="font-medium text-sm">{t('dashboard.onboardingTitle')}</span>
133
146
  </button>
134
147
  {!collapsed && (
@@ -140,7 +153,11 @@ function OnboardingChecklist({
140
153
  to={step.to}
141
154
  className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
142
155
  >
143
- {step.done ? <CheckCircle className="h-4 w-4 text-primary shrink-0" /> : <span className="w-4 h-4 rounded-full border border-muted-foreground shrink-0" />}
156
+ {step.done ? (
157
+ <CheckCircle className="h-4 w-4 text-primary shrink-0" />
158
+ ) : (
159
+ <span className="w-4 h-4 rounded-full border border-muted-foreground shrink-0" />
160
+ )}
144
161
  <span>{step.label}</span>
145
162
  </Link>
146
163
  </li>
@@ -164,7 +181,9 @@ export default function Dashboard() {
164
181
  const { pathPrefixForLinks } = useAppConfig();
165
182
  const prefix = (pathPrefixForLinks || '').replace(/\/+/g, '/') || '';
166
183
  const [stats, setStats] = useState<DashboardStats | null>(null);
167
- const [proTipDismissed, setProTipDismissed] = useState(() => typeof window !== 'undefined' && !!localStorage.getItem(PRO_TIP_DISMISSED_KEY));
184
+ const [proTipDismissed, setProTipDismissed] = useState(
185
+ () => typeof window !== 'undefined' && !!localStorage.getItem(PRO_TIP_DISMISSED_KEY)
186
+ );
168
187
 
169
188
  useEffect(() => {
170
189
  loadStats();
@@ -182,9 +201,17 @@ export default function Dashboard() {
182
201
  const quickAccess = stats?.quickAccessBookmarks ?? [];
183
202
  const pinnedBookmarks = stats?.pinnedBookmarks ?? [];
184
203
 
204
+ function handleBookmarkOpen(id: string, url: string) {
205
+ api.post(`/bookmarks/${id}/track-access`).catch(() => {});
206
+ window.open(url, '_blank', 'noopener,noreferrer');
207
+ }
208
+
209
+ function handleCopyUrl(url: string) {
210
+ navigator.clipboard.writeText(url).catch(() => {});
211
+ }
212
+
185
213
  return (
186
- <div className="space-y-8">
187
- {/* Feature discovery banner — dismissable, localStorage */}
214
+ <div className="space-y-6">
188
215
  {!proTipDismissed && (
189
216
  <ProTipBanner
190
217
  onDismiss={() => {
@@ -196,172 +223,50 @@ export default function Dashboard() {
196
223
  />
197
224
  )}
198
225
 
199
- {/* Top stats: bookmarks / folders / tags */}
226
+ <DashboardHeader
227
+ title={t('dashboard.overview')}
228
+ subtitle={t('dashboard.overviewSubtitle')}
229
+ />
230
+
200
231
  {stats && (
201
- <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
202
- <StatCard
203
- label={t('dashboard.statsBookmarks')}
204
- value={stats.totalBookmarks}
205
- icon={Bookmark}
206
- href={prefix + '/bookmarks'}
207
- dense
208
- iconContainerClassName="bg-primary/20"
209
- iconColorClassName="text-primary"
210
- />
211
- <StatCard
212
- label={t('dashboard.statsFolders')}
213
- value={stats.totalFolders}
214
- icon={Folder}
215
- href={prefix + '/folders'}
216
- dense
217
- iconContainerClassName="bg-primary/20"
218
- iconColorClassName="text-primary"
219
- />
220
- <StatCard
221
- label={t('dashboard.statsTags')}
222
- value={stats.totalTags}
223
- icon={Tag}
224
- href={prefix + '/tags'}
225
- dense
226
- iconContainerClassName="bg-primary/20"
227
- iconColorClassName="text-primary"
228
- />
229
- </div>
232
+ <StatsCardsRow
233
+ bookmarks={{
234
+ label: t('dashboard.statsBookmarks'),
235
+ value: stats.totalBookmarks,
236
+ href: prefix + '/bookmarks',
237
+ }}
238
+ folders={{
239
+ label: t('dashboard.statsFolders'),
240
+ value: stats.totalFolders,
241
+ href: prefix + '/folders',
242
+ }}
243
+ tags={{
244
+ label: t('dashboard.statsTags'),
245
+ value: stats.totalTags,
246
+ href: prefix + '/tags',
247
+ }}
248
+ />
230
249
  )}
231
250
 
232
- {/* Pinned bookmarks — same style as Quick Access */}
233
- <section className="space-y-3">
234
- <div className="flex items-center justify-between flex-wrap gap-2">
235
- <h2 className="text-sm font-medium uppercase tracking-wide text-muted-foreground">
236
- {t('dashboard.pinned')}
237
- </h2>
238
- <Link
239
- to={prefix + '/bookmarks?pinned=true'}
240
- className="text-sm font-medium text-primary hover:underline"
241
- >
242
- {t('dashboard.viewAll')}
243
- <ArrowRight className="inline-block ml-1 h-4 w-4" />
244
- </Link>
245
- </div>
246
- {pinnedBookmarks.length > 0 ? (
247
- <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-8 gap-3 overflow-hidden min-w-0">
248
- {pinnedBookmarks.map((b) => (
249
- <button
250
- key={b.id}
251
- type="button"
252
- onClick={() => {
253
- api.post(`/bookmarks/${b.id}/track-access`).catch(() => {});
254
- window.open(b.url, '_blank', 'noopener,noreferrer');
255
- }}
256
- className="group flex flex-col rounded-xl border border-border bg-card p-3 hover:border-primary/50 hover:shadow-md transition-all text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
257
- >
258
- <div className="flex items-start gap-2.5 mb-1.5">
259
- <div className="relative h-7 w-7 shrink-0 rounded bg-muted overflow-hidden">
260
- <img
261
- src={getFaviconUrl(b.url)}
262
- alt=""
263
- className="h-7 w-7 w-full object-cover"
264
- onError={(e) => {
265
- (e.target as HTMLImageElement).style.display = 'none';
266
- (e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden');
267
- }}
268
- />
269
- <span className="hidden absolute inset-0 flex items-center justify-center bg-primary/10 rounded">
270
- <Bookmark className="h-3.5 w-3.5 text-primary" />
271
- </span>
272
- </div>
273
- <p className="font-medium text-foreground line-clamp-2 text-xs flex-1 min-w-0">{b.title}</p>
274
- </div>
275
- <p className="text-[11px] text-primary font-mono truncate" title={b.slug ? `go/${b.slug}` : undefined}>{b.slug ? `go/${b.slug}` : getDomain(b.url)}</p>
276
- <p className="text-[11px] text-muted-foreground truncate mt-0.5">{getDomain(b.url)}</p>
277
- </button>
278
- ))}
279
- </div>
280
- ) : (
281
- <Card className="border border-border bg-card shadow-sm">
282
- <CardContent className="p-6">
283
- <EmptyState
284
- icon={Bookmark}
285
- title={t('dashboard.noPinnedBookmarks')}
286
- description={t('dashboard.pinFromBookmarks')}
287
- action={
288
- <Link to={prefix + '/bookmarks'}>
289
- <Button variant="secondary">{t('dashboard.pinFromBookmarksLink')}</Button>
290
- </Link>
291
- }
292
- />
293
- </CardContent>
294
- </Card>
295
- )}
296
- </section>
251
+ <PinnedSection
252
+ items={pinnedBookmarks}
253
+ pathPrefix={prefix}
254
+ maxItems={PINNED_QUICK_ACCESS_MAX_ITEMS}
255
+ t={t}
256
+ onOpen={handleBookmarkOpen}
257
+ onCopyUrl={handleCopyUrl}
258
+ />
297
259
 
298
- {/* Quick Access bookmarks — horizontal card grid; responsive columns and scroll on narrow */}
299
- <section className="space-y-3">
300
- <div className="flex items-center justify-between flex-wrap gap-2">
301
- <h2 className="text-sm font-medium uppercase tracking-wide text-muted-foreground">
302
- {t('dashboard.quickAccess')}
303
- </h2>
304
- <Link
305
- to={prefix + '/bookmarks'}
306
- className="text-sm font-medium text-primary hover:underline"
307
- >
308
- {t('dashboard.viewAll')}
309
- <ArrowRight className="inline-block ml-1 h-4 w-4" />
310
- </Link>
311
- </div>
312
- {quickAccess.length > 0 ? (
313
- <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-8 gap-3 overflow-hidden min-w-0">
314
- {quickAccess.map((b) => (
315
- <button
316
- key={b.id}
317
- type="button"
318
- onClick={() => {
319
- api.post(`/bookmarks/${b.id}/track-access`).catch(() => {});
320
- window.open(b.url, '_blank', 'noopener,noreferrer');
321
- }}
322
- className="group flex flex-col rounded-xl border border-border bg-card p-3 hover:border-primary/50 hover:shadow-md transition-all text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
323
- >
324
- <div className="flex items-start gap-2.5 mb-1.5">
325
- <div className="relative h-7 w-7 shrink-0 rounded bg-muted overflow-hidden">
326
- <img
327
- src={getFaviconUrl(b.url)}
328
- alt=""
329
- className="h-7 w-7 w-full object-cover"
330
- onError={(e) => {
331
- (e.target as HTMLImageElement).style.display = 'none';
332
- (e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden');
333
- }}
334
- />
335
- <span className="hidden absolute inset-0 flex items-center justify-center bg-primary/10 rounded">
336
- <Bookmark className="h-3.5 w-3.5 text-primary" />
337
- </span>
338
- </div>
339
- <p className="font-medium text-foreground line-clamp-2 text-xs flex-1 min-w-0">{b.title}</p>
340
- </div>
341
- <p className="text-[11px] text-primary font-mono truncate" title={`go/${b.slug}`}>go/{b.slug}</p>
342
- <p className="text-[11px] text-muted-foreground truncate mt-0.5">{getDomain(b.url)}</p>
343
- </button>
344
- ))}
345
- </div>
346
- ) : (
347
- <Card className="border border-border bg-card shadow-sm">
348
- <CardContent className="p-6">
349
- <EmptyState
350
- icon={Bookmark}
351
- title={t('dashboard.noQuickAccessBookmarks')}
352
- description={t('dashboard.noQuickAccessBookmarksHint')}
353
- action={
354
- <Link to={`${prefix}/bookmarks?create=true`}>
355
- <Button variant="primary" icon={Plus}>{t('bookmarks.create')}</Button>
356
- </Link>
357
- }
358
- />
359
- </CardContent>
360
- </Card>
361
- )}
362
- </section>
260
+ <QuickAccessSection
261
+ items={quickAccess}
262
+ pathPrefix={prefix}
263
+ maxItems={PINNED_QUICK_ACCESS_MAX_ITEMS}
264
+ subtitle={t('dashboard.quickAccessSubtitle')}
265
+ t={t}
266
+ onOpen={handleBookmarkOpen}
267
+ onCopyUrl={handleCopyUrl}
268
+ />
363
269
 
364
- {/* Shared With You — only when has shared content */}
365
270
  {stats && (stats.sharedBookmarks > 0 || stats.sharedFolders > 0) && (
366
271
  <section className="space-y-3">
367
272
  <h2 className="text-sm font-medium uppercase tracking-wide text-muted-foreground">
@@ -388,32 +293,10 @@ export default function Dashboard() {
388
293
  </section>
389
294
  )}
390
295
 
391
- {/* Most used tags — small row; click goes to bookmarks filtered by tag */}
392
296
  {stats && stats.topTags.length > 0 && (
393
- <section className="space-y-2">
394
- <h2 className="text-sm font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-2">
395
- <TrendingUp className="h-4 w-4" />
396
- {t('dashboard.topTags')}
397
- </h2>
398
- <div className="flex flex-wrap gap-2">
399
- {stats.topTags.map((tag) => (
400
- <Link
401
- key={tag.id}
402
- to={`${prefix}/bookmarks?tag_id=${tag.id}`}
403
- className="inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-2.5 py-1.5 text-xs font-medium text-foreground hover:bg-accent hover:border-primary/50 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
404
- title={t('dashboard.filterByTagHint')}
405
- >
406
- <span>{tag.name}</span>
407
- <Badge variant="secondary" className="text-[10px] px-1.5 py-0 font-normal">
408
- {tag.bookmark_count}
409
- </Badge>
410
- </Link>
411
- ))}
412
- </div>
413
- </section>
297
+ <MostUsedTagsSection tags={stats.topTags} pathPrefix={prefix} t={t} />
414
298
  )}
415
299
 
416
- {/* Onboarding checklist — collapsible, show when incomplete and not dismissed */}
417
300
  {stats && (
418
301
  <OnboardingChecklist
419
302
  totalBookmarks={stats.totalBookmarks}