@mdguggenbichler/slugbase-core 0.0.31 → 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 (30) 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/locales/de.json +2 -0
  16. package/frontend/src/locales/en.json +1 -0
  17. package/frontend/src/locales/es.json +2 -0
  18. package/frontend/src/locales/fr.json +2 -0
  19. package/frontend/src/locales/it.json +2 -0
  20. package/frontend/src/locales/ja.json +2 -0
  21. package/frontend/src/locales/nl.json +2 -0
  22. package/frontend/src/locales/pl.json +2 -0
  23. package/frontend/src/locales/pt.json +2 -0
  24. package/frontend/src/locales/ru.json +2 -0
  25. package/frontend/src/locales/zh.json +2 -0
  26. package/frontend/src/pages/Bookmarks.tsx +97 -214
  27. package/frontend/src/pages/Dashboard.tsx +99 -216
  28. package/frontend/src/pages/Folders.tsx +181 -251
  29. package/frontend/src/pages/Tags.tsx +87 -145
  30. package/package.json +1 -1
@@ -1,10 +1,16 @@
1
1
  import { useState, useMemo } from 'react';
2
- import { Share2, Tag as TagIcon, ExternalLink, Copy, Edit, Trash2, CheckSquare, Square, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
2
+ import { Share2, Tag as TagIcon, ExternalLink, Copy, Edit, Trash2, CheckSquare, Square, ArrowUpDown, ArrowUp, ArrowDown, MoreHorizontal } from 'lucide-react';
3
3
  import Button from '../ui/Button';
4
4
  import Tooltip from '../ui/Tooltip';
5
5
  import Favicon from '../Favicon';
6
6
  import FolderIcon from '../FolderIcon';
7
7
  import { Badge } from '../ui/badge';
8
+ import {
9
+ DropdownMenu,
10
+ DropdownMenuContent,
11
+ DropdownMenuItem,
12
+ DropdownMenuTrigger,
13
+ } from '../ui/dropdown-menu';
8
14
  import {
9
15
  Table,
10
16
  TableBody,
@@ -374,38 +380,56 @@ export default function BookmarkTableView({
374
380
  </TableCell>
375
381
  </>
376
382
  )}
377
- <TableCell className={`${cellClass} opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity duration-200`}>
378
- <div className={`flex items-center justify-end ${compact ? 'gap-1' : 'gap-2'}`}>
379
- {bookmark.forwarding_enabled && (
380
- <Tooltip content={`${window.location.origin}/go/${bookmark.slug}`}>
381
- <Button variant="ghost" size="sm" icon={Copy} className={`flex-shrink-0 h-8 w-8 p-0`} onClick={() => onCopyUrl(bookmark)} aria-label={t('bookmarks.copyUrl')} />
382
- </Tooltip>
383
- )}
383
+ <TableCell className={cellClass}>
384
+ <div className={`flex items-center justify-end gap-1 min-w-[4.5rem]`}>
384
385
  {onOpen ? (
385
386
  <Tooltip content={t('bookmarks.open')}>
386
- <Button variant="ghost" size="sm" icon={ExternalLink} className={`flex-shrink-0 ${compact ? 'h-8 w-8 p-0' : 'h-8 w-8 p-0'}`} onClick={() => onOpen(bookmark)} aria-label={t('bookmarks.open')} />
387
+ <Button variant="ghost" size="sm" icon={ExternalLink} className="h-8 w-8 p-0 flex-shrink-0" onClick={() => onOpen(bookmark)} aria-label={t('bookmarks.open')} />
387
388
  </Tooltip>
388
389
  ) : (
389
390
  <Tooltip content={t('bookmarks.open')}>
390
391
  <a href={safeHref(bookmark.url)} target="_blank" rel="noopener noreferrer" className="flex-shrink-0">
391
- <Button variant="ghost" size="sm" icon={ExternalLink} className={`flex-shrink-0 ${compact ? 'h-8 w-8 p-0' : 'h-8 w-8 p-0'}`} aria-label={t('bookmarks.open')} />
392
+ <Button variant="ghost" size="sm" icon={ExternalLink} className="h-8 w-8 p-0" aria-label={t('bookmarks.open')} />
392
393
  </a>
393
394
  </Tooltip>
394
395
  )}
395
396
  {bookmark.bookmark_type === 'own' && (
396
- <>
397
- {onShare && (
398
- <Tooltip content={t('sharing.shareBookmark')}>
399
- <Button variant="ghost" size="sm" icon={Share2} className={`flex-shrink-0 ${compact ? 'h-8 w-8 p-0' : 'h-8 w-8 p-0'}`} onClick={() => onShare(bookmark)} aria-label={t('sharing.shareBookmark')} />
400
- </Tooltip>
401
- )}
402
- <Tooltip content={t('common.edit')}>
403
- <Button variant="ghost" size="sm" icon={Edit} className={`flex-shrink-0 ${compact ? 'h-8 w-8 p-0' : 'h-8 w-8 p-0'}`} onClick={() => onEdit(bookmark)} aria-label={t('common.edit')} />
404
- </Tooltip>
405
- <Tooltip content={t('common.delete')}>
406
- <Button variant="ghost" size="sm" icon={Trash2} className={`flex-shrink-0 text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 ${compact ? 'h-8 w-8 p-0' : 'h-8 w-8 p-0'}`} onClick={() => onDelete(bookmark.id, bookmark.title)} aria-label={t('common.delete')} />
407
- </Tooltip>
408
- </>
397
+ <DropdownMenu>
398
+ <DropdownMenuTrigger asChild>
399
+ <Button
400
+ variant="ghost"
401
+ size="sm"
402
+ icon={MoreHorizontal}
403
+ className="h-8 w-8 p-0 flex-shrink-0 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity"
404
+ aria-label={t('bookmarks.moreActions')}
405
+ />
406
+ </DropdownMenuTrigger>
407
+ <DropdownMenuContent align="end">
408
+ {bookmark.forwarding_enabled && (
409
+ <DropdownMenuItem onClick={() => onCopyUrl(bookmark)}>
410
+ <Copy className="h-4 w-4" />
411
+ {t('bookmarks.copyUrl')}
412
+ </DropdownMenuItem>
413
+ )}
414
+ {onShare && (
415
+ <DropdownMenuItem onClick={() => onShare(bookmark)}>
416
+ <Share2 className="h-4 w-4" />
417
+ {t('sharing.shareBookmark')}
418
+ </DropdownMenuItem>
419
+ )}
420
+ <DropdownMenuItem onClick={() => onEdit(bookmark)}>
421
+ <Edit className="h-4 w-4" />
422
+ {t('common.edit')}
423
+ </DropdownMenuItem>
424
+ <DropdownMenuItem
425
+ onClick={() => onDelete(bookmark.id, bookmark.title)}
426
+ className="text-destructive focus:text-destructive"
427
+ >
428
+ <Trash2 className="h-4 w-4" />
429
+ {t('common.delete')}
430
+ </DropdownMenuItem>
431
+ </DropdownMenuContent>
432
+ </DropdownMenu>
409
433
  )}
410
434
  </div>
411
435
  </TableCell>
@@ -0,0 +1,294 @@
1
+ /**
2
+ * CollectionToolbar — Reusable toolbar for collection pages (Bookmarks, Folders, Tags).
3
+ *
4
+ * Props API summary:
5
+ * - Row 1 (header): title, count?, subtitle?, tabs? (scope), createButton?
6
+ * - Row 2: filterChips? (chips + onRemove, onClearAll, labels)
7
+ * - Row 3 (toolbar card): search?, folderFilter?, tagFilter?, sort?, perPage?, viewMode?,
8
+ * pinnedToggle?, onImport?, onExport?, bulkSelect?
9
+ * Only provided props are rendered. No collection-specific logic.
10
+ */
11
+
12
+ import { Plus, LayoutGrid, List, CheckSquare, Download, Upload, Pin, Search } from 'lucide-react';
13
+ import { PageHeader } from '../PageHeader';
14
+ import { ScopeSegmentedControl } from '../ScopeSegmentedControl';
15
+ import { FilterChips, type FilterChipItem } from '../FilterChips';
16
+ import Button from '../ui/Button';
17
+ import Select from '../ui/Select';
18
+
19
+ export type ViewModeValue = 'card' | 'list';
20
+
21
+ export interface CollectionToolbarProps {
22
+ /** Row 1 */
23
+ title: string;
24
+ count?: number;
25
+ subtitle?: string;
26
+ tabs?: {
27
+ value: string;
28
+ onChange: (value: string) => void;
29
+ options: { value: string; label: string }[];
30
+ ariaLabel?: string;
31
+ };
32
+ createButton?: { label: string; onClick: () => void };
33
+ /** Row 2 */
34
+ filterChips?: {
35
+ chips: FilterChipItem[];
36
+ onRemove: (key: string) => void;
37
+ onClearAll: () => void;
38
+ clearAllLabel: string;
39
+ clearAllAriaLabel: string;
40
+ };
41
+ /** Row 3 — primary filters */
42
+ search?: {
43
+ value: string;
44
+ onChange: (value: string) => void;
45
+ onSubmit: (value: string) => void;
46
+ placeholder?: string;
47
+ ariaLabel?: string;
48
+ };
49
+ folderFilter?: {
50
+ value: string;
51
+ onChange: (value: string) => void;
52
+ options: { value: string; label: string; icon?: string | null }[];
53
+ placeholder?: string;
54
+ };
55
+ tagFilter?: {
56
+ value: string;
57
+ onChange: (value: string) => void;
58
+ options: { value: string; label: string }[];
59
+ placeholder?: string;
60
+ };
61
+ sort?: {
62
+ value: string;
63
+ onChange: (value: string) => void;
64
+ options: { value: string; label: string }[];
65
+ className?: string;
66
+ };
67
+ /** Row 3 — utility */
68
+ perPage?: {
69
+ value: number;
70
+ onChange: (value: number) => void;
71
+ options: number[];
72
+ label?: string;
73
+ };
74
+ viewMode?: {
75
+ value: ViewModeValue;
76
+ onChange: (value: ViewModeValue) => void;
77
+ cardLabel?: string;
78
+ listLabel?: string;
79
+ };
80
+ pinnedToggle?: { active: boolean; onClick: () => void; label: string };
81
+ onImport?: () => void;
82
+ importLabel?: string;
83
+ onExport?: () => void;
84
+ exportLabel?: string;
85
+ bulkSelect?: { onClick: () => void; label: string; disabled?: boolean };
86
+ /** Optional wrapper className */
87
+ className?: string;
88
+ }
89
+
90
+ const STICKY_CLASS =
91
+ 'sticky top-0 z-40 space-y-4 pb-4 -mx-4 sm:-mx-6 lg:-mx-8 px-4 sm:px-6 lg:px-8 pt-0 -mt-8 bg-background shadow-sm';
92
+
93
+ export function CollectionToolbar({
94
+ title,
95
+ count,
96
+ subtitle,
97
+ tabs,
98
+ createButton,
99
+ filterChips,
100
+ search,
101
+ folderFilter,
102
+ tagFilter,
103
+ sort,
104
+ perPage,
105
+ viewMode,
106
+ pinnedToggle,
107
+ onImport,
108
+ importLabel = 'Import',
109
+ onExport,
110
+ exportLabel = 'Export',
111
+ bulkSelect,
112
+ className,
113
+ }: CollectionToolbarProps) {
114
+ const displayTitle = count !== undefined ? `${title} (${count})` : title;
115
+
116
+ const hasToolbarRow =
117
+ search ||
118
+ folderFilter ||
119
+ tagFilter ||
120
+ sort ||
121
+ perPage ||
122
+ viewMode ||
123
+ pinnedToggle ||
124
+ onImport ||
125
+ onExport ||
126
+ (bulkSelect && !bulkSelect.disabled);
127
+
128
+ const headerActions = (
129
+ <div className="flex flex-wrap items-center gap-2">
130
+ {tabs && (
131
+ <ScopeSegmentedControl
132
+ value={tabs.value}
133
+ onChange={tabs.onChange}
134
+ options={tabs.options}
135
+ ariaLabel={tabs.ariaLabel}
136
+ />
137
+ )}
138
+ {createButton && (
139
+ <Button onClick={createButton.onClick} icon={Plus}>
140
+ {createButton.label}
141
+ </Button>
142
+ )}
143
+ </div>
144
+ );
145
+
146
+ return (
147
+ <div className={className ? `${STICKY_CLASS} ${className}` : STICKY_CLASS}>
148
+ <PageHeader
149
+ className="pt-4"
150
+ title={displayTitle}
151
+ subtitle={subtitle}
152
+ actions={tabs || createButton ? headerActions : undefined}
153
+ />
154
+
155
+ {filterChips && filterChips.chips.length > 0 && (
156
+ <FilterChips
157
+ chips={filterChips.chips}
158
+ onRemove={filterChips.onRemove}
159
+ onClearAll={filterChips.onClearAll}
160
+ clearAllLabel={filterChips.clearAllLabel}
161
+ clearAllAriaLabel={filterChips.clearAllAriaLabel}
162
+ />
163
+ )}
164
+
165
+ {hasToolbarRow && (
166
+ <div className="flex flex-wrap items-center gap-3 bg-card rounded-lg border border-border p-4 shadow-sm">
167
+ {/* Primary filters — left */}
168
+ <div className="flex flex-wrap items-center gap-3 flex-1 min-w-[200px]">
169
+ {search && (
170
+ <div className="flex items-center gap-2 min-w-[200px] flex-1">
171
+ <Search className="h-4 w-4 text-muted-foreground flex-shrink-0" aria-hidden />
172
+ <input
173
+ type="search"
174
+ value={search.value}
175
+ onChange={(e) => search.onChange(e.target.value)}
176
+ onKeyDown={(e) => {
177
+ if (e.key === 'Enter') {
178
+ search.onSubmit((e.target as HTMLInputElement).value.trim());
179
+ }
180
+ }}
181
+ placeholder={search.placeholder}
182
+ className="flex-1 min-w-0 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
183
+ aria-label={search.ariaLabel ?? search.placeholder}
184
+ />
185
+ </div>
186
+ )}
187
+ {folderFilter && (
188
+ <div className="flex-1 min-w-[180px]">
189
+ <Select
190
+ value={folderFilter.value}
191
+ onChange={folderFilter.onChange}
192
+ options={folderFilter.options}
193
+ placeholder={folderFilter.placeholder}
194
+ />
195
+ </div>
196
+ )}
197
+ {tagFilter && (
198
+ <div className="flex-1 min-w-[180px]">
199
+ <Select
200
+ value={tagFilter.value}
201
+ onChange={tagFilter.onChange}
202
+ options={tagFilter.options}
203
+ placeholder={tagFilter.placeholder}
204
+ />
205
+ </div>
206
+ )}
207
+ {sort && (
208
+ <div className="flex items-center gap-2">
209
+ <Select
210
+ value={sort.value}
211
+ onChange={sort.onChange}
212
+ options={sort.options}
213
+ className={sort.className ?? 'min-w-[160px]'}
214
+ />
215
+ </div>
216
+ )}
217
+ </div>
218
+
219
+ {/* Utility — right, visually quieter */}
220
+ <div className="flex flex-wrap items-center gap-3 border-l border-border pl-3 ml-auto">
221
+ {pinnedToggle && (
222
+ <Button
223
+ variant={pinnedToggle.active ? 'secondary' : 'ghost'}
224
+ size="sm"
225
+ icon={Pin}
226
+ onClick={pinnedToggle.onClick}
227
+ title={pinnedToggle.label}
228
+ aria-pressed={pinnedToggle.active}
229
+ >
230
+ <span className="hidden sm:inline">{pinnedToggle.label}</span>
231
+ </Button>
232
+ )}
233
+ {onImport && (
234
+ <Button variant="ghost" size="sm" icon={Upload} onClick={onImport} title={importLabel}>
235
+ <span className="hidden sm:inline">{importLabel}</span>
236
+ </Button>
237
+ )}
238
+ {onExport && (
239
+ <Button variant="ghost" size="sm" icon={Download} onClick={onExport} title={exportLabel}>
240
+ <span className="hidden sm:inline">{exportLabel}</span>
241
+ </Button>
242
+ )}
243
+ {perPage && (
244
+ <div className="flex items-center gap-2">
245
+ <Select
246
+ value={String(perPage.value)}
247
+ onChange={(value) => perPage.onChange(Number(value))}
248
+ options={perPage.options.map((n) => ({ value: String(n), label: String(n) }))}
249
+ className="min-w-[80px]"
250
+ />
251
+ <span className="text-sm text-muted-foreground whitespace-nowrap">
252
+ {perPage.label ?? 'Per page'}
253
+ </span>
254
+ </div>
255
+ )}
256
+ {viewMode && (
257
+ <div className="flex items-center gap-1 bg-muted rounded-lg p-1 border border-border">
258
+ <button
259
+ type="button"
260
+ onClick={() => viewMode.onChange('card')}
261
+ className={`p-1.5 rounded transition-colors ${
262
+ viewMode.value === 'card'
263
+ ? 'bg-background text-primary shadow-sm'
264
+ : 'text-muted-foreground hover:text-foreground'
265
+ }`}
266
+ title={viewMode.cardLabel ?? 'Card view'}
267
+ >
268
+ <LayoutGrid className="h-4 w-4" />
269
+ </button>
270
+ <button
271
+ type="button"
272
+ onClick={() => viewMode.onChange('list')}
273
+ className={`p-1.5 rounded transition-colors ${
274
+ viewMode.value === 'list'
275
+ ? 'bg-background text-primary shadow-sm'
276
+ : 'text-muted-foreground hover:text-foreground'
277
+ }`}
278
+ title={viewMode.listLabel ?? 'List view'}
279
+ >
280
+ <List className="h-4 w-4" />
281
+ </button>
282
+ </div>
283
+ )}
284
+ {bulkSelect && !bulkSelect.disabled && (
285
+ <Button variant="ghost" size="sm" icon={CheckSquare} onClick={bulkSelect.onClick}>
286
+ {bulkSelect.label}
287
+ </Button>
288
+ )}
289
+ </div>
290
+ </div>
291
+ )}
292
+ </div>
293
+ );
294
+ }
@@ -0,0 +1,44 @@
1
+ # CollectionToolbar
2
+
3
+ Reusable toolbar for collection pages (Bookmarks, Folders, Tags). Renders only the controls passed via props; no collection-specific logic.
4
+
5
+ ## Props API
6
+
7
+ | Prop | Type | Description |
8
+ |------|------|-------------|
9
+ | **Row 1 — Header** | | |
10
+ | `title` | `string` | Page title (e.g. "Bookmarks") |
11
+ | `count?` | `number` | Shown as "Title (count)" |
12
+ | `subtitle?` | `string` | e.g. "Showing X of Y" |
13
+ | `tabs?` | `{ value, onChange, options, ariaLabel? }` | Scope tabs (All / Mine / Shared) |
14
+ | `createButton?` | `{ label, onClick }` | Primary Create button |
15
+ | **Row 2 — Filter chips** | | |
16
+ | `filterChips?` | `{ chips, onRemove, onClearAll, clearAllLabel, clearAllAriaLabel }` | Renders when chips.length > 0 |
17
+ | **Row 3 — Toolbar** | | |
18
+ | `search?` | `{ value, onChange, onSubmit, placeholder?, ariaLabel? }` | Search input (Enter submits) |
19
+ | `folderFilter?` | `{ value, onChange, options, placeholder? }` | Folder select |
20
+ | `tagFilter?` | `{ value, onChange, options, placeholder? }` | Tag select |
21
+ | `sort?` | `{ value, onChange, options, className? }` | Sort select |
22
+ | `perPage?` | `{ value, onChange, options, label? }` | Per-page select |
23
+ | `viewMode?` | `{ value: 'card' \| 'list', onChange, cardLabel?, listLabel? }` | Card/list toggle |
24
+ | `pinnedToggle?` | `{ active, onClick, label }` | Pinned filter (utility) |
25
+ | `onImport?` | `() => void` | Import button (utility) |
26
+ | `importLabel?` | `string` | Default: "Import" |
27
+ | `onExport?` | `() => void` | Export button (utility) |
28
+ | `exportLabel?` | `string` | Default: "Export" |
29
+ | `bulkSelect?` | `{ onClick, label, disabled? }` | Bulk select (hidden when disabled) |
30
+ | `className?` | `string` | Optional wrapper class |
31
+
32
+ ## Usage
33
+
34
+ - **Bookmarks**: title, count, subtitle, tabs, createButton, filterChips, search, folderFilter, tagFilter, sort, perPage, viewMode, pinnedToggle, onImport, onExport, bulkSelect.
35
+ - **Folders**: title, count, subtitle, tabs, createButton, filterChips, sort, perPage, viewMode.
36
+ - **Tags**: title, count, subtitle, createButton, filterChips, sort, perPage, viewMode.
37
+
38
+ ## Modified files (refactor)
39
+
40
+ - `frontend/src/components/collections/CollectionToolbar.tsx` (new)
41
+ - `frontend/src/components/collections/index.ts` (new)
42
+ - `frontend/src/pages/Bookmarks.tsx`
43
+ - `frontend/src/pages/Folders.tsx`
44
+ - `frontend/src/pages/Tags.tsx`
@@ -0,0 +1,2 @@
1
+ export { CollectionToolbar } from './CollectionToolbar';
2
+ export type { CollectionToolbarProps, ViewModeValue } from './CollectionToolbar';
@@ -0,0 +1,16 @@
1
+ import { PageHeader } from '../PageHeader';
2
+
3
+ export interface DashboardHeaderProps {
4
+ title: string;
5
+ subtitle?: string;
6
+ actions?: React.ReactNode;
7
+ className?: string;
8
+ }
9
+
10
+ /**
11
+ * Dashboard page header: title, optional subtitle, optional actions.
12
+ * Uses the shared PageHeader for consistency.
13
+ */
14
+ export function DashboardHeader({ title, subtitle, actions, className }: DashboardHeaderProps) {
15
+ return <PageHeader title={title} subtitle={subtitle} actions={actions} className={className} />;
16
+ }
@@ -0,0 +1,49 @@
1
+ import { Link } from 'react-router-dom';
2
+ import { TrendingUp } from 'lucide-react';
3
+ import { Badge } from '../ui/badge';
4
+
5
+ export interface MostUsedTagsSectionTag {
6
+ id: string;
7
+ name: string;
8
+ bookmark_count: number;
9
+ }
10
+
11
+ export interface MostUsedTagsSectionProps {
12
+ tags: MostUsedTagsSectionTag[];
13
+ pathPrefix: string;
14
+ t: (key: string) => string;
15
+ }
16
+
17
+ /**
18
+ * Most used tags section: title, clickable tag chips linking to bookmarks filtered by tag.
19
+ * Improved spacing and visual rhythm.
20
+ */
21
+ export function MostUsedTagsSection({ tags, pathPrefix, t }: MostUsedTagsSectionProps) {
22
+ const prefix = pathPrefix.replace(/\/+/g, '/') || '';
23
+
24
+ if (tags.length === 0) return null;
25
+
26
+ return (
27
+ <section className="space-y-3">
28
+ <h2 className="text-sm font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-2">
29
+ <TrendingUp className="h-4 w-4" />
30
+ {t('dashboard.topTags')}
31
+ </h2>
32
+ <div className="flex flex-wrap gap-2.5">
33
+ {tags.map((tag) => (
34
+ <Link
35
+ key={tag.id}
36
+ to={`${prefix}/bookmarks?tag_id=${tag.id}`}
37
+ 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"
38
+ title={t('dashboard.filterByTagHint')}
39
+ >
40
+ <span>{tag.name}</span>
41
+ <Badge variant="secondary" className="text-[10px] px-1.5 py-0 font-normal">
42
+ {tag.bookmark_count}
43
+ </Badge>
44
+ </Link>
45
+ ))}
46
+ </div>
47
+ </section>
48
+ );
49
+ }
@@ -0,0 +1,110 @@
1
+ import { Link, useNavigate } from 'react-router-dom';
2
+ import { ArrowRight, Bookmark } from 'lucide-react';
3
+ import { Card, CardContent } from '../ui/card';
4
+ import { EmptyState } from '../EmptyState';
5
+ import Button from '../ui/Button';
6
+ import BookmarkCard from '../bookmarks/BookmarkCard';
7
+
8
+ export interface PinnedSectionBookmark {
9
+ id: string;
10
+ title: string;
11
+ url: string;
12
+ slug: string;
13
+ }
14
+
15
+ function toBookmarkCardItem(
16
+ b: PinnedSectionBookmark,
17
+ pinned: boolean
18
+ ): Parameters<typeof BookmarkCard>[0]['bookmark'] {
19
+ return {
20
+ id: b.id,
21
+ title: b.title,
22
+ url: b.url,
23
+ slug: b.slug || '',
24
+ forwarding_enabled: !!b.slug,
25
+ folders: [],
26
+ tags: [],
27
+ shared_teams: [],
28
+ shared_users: [],
29
+ bookmark_type: 'own',
30
+ pinned,
31
+ access_count: undefined,
32
+ last_accessed_at: null,
33
+ };
34
+ }
35
+
36
+ export interface PinnedSectionProps {
37
+ items: PinnedSectionBookmark[];
38
+ pathPrefix: string;
39
+ maxItems?: number;
40
+ t: (key: string) => string;
41
+ onOpen: (id: string, url: string) => void;
42
+ onCopyUrl: (url: string) => void;
43
+ }
44
+
45
+ /**
46
+ * Pinned bookmarks section: title, "View all" link, grid (limited to maxItems), empty state.
47
+ */
48
+ export function PinnedSection({
49
+ items,
50
+ pathPrefix,
51
+ maxItems = 6,
52
+ t,
53
+ onOpen,
54
+ onCopyUrl,
55
+ }: PinnedSectionProps) {
56
+ const navigate = useNavigate();
57
+ const prefix = pathPrefix.replace(/\/+/g, '/') || '';
58
+ const displayItems = items.slice(0, maxItems);
59
+
60
+ return (
61
+ <section className="space-y-3">
62
+ <div className="flex items-center justify-between flex-wrap gap-2">
63
+ <h2 className="text-sm font-medium uppercase tracking-wide text-muted-foreground">
64
+ {t('dashboard.pinned')}
65
+ </h2>
66
+ <Link
67
+ to={prefix + '/bookmarks?pinned=true'}
68
+ className="text-sm font-medium text-primary hover:underline inline-flex items-center gap-1"
69
+ >
70
+ {t('dashboard.viewAll')}
71
+ <ArrowRight className="h-4 w-4" />
72
+ </Link>
73
+ </div>
74
+ {displayItems.length > 0 ? (
75
+ <div className="grid gap-3 items-stretch [grid-template-columns:repeat(auto-fill,minmax(300px,1fr))]">
76
+ {displayItems.map((b) => (
77
+ <BookmarkCard
78
+ key={b.id}
79
+ bookmark={toBookmarkCardItem(b, true)}
80
+ compact={false}
81
+ selected={false}
82
+ onSelect={() => {}}
83
+ onEdit={() => navigate(prefix + '/bookmarks')}
84
+ onDelete={() => {}}
85
+ onCopyUrl={() => onCopyUrl(b.url)}
86
+ onOpen={() => onOpen(b.id, b.url)}
87
+ bulkMode={false}
88
+ t={t}
89
+ />
90
+ ))}
91
+ </div>
92
+ ) : (
93
+ <Card className="border border-border bg-card shadow-sm">
94
+ <CardContent className="p-6">
95
+ <EmptyState
96
+ icon={Bookmark}
97
+ title={t('dashboard.noPinnedBookmarks')}
98
+ description={t('dashboard.pinFromBookmarks')}
99
+ action={
100
+ <Link to={prefix + '/bookmarks'}>
101
+ <Button variant="secondary">{t('dashboard.pinFromBookmarksLink')}</Button>
102
+ </Link>
103
+ }
104
+ />
105
+ </CardContent>
106
+ </Card>
107
+ )}
108
+ </section>
109
+ );
110
+ }