@mdguggenbichler/slugbase-core 0.0.31 → 0.0.33

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 (40) hide show
  1. package/backend/dist/config/mode.d.ts +4 -4
  2. package/backend/dist/config/mode.d.ts.map +1 -1
  3. package/backend/dist/config/mode.js +4 -4
  4. package/backend/dist/config/mode.js.map +1 -1
  5. package/backend/dist/routes/auth.d.ts.map +1 -1
  6. package/backend/dist/routes/auth.js +33 -3
  7. package/backend/dist/routes/auth.js.map +1 -1
  8. package/frontend/src/App.tsx +3 -1
  9. package/frontend/src/components/FilterChips.tsx +5 -3
  10. package/frontend/src/components/StatCard.tsx +82 -5
  11. package/frontend/src/components/bookmarks/BookmarkCard.tsx +317 -210
  12. package/frontend/src/components/bookmarks/BookmarkTableView.tsx +47 -23
  13. package/frontend/src/components/collections/CollectionToolbar.tsx +294 -0
  14. package/frontend/src/components/collections/README.md +44 -0
  15. package/frontend/src/components/collections/index.ts +2 -0
  16. package/frontend/src/components/dashboard/DashboardHeader.tsx +16 -0
  17. package/frontend/src/components/dashboard/MostUsedTagsSection.tsx +49 -0
  18. package/frontend/src/components/dashboard/PinnedSection.tsx +110 -0
  19. package/frontend/src/components/dashboard/QuickAccessSection.tsx +120 -0
  20. package/frontend/src/components/dashboard/README.md +35 -0
  21. package/frontend/src/components/dashboard/StatsCardsRow.tsx +78 -0
  22. package/frontend/src/components/dashboard/index.ts +17 -0
  23. package/frontend/src/locales/de.json +11 -0
  24. package/frontend/src/locales/en.json +10 -0
  25. package/frontend/src/locales/es.json +11 -0
  26. package/frontend/src/locales/fr.json +2 -0
  27. package/frontend/src/locales/it.json +2 -0
  28. package/frontend/src/locales/ja.json +2 -0
  29. package/frontend/src/locales/nl.json +2 -0
  30. package/frontend/src/locales/pl.json +2 -0
  31. package/frontend/src/locales/pt.json +2 -0
  32. package/frontend/src/locales/ru.json +2 -0
  33. package/frontend/src/locales/zh.json +2 -0
  34. package/frontend/src/pages/Bookmarks.tsx +97 -214
  35. package/frontend/src/pages/Dashboard.tsx +99 -216
  36. package/frontend/src/pages/Folders.tsx +181 -251
  37. package/frontend/src/pages/Login.tsx +6 -3
  38. package/frontend/src/pages/Tags.tsx +87 -145
  39. package/frontend/src/pages/VerifyEmailRequired.tsx +163 -0
  40. package/package.json +1 -1
@@ -1,12 +1,41 @@
1
- import { Share2, Tag as TagIcon, ExternalLink, Copy, Edit, Trash2, CheckSquare, Square, Pin } from 'lucide-react';
1
+ /**
2
+ * BookmarkCard — scan-first grid layout (Linear/Vercel style).
3
+ *
4
+ * Density improvements:
5
+ * - Fixed height ~148px (high-density) and tight padding fit more rows per viewport.
6
+ * - Single metadata row (folder · tags · slug) reduces vertical scan and badge clutter.
7
+ * - Title up to 2 lines (line-clamp-2) with tooltip for full text; favicon in subtle container.
8
+ * - Footer actions on hover (opacity only) keep a calm default and avoid layout shift.
9
+ * - No dividers or heavy badges; cards are visually lighter and easier to scan.
10
+ */
11
+ import {
12
+ Share2,
13
+ ExternalLink,
14
+ Copy,
15
+ Edit,
16
+ Trash2,
17
+ CheckSquare,
18
+ Square,
19
+ Pin,
20
+ MoreVertical,
21
+ } from 'lucide-react';
2
22
  import Button from '../ui/Button';
3
23
  import Tooltip from '../ui/Tooltip';
4
24
  import Favicon from '../Favicon';
5
25
  import FolderIcon from '../FolderIcon';
6
- import { Badge } from '../ui/badge';
26
+ import { Card } from '../ui/card';
27
+ import {
28
+ DropdownMenu,
29
+ DropdownMenuContent,
30
+ DropdownMenuItem,
31
+ DropdownMenuSeparator,
32
+ DropdownMenuTrigger,
33
+ } from '../ui/dropdown-menu';
7
34
  import { safeHref } from '../../utils/safeHref';
8
35
  import { formatRelativeTime, formatFullDateTime } from '../../utils/formatRelativeTime';
9
36
 
37
+ const SEP = ' · ';
38
+
10
39
  interface Bookmark {
11
40
  id: string;
12
41
  title: string;
@@ -52,12 +81,58 @@ export default function BookmarkCard({
52
81
  bulkMode,
53
82
  t,
54
83
  }: BookmarkCardProps) {
55
- const totalSharedTeams = (bookmark.shared_teams?.length || 0) +
84
+ const totalSharedTeams =
85
+ (bookmark.shared_teams?.length || 0) +
56
86
  (bookmark.folders?.reduce((sum, f) => sum + (f.shared_teams?.length || 0), 0) || 0);
57
- const totalSharedUsers = (bookmark.shared_users?.length || 0) +
87
+ const totalSharedUsers =
88
+ (bookmark.shared_users?.length || 0) +
58
89
  (bookmark.folders?.reduce((sum, f) => sum + (f.shared_users?.length || 0), 0) || 0);
59
90
  const isShared = totalSharedTeams > 0 || totalSharedUsers > 0;
60
91
 
92
+ const folderLabel =
93
+ bookmark.folders && bookmark.folders.length > 0
94
+ ? bookmark.folders[0].name
95
+ : (t('bookmarks.noFolder') as string);
96
+ const tagNames = bookmark.tags?.slice(0, 3).map((tag) => tag.name) ?? [];
97
+ const tagOverflow = (bookmark.tags?.length ?? 0) > 3;
98
+ const tagOverflowN = (bookmark.tags?.length ?? 0) - 3;
99
+ const slugPart = bookmark.forwarding_enabled ? `/${bookmark.slug}` : '';
100
+ const metaParts: string[] = [folderLabel, ...tagNames];
101
+ if (tagOverflow) metaParts.push(`+${tagOverflowN}`);
102
+ if (slugPart) metaParts.push(slugPart);
103
+ const metadataLine = metaParts.join(SEP);
104
+
105
+ const hasMultipleFolders = (bookmark.folders?.length ?? 0) > 1;
106
+ const hasManyTags = (bookmark.tags?.length ?? 0) > 3;
107
+ const metaTooltipContent =
108
+ hasMultipleFolders || hasManyTags ? (
109
+ <div className="space-y-1.5 text-left">
110
+ {hasMultipleFolders && bookmark.folders && (
111
+ <div>
112
+ <div className="font-semibold mb-0.5 text-xs">{t('bookmarks.folders')}</div>
113
+ <div className="text-xs text-muted-foreground space-y-0.5">
114
+ {bookmark.folders.map((folder) => (
115
+ <div key={folder.id} className="flex items-center gap-1.5">
116
+ <FolderIcon iconName={folder.icon} size={12} className="text-muted-foreground shrink-0" />
117
+ {folder.name}
118
+ </div>
119
+ ))}
120
+ </div>
121
+ </div>
122
+ )}
123
+ {hasManyTags && bookmark.tags && (
124
+ <div>
125
+ <div className="font-semibold mb-0.5 text-xs">{t('bookmarks.tags')}</div>
126
+ <div className="text-xs text-muted-foreground flex flex-wrap gap-1">
127
+ {bookmark.tags.map((tag) => (
128
+ <span key={tag.id}>{tag.name}</span>
129
+ ))}
130
+ </div>
131
+ </div>
132
+ )}
133
+ </div>
134
+ ) : null;
135
+
61
136
  function handleCardClick(e: React.MouseEvent) {
62
137
  if (bulkMode) return;
63
138
  const target = e.target as HTMLElement;
@@ -76,237 +151,269 @@ export default function BookmarkCard({
76
151
  }
77
152
 
78
153
  return (
79
- <div
154
+ <Card
80
155
  role="button"
81
156
  tabIndex={0}
82
157
  onClick={handleCardClick}
83
158
  onKeyDown={handleCardKeyDown}
84
- className={`group bg-card rounded-lg border ${
159
+ className={`group relative flex flex-col h-[148px] cursor-pointer rounded-lg border bg-card/95 dark:bg-card/90 transition-[border-color,box-shadow] duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 px-3 pt-0 pb-1.5 ${
85
160
  selected
86
161
  ? 'border-primary ring-2 ring-primary/20'
87
- : 'border-border hover:border-primary/70 hover:bg-muted/50 hover:shadow-md'
88
- } transition-all duration-200 flex flex-col h-full min-h-0 cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${compact ? 'p-2.5 min-h-[160px]' : 'p-2.5 min-h-[140px]'}`}
162
+ : 'border-border/80 hover:border-primary/80 hover:shadow-[0_2px_6px_rgba(0,0,0,0.06)] dark:border-border/70 dark:hover:border-primary/80 dark:hover:shadow-[0_2px_6px_rgba(0,0,0,0.25)]'
163
+ }`}
89
164
  >
90
- <div className="flex-shrink-0 mb-3">
91
- <div className="flex items-center gap-3">
92
- {bulkMode && (
93
- <button
94
- data-card-action
95
- onClick={(e) => { e.stopPropagation(); onSelect(); }}
96
- className="flex-shrink-0 text-primary"
97
- >
98
- {selected ? <CheckSquare className="h-5 w-5" /> : <Square className="h-5 w-5" />}
99
- </button>
100
- )}
101
- <div className={`flex-shrink-0 ${compact ? 'w-9 h-9' : 'w-10 h-10'} rounded-xl bg-primary/20 flex items-center justify-center border border-primary/30 overflow-hidden`}>
102
- <Favicon url={bookmark.url} size={compact ? 18 : 20} />
103
- </div>
104
- <div className="flex-1 min-w-0">
105
- <h3 className={`${compact ? 'text-xs font-semibold' : 'text-sm font-medium'} text-foreground line-clamp-2 leading-snug mb-1`}>
165
+ {/* Bulk checkbox: top-right corner when bulk mode active */}
166
+ {bulkMode && (
167
+ <div className="absolute top-3 right-3 z-10" data-card-action>
168
+ <button
169
+ type="button"
170
+ onClick={(e) => {
171
+ e.stopPropagation();
172
+ onSelect();
173
+ }}
174
+ className="text-primary rounded p-0.5 hover:bg-muted/50 transition-colors"
175
+ aria-label={selected ? t('bookmarks.deselect') : t('bookmarks.select')}
176
+ >
177
+ {selected ? <CheckSquare className="h-4 w-4" /> : <Square className="h-4 w-4" />}
178
+ </button>
179
+ </div>
180
+ )}
181
+
182
+ {/* Header: icon + title; reserve right space for bulk checkbox so title never overlaps */}
183
+ <header className={`flex-shrink-0 flex items-center gap-1.5 min-w-0 pt-3 ${bulkMode ? 'pr-8' : ''}`}>
184
+ <div
185
+ className={`flex-shrink-0 ${compact ? 'w-6 h-6' : 'w-7 h-7'} rounded-md bg-background/90 dark:bg-muted/20 flex items-center justify-center border border-border/50 overflow-hidden`}
186
+ >
187
+ <Favicon url={bookmark.url} size={compact ? 12 : 14} />
188
+ </div>
189
+ <div className="flex-1 min-w-0 min-h-0 overflow-hidden">
190
+ <Tooltip content={bookmark.title}>
191
+ <h3 className="text-sm font-semibold text-foreground line-clamp-2 break-words leading-snug tracking-tight min-h-0">
106
192
  {bookmark.title}
107
193
  </h3>
108
- {isShared && (
109
- <Tooltip
110
- content={
111
- <div className="space-y-1">
112
- <div className="font-semibold mb-1">{t('bookmarks.sharedWith')}</div>
113
- {bookmark.shared_teams && bookmark.shared_teams.map((team) => (
114
- <div key={team.id} className="text-xs">• {team.name}</div>
115
- ))}
116
- {bookmark.shared_users && bookmark.shared_users.map((user) => (
117
- <div key={user.id} className="text-xs">• {user.name || user.email}</div>
118
- ))}
119
- {bookmark.folders && bookmark.folders.map((folder) => {
120
- const hasShares = (folder.shared_teams?.length || 0) > 0 || (folder.shared_users?.length || 0) > 0;
121
- if (!hasShares) return null;
122
- return (
123
- <div key={folder.id} className="text-xs mt-1 pt-1 border-t border-gray-700">
124
- <div className="font-semibold mb-0.5">{folder.name}:</div>
125
- {folder.shared_teams?.map((team) => (
126
- <div key={team.id} className="text-xs pl-2">• {team.name}</div>
127
- ))}
128
- {folder.shared_users?.map((user) => (
129
- <div key={user.id} className="text-xs pl-2">• {user.name || user.email}</div>
130
- ))}
131
- </div>
132
- );
133
- })}
134
- </div>
135
- }
136
- >
137
- <span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300 rounded-md border border-green-200 dark:border-green-800/50 cursor-help">
138
- <Share2 className="h-3 w-3" />
139
- {totalSharedTeams > 0
140
- ? t('bookmarks.sharedWithTeams', { count: totalSharedTeams, teams: totalSharedTeams === 1 ? t('common.team') : t('common.teams') })
141
- : t('bookmarks.shared')}
142
- </span>
143
- </Tooltip>
144
- )}
145
- </div>
194
+ </Tooltip>
146
195
  </div>
196
+ </header>
197
+
198
+ {/* Metadata row: folder (emphasized) · tags (muted) · slug (mono, most-muted); ~8px below title */}
199
+ <div className="flex-shrink-0 min-h-0 min-w-0 text-[10px] truncate mt-2">
200
+ {metaTooltipContent ? (
201
+ <Tooltip content={metaTooltipContent}>
202
+ <p className="truncate cursor-default" title={metadataLine}>
203
+ <span className="font-medium text-foreground/85">{folderLabel}</span>
204
+ {tagNames.length > 0 && (
205
+ <>
206
+ <span className="text-muted-foreground/50 mx-0.5">·</span>
207
+ <span className="text-muted-foreground/80">{tagNames.join(SEP)}</span>
208
+ </>
209
+ )}
210
+ {tagOverflow && (
211
+ <>
212
+ <span className="text-muted-foreground/50 mx-0.5">·</span>
213
+ <span className="text-muted-foreground/70">+{tagOverflowN}</span>
214
+ </>
215
+ )}
216
+ {slugPart && (
217
+ <>
218
+ <span className="text-muted-foreground/50 mx-0.5">·</span>
219
+ <span className="font-mono text-muted-foreground/45">{slugPart}</span>
220
+ </>
221
+ )}
222
+ </p>
223
+ </Tooltip>
224
+ ) : (
225
+ <p className="truncate" title={metadataLine}>
226
+ <span className="font-medium text-foreground/85">{folderLabel}</span>
227
+ {tagNames.length > 0 && (
228
+ <>
229
+ <span className="text-muted-foreground/50 mx-0.5">·</span>
230
+ <span className="text-muted-foreground/80">{tagNames.join(SEP)}</span>
231
+ </>
232
+ )}
233
+ {tagOverflow && (
234
+ <>
235
+ <span className="text-muted-foreground/50 mx-0.5">·</span>
236
+ <span className="text-muted-foreground/70">+{tagOverflowN}</span>
237
+ </>
238
+ )}
239
+ {slugPart && (
240
+ <>
241
+ <span className="text-muted-foreground/50 mx-0.5">·</span>
242
+ <span className="font-mono text-muted-foreground/45">{slugPart}</span>
243
+ </>
244
+ )}
245
+ </p>
246
+ )}
147
247
  </div>
148
248
 
149
- <div className={`flex-1 flex flex-col min-h-0 ${compact ? 'min-h-[100px]' : 'min-h-[120px]'} space-y-2`}>
150
- <div className="flex flex-wrap items-center gap-1.5 min-h-[24px] flex-shrink-0">
151
- {bookmark.folders && bookmark.folders.length > 0 ? (
152
- <>
153
- {bookmark.folders.slice(0, compact ? 1 : 2).map((folder) => (
154
- <Badge key={folder.id} variant="secondary" className="text-xs font-medium bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-800/50">
155
- <FolderIcon iconName={folder.icon} size={12} className="text-blue-700 dark:text-blue-300 mr-1" />
156
- {folder.name}
157
- </Badge>
158
- ))}
159
- {bookmark.folders.length > (compact ? 1 : 2) && (
160
- <Tooltip
161
- content={
162
- <div className="space-y-1">
163
- <div className="font-semibold mb-1">{t('bookmarks.folders')}</div>
164
- {bookmark.folders.map((folder) => (
165
- <div key={folder.id} className="text-xs flex items-center gap-1.5">
166
- <FolderIcon iconName={folder.icon} size={12} className="text-blue-400" />
167
- {folder.name}
168
- </div>
249
+ {/* Shared chip: below meta line when bookmark is shared */}
250
+ {isShared && (
251
+ <div className="flex-shrink-0 mt-1">
252
+ <Tooltip
253
+ content={
254
+ <div className="space-y-1">
255
+ <div className="font-semibold mb-1">{t('bookmarks.sharedWith')}</div>
256
+ {bookmark.shared_teams?.map((team) => (
257
+ <div key={team.id} className="text-xs">• {team.name}</div>
258
+ ))}
259
+ {bookmark.shared_users?.map((user) => (
260
+ <div key={user.id} className="text-xs">• {user.name || user.email}</div>
261
+ ))}
262
+ {bookmark.folders?.map((folder) => {
263
+ const hasShares =
264
+ (folder.shared_teams?.length || 0) > 0 || (folder.shared_users?.length || 0) > 0;
265
+ if (!hasShares) return null;
266
+ return (
267
+ <div key={folder.id} className="text-xs mt-1 pt-1 border-t border-border">
268
+ <div className="font-semibold mb-0.5">{folder.name}:</div>
269
+ {folder.shared_teams?.map((team) => (
270
+ <div key={team.id} className="text-xs pl-2">• {team.name}</div>
271
+ ))}
272
+ {folder.shared_users?.map((user) => (
273
+ <div key={user.id} className="text-xs pl-2">• {user.name || user.email}</div>
169
274
  ))}
170
275
  </div>
171
- }
172
- >
173
- <Badge variant="secondary" className="text-xs cursor-help bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300">
174
- +{bookmark.folders.length - (compact ? 1 : 2)}
175
- </Badge>
176
- </Tooltip>
177
- )}
178
- </>
179
- ) : (
180
- <Badge variant="secondary" className="text-xs font-medium bg-gray-50 dark:bg-gray-900/20 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-800/50 opacity-60">
181
- <FolderIcon iconName={null} size={12} className="text-gray-600 dark:text-gray-400 mr-1" />
182
- {t('bookmarks.noFolder')}
183
- </Badge>
184
- )}
276
+ );
277
+ })}
278
+ </div>
279
+ }
280
+ >
281
+ <span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-medium bg-emerald-500/10 dark:bg-emerald-500/10 text-muted-foreground rounded cursor-help">
282
+ <Share2 className="h-2.5 w-2.5" />
283
+ {totalSharedTeams > 0
284
+ ? t('bookmarks.sharedWithTeams', {
285
+ count: totalSharedTeams,
286
+ teams: totalSharedTeams === 1 ? t('common.team') : t('common.teams'),
287
+ })
288
+ : t('bookmarks.shared')}
289
+ </span>
290
+ </Tooltip>
185
291
  </div>
292
+ )}
186
293
 
187
- <div className="flex flex-wrap items-center gap-1.5 min-h-[24px] flex-shrink-0">
188
- {bookmark.tags && bookmark.tags.length > 0 ? (
189
- <>
190
- {bookmark.tags.slice(0, compact ? 2 : 3).map((tag) => (
191
- <Badge key={tag.id} variant="secondary" className="text-xs font-medium bg-purple-50 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300 border-purple-200 dark:border-purple-800/50">
192
- <TagIcon className="h-3 w-3 mr-1" />
193
- {tag.name}
194
- </Badge>
195
- ))}
196
- {bookmark.tags.length > (compact ? 2 : 3) && (
197
- <Tooltip
198
- content={
199
- <div className="space-y-1">
200
- <div className="font-semibold mb-1">{t('bookmarks.tags')}</div>
201
- {bookmark.tags.map((tag) => (
202
- <div key={tag.id} className="text-xs flex items-center gap-1.5">
203
- <TagIcon className="h-3 w-3 text-purple-400" />
204
- {tag.name}
205
- </div>
206
- ))}
207
- </div>
208
- }
209
- >
210
- <Badge variant="secondary" className="text-xs cursor-help bg-purple-50 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300">
211
- +{bookmark.tags.length - (compact ? 2 : 3)}
212
- </Badge>
213
- </Tooltip>
214
- )}
215
- </>
294
+ {/* Spacer: pushes footer to bottom without stretching header */}
295
+ <div className="flex-1 min-h-0" aria-hidden />
296
+
297
+ {/* Footer: Clicks · Last opened (left); open + kebab (right, hover-only); ~10px above */}
298
+ <footer className="flex-shrink-0 flex items-center justify-between gap-2 h-6 min-h-[24px] pt-2.5">
299
+ <div className="text-[10px] text-foreground/70 truncate min-w-0">
300
+ {t('bookmarks.clicks')}: {typeof bookmark.access_count === 'number' ? bookmark.access_count : '–'}
301
+ {SEP}
302
+ {bookmark.last_accessed_at ? (
303
+ <Tooltip content={formatFullDateTime(bookmark.last_accessed_at)}>
304
+ <span className="cursor-help">
305
+ {t('bookmarks.lastOpened')}: {formatRelativeTime(bookmark.last_accessed_at)}
306
+ </span>
307
+ </Tooltip>
216
308
  ) : (
217
- <Badge variant="secondary" className="text-xs font-medium bg-gray-50 dark:bg-gray-900/20 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-800/50 opacity-60">
218
- <TagIcon className="h-3 w-3 mr-1" />
219
- {t('bookmarks.noTags') || 'No Tags'}
220
- </Badge>
309
+ <span>{t('bookmarks.lastOpened')}: {t('bookmarks.never')}</span>
221
310
  )}
222
311
  </div>
223
-
224
- {(typeof bookmark.access_count === 'number' || bookmark.last_accessed_at != null) && (
225
- <div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground flex-shrink-0">
226
- <span>{t('bookmarks.clicks')}: {typeof bookmark.access_count === 'number' ? bookmark.access_count : '-'}</span>
227
- {bookmark.last_accessed_at ? (
228
- <Tooltip content={formatFullDateTime(bookmark.last_accessed_at)}>
229
- <span className="cursor-help">
230
- {t('bookmarks.lastOpened')}: {formatRelativeTime(bookmark.last_accessed_at)}
231
- </span>
232
- </Tooltip>
312
+ <div
313
+ className="flex items-center gap-0.5 flex-shrink-0 w-[52px] justify-end opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity duration-150"
314
+ data-card-action
315
+ >
316
+ <Tooltip content={t('bookmarks.open')}>
317
+ {onOpen ? (
318
+ <Button
319
+ variant="ghost"
320
+ size="sm"
321
+ icon={ExternalLink}
322
+ iconClassName="h-3.5 w-3.5 stroke-[1.5]"
323
+ className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground transition-colors min-w-6"
324
+ onClick={(e) => {
325
+ e.stopPropagation();
326
+ onOpen();
327
+ }}
328
+ aria-label={t('bookmarks.open')}
329
+ />
233
330
  ) : (
234
- <span>{t('bookmarks.lastOpened')}: {t('bookmarks.never')}</span>
235
- )}
236
- </div>
237
- )}
238
-
239
- {bookmark.forwarding_enabled && (
240
- <div className={`flex items-center gap-1.5 flex-shrink-0 ${compact ? 'px-2 py-1.5' : 'px-2 py-1.5'}`}>
241
- <Badge variant="outline" className="text-xs font-mono">
242
- {t('bookmarks.slug')}: /{bookmark.slug}
243
- </Badge>
244
- <Tooltip content={t('bookmarks.copyUrl')}>
245
- <button
246
- data-card-action
247
- type="button"
248
- onClick={(e) => { e.stopPropagation(); onCopyUrl(); }}
249
- className="flex-shrink-0 p-1.5 text-muted-foreground hover:text-foreground rounded-md hover:bg-muted transition-colors"
250
- aria-label={t('bookmarks.copyUrl')}
331
+ <a
332
+ href={safeHref(bookmark.url)}
333
+ target="_blank"
334
+ rel="noopener noreferrer"
335
+ onClick={(e) => e.stopPropagation()}
336
+ className="text-muted-foreground hover:text-foreground transition-colors inline-flex items-center justify-center"
251
337
  >
252
- <Copy className="h-3.5 w-3.5" />
253
- </button>
254
- </Tooltip>
255
- </div>
256
- )}
257
- </div>
258
-
259
- <div className={`flex gap-1.5 pt-2.5 shrink-0 border-t border-border ${compact ? 'pt-2' : ''}`} data-card-action>
260
- {onOpen ? (
261
- <Tooltip content={t('bookmarks.open')}>
262
- <Button variant="ghost" size="sm" icon={ExternalLink} iconClassName="h-3.5 w-3.5 stroke-[1.5]" className="flex-shrink-0 h-8 w-8 p-0" onClick={(e) => { e.stopPropagation(); onOpen(); }} aria-label={t('bookmarks.open')} />
263
- </Tooltip>
264
- ) : (
265
- <Tooltip content={t('bookmarks.open')}>
266
- <a
267
- href={safeHref(bookmark.url)}
268
- target="_blank"
269
- rel="noopener noreferrer"
270
- onClick={(e) => e.stopPropagation()}
271
- className="flex-shrink-0"
272
- >
273
- <Button variant="ghost" size="sm" icon={ExternalLink} iconClassName="h-3.5 w-3.5 stroke-[1.5]" className="h-8 w-8 p-0" aria-label={t('bookmarks.open')} />
274
- </a>
275
- </Tooltip>
276
- )}
277
- {bookmark.bookmark_type === 'own' && (
278
- <>
279
- {onPinToggle && (
280
- <Tooltip content={bookmark.pinned ? t('bookmarks.pinned') : t('bookmarks.pin')}>
281
338
  <Button
282
339
  variant="ghost"
283
340
  size="sm"
284
- icon={Pin}
285
- iconClassName="h-3.5 w-3.5 stroke-[1.5]"
286
- className={`flex-shrink-0 h-8 w-8 p-0 ${bookmark.pinned ? 'text-primary' : ''}`}
287
- onClick={(e) => { e.stopPropagation(); onPinToggle(); }}
288
- aria-label={bookmark.pinned ? t('bookmarks.pinned') : t('bookmarks.pin')}
289
- aria-pressed={bookmark.pinned}
341
+ icon={ExternalLink}
342
+ iconClassName="h-3 w-3 stroke-[1.5]"
343
+ className="h-6 w-6 p-0 min-w-6"
344
+ aria-label={t('bookmarks.open')}
290
345
  />
291
- </Tooltip>
292
- )}
293
- {onShare && (
294
- <Tooltip content={t('sharing.shareBookmark')}>
295
- <Button variant="ghost" size="sm" icon={Share2} iconClassName="h-3.5 w-3.5 stroke-[1.5]" className="flex-shrink-0 h-8 w-8 p-0" onClick={(e) => { e.stopPropagation(); onShare(); }} aria-label={t('sharing.shareBookmark')} />
296
- </Tooltip>
346
+ </a>
297
347
  )}
298
- <Tooltip content={t('common.edit')}>
299
- <Button variant="ghost" size="sm" icon={Edit} iconClassName="h-3.5 w-3.5 stroke-[1.5]" className="flex-shrink-0 h-8 w-8 p-0" onClick={(e) => { e.stopPropagation(); onEdit(); }} aria-label={t('common.edit')} />
300
- </Tooltip>
301
- <Tooltip content={t('bookmarks.copyUrl')}>
302
- <Button variant="ghost" size="sm" icon={Copy} iconClassName="h-3.5 w-3.5 stroke-[1.5]" className="flex-shrink-0 h-8 w-8 p-0" onClick={(e) => { e.stopPropagation(); onCopyUrl(); }} aria-label={t('bookmarks.copyUrl')} />
303
- </Tooltip>
304
- <Tooltip content={t('common.delete')}>
305
- <Button variant="ghost" size="sm" icon={Trash2} iconClassName="h-3.5 w-3.5 stroke-[1.5]" className="flex-shrink-0 h-8 w-8 p-0 text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300" onClick={(e) => { e.stopPropagation(); onDelete(); }} aria-label={t('common.delete')} />
306
- </Tooltip>
307
- </>
308
- )}
309
- </div>
310
- </div>
348
+ </Tooltip>
349
+ <DropdownMenu>
350
+ <DropdownMenuTrigger asChild>
351
+ <Button
352
+ variant="ghost"
353
+ size="sm"
354
+ icon={MoreVertical}
355
+ iconClassName="h-3 w-3 stroke-[1.5]"
356
+ className="h-6 w-6 p-0 min-w-6 text-muted-foreground hover:text-foreground transition-colors"
357
+ aria-label={t('bookmarks.moreActions')}
358
+ onClick={(e) => e.stopPropagation()}
359
+ />
360
+ </DropdownMenuTrigger>
361
+ <DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
362
+ {bookmark.bookmark_type === 'own' && onPinToggle && (
363
+ <DropdownMenuItem
364
+ onClick={(e) => {
365
+ e.stopPropagation();
366
+ onPinToggle();
367
+ }}
368
+ >
369
+ <Pin className="h-4 w-4" />
370
+ {bookmark.pinned ? t('bookmarks.pinned') : t('bookmarks.pin')}
371
+ </DropdownMenuItem>
372
+ )}
373
+ {onShare && (
374
+ <DropdownMenuItem
375
+ onClick={(e) => {
376
+ e.stopPropagation();
377
+ onShare();
378
+ }}
379
+ >
380
+ <Share2 className="h-4 w-4" />
381
+ {t('sharing.shareBookmark')}
382
+ </DropdownMenuItem>
383
+ )}
384
+ <DropdownMenuItem
385
+ onClick={(e) => {
386
+ e.stopPropagation();
387
+ onCopyUrl();
388
+ }}
389
+ >
390
+ <Copy className="h-4 w-4" />
391
+ {t('bookmarks.copyUrl')}
392
+ </DropdownMenuItem>
393
+ <DropdownMenuItem
394
+ onClick={(e) => {
395
+ e.stopPropagation();
396
+ onEdit();
397
+ }}
398
+ >
399
+ <Edit className="h-4 w-4" />
400
+ {t('common.edit')}
401
+ </DropdownMenuItem>
402
+ <DropdownMenuSeparator />
403
+ <DropdownMenuItem
404
+ onClick={(e) => {
405
+ e.stopPropagation();
406
+ onDelete();
407
+ }}
408
+ className="text-destructive focus:text-destructive"
409
+ >
410
+ <Trash2 className="h-4 w-4" />
411
+ {t('common.delete')}
412
+ </DropdownMenuItem>
413
+ </DropdownMenuContent>
414
+ </DropdownMenu>
415
+ </div>
416
+ </footer>
417
+ </Card>
311
418
  );
312
419
  }