@mdguggenbichler/slugbase-core 0.0.4 → 0.0.6

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 (120) hide show
  1. package/frontend/index.tsx +3 -3
  2. package/frontend/public/favicon.svg +1 -0
  3. package/frontend/public/slugbase_icon_blue.svg +1 -0
  4. package/frontend/public/slugbase_icon_white.png +0 -0
  5. package/frontend/public/slugbase_icon_white.svg +1 -0
  6. package/frontend/src/App.tsx +179 -0
  7. package/frontend/src/api/client.ts +134 -0
  8. package/frontend/src/components/AppSidebar.tsx +214 -0
  9. package/frontend/src/components/EmptyState.tsx +33 -0
  10. package/frontend/src/components/Favicon.tsx +76 -0
  11. package/frontend/src/components/FilterChips.tsx +60 -0
  12. package/frontend/src/components/FolderIcon.tsx +207 -0
  13. package/frontend/src/components/GlobalSearch.tsx +275 -0
  14. package/frontend/src/components/Layout.tsx +60 -0
  15. package/frontend/src/components/PageHeader.tsx +31 -0
  16. package/frontend/src/components/ScopeSegmentedControl.tsx +42 -0
  17. package/frontend/src/components/SentryDebug.tsx +32 -0
  18. package/frontend/src/components/StatCard.tsx +66 -0
  19. package/frontend/src/components/TopBar.tsx +63 -0
  20. package/frontend/src/components/UserDropdown.tsx +86 -0
  21. package/frontend/src/components/admin/AdminAI.tsx +207 -0
  22. package/frontend/src/components/admin/AdminOIDCProviders.tsx +183 -0
  23. package/frontend/src/components/admin/AdminSettings.tsx +413 -0
  24. package/frontend/src/components/admin/AdminTeams.tsx +177 -0
  25. package/frontend/src/components/admin/AdminUsers.tsx +225 -0
  26. package/frontend/src/components/bookmarks/BookmarkCard.tsx +312 -0
  27. package/frontend/src/components/bookmarks/BookmarkListItem.tsx +159 -0
  28. package/frontend/src/components/bookmarks/BookmarkTableView.tsx +419 -0
  29. package/frontend/src/components/bookmarks/BulkActionModals.tsx +162 -0
  30. package/frontend/src/components/bookmarks/FilterChips.tsx +5 -0
  31. package/frontend/src/components/modals/BookmarkModal.tsx +493 -0
  32. package/frontend/src/components/modals/FolderModal.tsx +306 -0
  33. package/frontend/src/components/modals/ImportModal.tsx +232 -0
  34. package/frontend/src/components/modals/OIDCProviderModal.tsx +284 -0
  35. package/frontend/src/components/modals/SharingModal.tsx +96 -0
  36. package/frontend/src/components/modals/TagModal.tsx +101 -0
  37. package/frontend/src/components/modals/TeamAssignmentModal.tsx +354 -0
  38. package/frontend/src/components/modals/TeamModal.tsx +117 -0
  39. package/frontend/src/components/modals/UserModal.tsx +225 -0
  40. package/frontend/src/components/profile/CreateTokenModal.tsx +172 -0
  41. package/frontend/src/components/sharing/ShareResourceDialog.tsx +422 -0
  42. package/frontend/src/components/ui/Autocomplete.tsx +155 -0
  43. package/frontend/src/components/ui/Button.tsx +68 -0
  44. package/frontend/src/components/ui/ConfirmDialog.tsx +79 -0
  45. package/frontend/src/components/ui/FormFieldWrapper.tsx +36 -0
  46. package/frontend/src/components/ui/ModalFooterActions.tsx +49 -0
  47. package/frontend/src/components/ui/ModalSection.tsx +34 -0
  48. package/frontend/src/components/ui/PageLoadingSkeleton.tsx +24 -0
  49. package/frontend/src/components/ui/Select.tsx +61 -0
  50. package/frontend/src/components/ui/SharingField.tsx +298 -0
  51. package/frontend/src/components/ui/Toast.tsx +47 -0
  52. package/frontend/src/components/ui/Tooltip.tsx +21 -0
  53. package/frontend/src/components/ui/alert-dialog.tsx +139 -0
  54. package/frontend/src/components/ui/badge.tsx +36 -0
  55. package/frontend/src/components/ui/button-base.tsx +57 -0
  56. package/frontend/src/components/ui/card.tsx +76 -0
  57. package/frontend/src/components/ui/command.tsx +161 -0
  58. package/frontend/src/components/ui/dialog.tsx +120 -0
  59. package/frontend/src/components/ui/dropdown-menu.tsx +199 -0
  60. package/frontend/src/components/ui/input.tsx +22 -0
  61. package/frontend/src/components/ui/label.tsx +24 -0
  62. package/frontend/src/components/ui/popover.tsx +33 -0
  63. package/frontend/src/components/ui/progress.tsx +26 -0
  64. package/frontend/src/components/ui/scroll-area.tsx +48 -0
  65. package/frontend/src/components/ui/select-base.tsx +159 -0
  66. package/frontend/src/components/ui/separator.tsx +29 -0
  67. package/frontend/src/components/ui/sheet.tsx +140 -0
  68. package/frontend/src/components/ui/sidebar.tsx +783 -0
  69. package/frontend/src/components/ui/skeleton.tsx +15 -0
  70. package/frontend/src/components/ui/sonner.tsx +46 -0
  71. package/frontend/src/components/ui/switch.tsx +28 -0
  72. package/frontend/src/components/ui/table.tsx +120 -0
  73. package/frontend/src/components/ui/tooltip-base.tsx +30 -0
  74. package/frontend/src/config/api.ts +16 -0
  75. package/frontend/src/config/mode.ts +6 -0
  76. package/frontend/src/contexts/AppConfigContext.tsx +39 -0
  77. package/frontend/src/contexts/AuthContext.tsx +137 -0
  78. package/frontend/src/contexts/SearchCommandContext.tsx +28 -0
  79. package/frontend/src/hooks/use-mobile.tsx +19 -0
  80. package/frontend/src/hooks/useConfirmDialog.ts +63 -0
  81. package/frontend/src/hooks/useMarketingTheme.ts +47 -0
  82. package/frontend/src/i18n.ts +39 -0
  83. package/frontend/src/index.css +117 -0
  84. package/frontend/src/instrument.ts +20 -0
  85. package/frontend/src/lib/utils.ts +6 -0
  86. package/frontend/src/locales/de.json +899 -0
  87. package/frontend/src/locales/en.json +937 -0
  88. package/frontend/src/locales/es.json +884 -0
  89. package/frontend/src/locales/fr.json +550 -0
  90. package/frontend/src/locales/it.json +535 -0
  91. package/frontend/src/locales/ja.json +535 -0
  92. package/frontend/src/locales/nl.json +550 -0
  93. package/frontend/src/locales/pl.json +535 -0
  94. package/frontend/src/locales/pt.json +535 -0
  95. package/frontend/src/locales/ru.json +535 -0
  96. package/frontend/src/locales/zh.json +535 -0
  97. package/frontend/src/main.tsx +44 -0
  98. package/frontend/src/pages/Bookmarks.tsx +1004 -0
  99. package/frontend/src/pages/Dashboard.tsx +427 -0
  100. package/frontend/src/pages/Folders.tsx +578 -0
  101. package/frontend/src/pages/GoPreferences.tsx +134 -0
  102. package/frontend/src/pages/Login.tsx +196 -0
  103. package/frontend/src/pages/PasswordReset.tsx +242 -0
  104. package/frontend/src/pages/Profile.tsx +593 -0
  105. package/frontend/src/pages/SearchEngineGuide.tsx +135 -0
  106. package/frontend/src/pages/Setup.tsx +210 -0
  107. package/frontend/src/pages/Signup.tsx +199 -0
  108. package/frontend/src/pages/Tags.tsx +421 -0
  109. package/frontend/src/pages/VerifyEmail.tsx +254 -0
  110. package/frontend/src/pages/admin/AdminAIPage.tsx +5 -0
  111. package/frontend/src/pages/admin/AdminLayout.tsx +40 -0
  112. package/frontend/src/pages/admin/AdminMembersPage.tsx +5 -0
  113. package/frontend/src/pages/admin/AdminOIDCPage.tsx +5 -0
  114. package/frontend/src/pages/admin/AdminSettingsPage.tsx +5 -0
  115. package/frontend/src/pages/admin/AdminTeamsPage.tsx +5 -0
  116. package/frontend/src/utils/favicon.ts +36 -0
  117. package/frontend/src/utils/formatRelativeTime.ts +37 -0
  118. package/frontend/src/utils/safeHref.ts +31 -0
  119. package/frontend/src/vite-env.d.ts +10 -0
  120. package/package.json +9 -1
@@ -0,0 +1,159 @@
1
+ import { Share2, Tag as TagIcon, ExternalLink, Copy, Edit, Trash2, CheckSquare, Square } from 'lucide-react';
2
+ import Button from '../ui/Button';
3
+ import Tooltip from '../ui/Tooltip';
4
+ import Favicon from '../Favicon';
5
+ import FolderIcon from '../FolderIcon';
6
+ import { safeHref } from '../../utils/safeHref';
7
+
8
+ interface Bookmark {
9
+ id: string;
10
+ title: string;
11
+ url: string;
12
+ slug: string;
13
+ forwarding_enabled: boolean;
14
+ owner_user_key?: string;
15
+ folders?: Array<{ id: string; name: string; icon?: string | null; shared_teams?: Array<{ id: string; name: string }> }>;
16
+ tags?: Array<{ id: string; name: string }>;
17
+ shared_teams?: Array<{ id: string; name: string }>;
18
+ bookmark_type?: 'own' | 'shared';
19
+ }
20
+
21
+ interface BookmarkListItemProps {
22
+ bookmark: Bookmark;
23
+ compact: boolean;
24
+ selected: boolean;
25
+ onSelect: () => void;
26
+ onEdit: () => void;
27
+ onDelete: () => void;
28
+ onCopyUrl: () => void;
29
+ bulkMode: boolean;
30
+ user: any;
31
+ t: any;
32
+ }
33
+
34
+ export default function BookmarkListItem({
35
+ bookmark,
36
+ compact,
37
+ selected,
38
+ onSelect,
39
+ onEdit,
40
+ onDelete,
41
+ onCopyUrl,
42
+ bulkMode,
43
+ user: _user,
44
+ t,
45
+ }: BookmarkListItemProps) {
46
+ const totalSharedTeams = (bookmark.shared_teams?.length || 0) +
47
+ (bookmark.folders?.reduce((sum, f) => sum + (f.shared_teams?.length || 0), 0) || 0);
48
+ const isShared = totalSharedTeams > 0;
49
+
50
+ return (
51
+ <div
52
+ className={`group bg-white dark:bg-gray-800 rounded-lg border ${
53
+ selected
54
+ ? 'border-blue-500 dark:border-blue-400 ring-2 ring-blue-500/20'
55
+ : 'border-gray-200 dark:border-gray-700 hover:border-blue-400 dark:hover:border-blue-500'
56
+ } hover:shadow transition-all duration-200 ${compact ? 'p-2.5 min-h-[80px]' : 'p-4'}`}
57
+ >
58
+ <div className={`flex items-center ${compact ? 'gap-3' : 'gap-4'}`}>
59
+ {bulkMode && (
60
+ <button
61
+ onClick={onSelect}
62
+ className="flex-shrink-0 text-blue-600 dark:text-blue-400"
63
+ >
64
+ {selected ? <CheckSquare className="h-5 w-5" /> : <Square className="h-5 w-5" />}
65
+ </button>
66
+ )}
67
+ <div className={`flex-shrink-0 ${compact ? 'w-10 h-10' : 'w-12 h-12'} rounded-lg bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/30 dark:to-blue-800/20 flex items-center justify-center border border-blue-100 dark:border-blue-800/50 overflow-hidden`}>
68
+ <Favicon url={bookmark.url} size={compact ? 20 : 24} />
69
+ </div>
70
+ <div className="flex-1 min-w-0">
71
+ <div className="flex items-start justify-between gap-4">
72
+ <div className="flex-1 min-w-0">
73
+ <h3 className={`${compact ? 'text-sm' : 'text-[15px]'} font-medium text-gray-900 dark:text-white mb-1`}>
74
+ {bookmark.title}
75
+ </h3>
76
+ <p className={`${compact ? 'text-xs' : 'text-sm'} text-gray-700 dark:text-gray-200 truncate ${compact ? 'mb-1' : 'mb-2'}`}>
77
+ {bookmark.url}
78
+ </p>
79
+ <div className={`flex flex-wrap items-center gap-2 ${compact ? 'min-h-[24px]' : ''}`}>
80
+ {bookmark.folders && bookmark.folders.length > 0 && (
81
+ bookmark.folders.slice(0, 1).map((folder) => (
82
+ <span
83
+ key={folder.id}
84
+ className={`inline-flex items-center gap-1 px-2 py-0.5 ${compact ? 'text-xs' : 'text-xs'} font-medium bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 rounded-md`}
85
+ >
86
+ <FolderIcon iconName={folder.icon} size={12} />
87
+ {folder.name}
88
+ </span>
89
+ ))
90
+ )}
91
+ {bookmark.tags && bookmark.tags.length > 0 && (
92
+ bookmark.tags.slice(0, compact ? 2 : 3).map((tag) => (
93
+ <span
94
+ key={tag.id}
95
+ className={`inline-flex items-center gap-1 px-2 py-0.5 ${compact ? 'text-xs' : 'text-xs'} font-medium bg-purple-50 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300 rounded-md`}
96
+ >
97
+ <TagIcon className="h-3 w-3" />
98
+ {tag.name}
99
+ </span>
100
+ ))
101
+ )}
102
+ {isShared && (
103
+ <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">
104
+ <Share2 className="h-3 w-3" />
105
+ {totalSharedTeams > 0
106
+ ? t('bookmarks.sharedWithTeams', { count: totalSharedTeams, teams: totalSharedTeams === 1 ? t('common.team') : t('common.teams') })
107
+ : t('bookmarks.shared')}
108
+ </span>
109
+ )}
110
+ </div>
111
+ </div>
112
+ <div className="flex items-center gap-2 flex-shrink-0">
113
+ {bookmark.forwarding_enabled && (
114
+ <Tooltip content={`${window.location.origin}/go/${bookmark.slug}`}>
115
+ <button
116
+ onClick={onCopyUrl}
117
+ className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
118
+ title={t('bookmarks.copyUrl')}
119
+ >
120
+ <Copy className="h-4 w-4" />
121
+ </button>
122
+ </Tooltip>
123
+ )}
124
+ <a
125
+ href={safeHref(bookmark.url)}
126
+ target="_blank"
127
+ rel="noopener noreferrer"
128
+ >
129
+ <Button variant="primary" size="sm" icon={ExternalLink} className={compact ? 'text-xs px-2' : ''}>
130
+ {t('bookmarks.open')}
131
+ </Button>
132
+ </a>
133
+ {bookmark.bookmark_type === 'own' && (
134
+ <>
135
+ <Button
136
+ variant="ghost"
137
+ size="sm"
138
+ icon={Edit}
139
+ onClick={onEdit}
140
+ title={t('common.edit')}
141
+ className="px-2"
142
+ />
143
+ <Button
144
+ variant="ghost"
145
+ size="sm"
146
+ icon={Trash2}
147
+ onClick={onDelete}
148
+ title={t('common.delete')}
149
+ className="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 px-2"
150
+ />
151
+ </>
152
+ )}
153
+ </div>
154
+ </div>
155
+ </div>
156
+ </div>
157
+ </div>
158
+ );
159
+ }
@@ -0,0 +1,419 @@
1
+ import { useState, useMemo } from 'react';
2
+ import { Share2, Tag as TagIcon, ExternalLink, Copy, Edit, Trash2, CheckSquare, Square, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
3
+ import Button from '../ui/Button';
4
+ import Tooltip from '../ui/Tooltip';
5
+ import Favicon from '../Favicon';
6
+ import FolderIcon from '../FolderIcon';
7
+ import { Badge } from '../ui/badge';
8
+ import {
9
+ Table,
10
+ TableBody,
11
+ TableCell,
12
+ TableHead,
13
+ TableHeader,
14
+ TableRow,
15
+ } from '../ui/table';
16
+ import { Card } from '../ui/card';
17
+ import { safeHref } from '../../utils/safeHref';
18
+ import { formatRelativeTime, formatFullDateTime } from '../../utils/formatRelativeTime';
19
+
20
+ interface Bookmark {
21
+ id: string;
22
+ title: string;
23
+ url: string;
24
+ slug: string;
25
+ forwarding_enabled: boolean;
26
+ owner_user_key?: string;
27
+ folders?: Array<{ id: string; name: string; icon?: string | null; shared_teams?: Array<{ id: string; name: string }> }>;
28
+ tags?: Array<{ id: string; name: string }>;
29
+ shared_teams?: Array<{ id: string; name: string }>;
30
+ bookmark_type?: 'own' | 'shared';
31
+ last_accessed_at?: string | null | undefined;
32
+ access_count?: number;
33
+ created_at?: string;
34
+ }
35
+
36
+ interface BookmarkTableViewProps {
37
+ bookmarks: Bookmark[];
38
+ selectedBookmarks: Set<string>;
39
+ onSelect: (id: string) => void;
40
+ onSelectAll: () => void;
41
+ onEdit: (bookmark: Bookmark) => void;
42
+ onDelete: (id: string, name?: string) => void;
43
+ onCopyUrl: (bookmark: Bookmark) => void;
44
+ onShare?: (bookmark: Bookmark) => void;
45
+ onOpen?: (bookmark: Bookmark) => void;
46
+ bulkMode: boolean;
47
+ user: any;
48
+ t: any;
49
+ compact?: boolean;
50
+ }
51
+
52
+ export default function BookmarkTableView({
53
+ bookmarks,
54
+ selectedBookmarks,
55
+ onSelect,
56
+ onSelectAll,
57
+ onEdit,
58
+ onDelete,
59
+ onCopyUrl,
60
+ onShare,
61
+ onOpen,
62
+ bulkMode,
63
+ user: _user,
64
+ t,
65
+ compact = false,
66
+ }: BookmarkTableViewProps) {
67
+ const [sortColumn, setSortColumn] = useState<'title' | 'url' | 'last_accessed' | null>(null);
68
+ const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
69
+
70
+ const sortedBookmarks = useMemo(() => {
71
+ if (!sortColumn) return bookmarks;
72
+
73
+ return [...bookmarks].sort((a, b) => {
74
+ let aVal: any;
75
+ let bVal: any;
76
+
77
+ switch (sortColumn) {
78
+ case 'title':
79
+ aVal = a.title.toLowerCase();
80
+ bVal = b.title.toLowerCase();
81
+ break;
82
+ case 'url':
83
+ aVal = a.url.toLowerCase();
84
+ bVal = b.url.toLowerCase();
85
+ break;
86
+ case 'last_accessed':
87
+ aVal = a.last_accessed_at ? new Date(a.last_accessed_at).getTime() : 0;
88
+ bVal = b.last_accessed_at ? new Date(b.last_accessed_at).getTime() : 0;
89
+ break;
90
+ default:
91
+ return 0;
92
+ }
93
+
94
+ if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1;
95
+ if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1;
96
+ return 0;
97
+ });
98
+ }, [bookmarks, sortColumn, sortDirection]);
99
+
100
+ function handleSort(column: 'title' | 'url' | 'last_accessed') {
101
+ if (sortColumn === column) {
102
+ setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
103
+ } else {
104
+ setSortColumn(column);
105
+ setSortDirection('asc');
106
+ }
107
+ }
108
+
109
+ function getSortIcon(column: 'title' | 'url' | 'last_accessed') {
110
+ const iconSize = compact ? 'h-3 w-3' : 'h-4 w-4';
111
+ if (sortColumn !== column) {
112
+ return <ArrowUpDown className={`${iconSize} text-gray-400`} />;
113
+ }
114
+ return sortDirection === 'asc' ? (
115
+ <ArrowUp className={`${iconSize} text-primary`} />
116
+ ) : (
117
+ <ArrowDown className={`${iconSize} text-primary`} />
118
+ );
119
+ }
120
+
121
+ function formatCreated(dateString?: string | null) {
122
+ if (!dateString) return '-';
123
+ const date = new Date(dateString);
124
+ return date.toLocaleDateString();
125
+ }
126
+
127
+ const cellClass = compact ? 'px-2 py-1.5' : 'px-4 py-3';
128
+ const headClass = compact ? 'text-[10px]' : 'text-xs';
129
+
130
+ return (
131
+ <Card className={compact ? 'overflow-x-auto' : ''}>
132
+ <Table>
133
+ <TableHeader>
134
+ <TableRow>
135
+ {bulkMode && (
136
+ <TableHead className={cellClass}>
137
+ <button
138
+ onClick={onSelectAll}
139
+ className="text-primary"
140
+ aria-label={t('bookmarks.selectAll')}
141
+ >
142
+ {selectedBookmarks.size === bookmarks.length ? (
143
+ <CheckSquare className={compact ? 'h-4 w-4' : 'h-5 w-5'} />
144
+ ) : (
145
+ <Square className={compact ? 'h-4 w-4' : 'h-5 w-5'} />
146
+ )}
147
+ </button>
148
+ </TableHead>
149
+ )}
150
+ <TableHead className={cellClass}>
151
+ <button
152
+ onClick={() => handleSort('title')}
153
+ className={`flex items-center gap-2 ${headClass} font-semibold uppercase tracking-wide hover:text-foreground`}
154
+ >
155
+ {t('bookmarks.name')}
156
+ {getSortIcon('title')}
157
+ </button>
158
+ </TableHead>
159
+ {compact ? (
160
+ <>
161
+ <TableHead className={`${cellClass} ${headClass} font-semibold uppercase tracking-wide`}>{t('bookmarks.folders')}</TableHead>
162
+ <TableHead className={`${cellClass} ${headClass} font-semibold uppercase tracking-wide`}>{t('bookmarks.tags')}</TableHead>
163
+ <TableHead className={`${cellClass} ${headClass} font-semibold uppercase tracking-wide`}>{t('bookmarks.clicks')}</TableHead>
164
+ <TableHead className={`${cellClass} ${headClass} font-semibold uppercase tracking-wide`}>{t('bookmarks.lastOpened')}</TableHead>
165
+ <TableHead className={`${cellClass} ${headClass} font-semibold uppercase tracking-wide`}>{t('bookmarks.sortRecentlyAdded')}</TableHead>
166
+ </>
167
+ ) : (
168
+ <>
169
+ <TableHead className={cellClass}>
170
+ <button
171
+ onClick={() => handleSort('url')}
172
+ className={`flex items-center gap-2 text-xs font-semibold uppercase tracking-wide hover:text-foreground`}
173
+ >
174
+ {t('bookmarks.url')}
175
+ {getSortIcon('url')}
176
+ </button>
177
+ </TableHead>
178
+ <TableHead className={`${cellClass} text-xs font-semibold uppercase tracking-wide`}>
179
+ {t('bookmarks.folders')}
180
+ </TableHead>
181
+ <TableHead className={`${cellClass} text-xs font-semibold uppercase tracking-wide`}>
182
+ {t('bookmarks.tags')}
183
+ </TableHead>
184
+ <TableHead className={`${cellClass} text-xs font-semibold uppercase tracking-wide`}>
185
+ {t('bookmarks.shared')}
186
+ </TableHead>
187
+ <TableHead className={`${cellClass} text-xs font-semibold uppercase tracking-wide`}>
188
+ {t('bookmarks.clicks')}
189
+ </TableHead>
190
+ <TableHead className={cellClass}>
191
+ <button
192
+ onClick={() => handleSort('last_accessed')}
193
+ className={`flex items-center gap-2 text-xs font-semibold uppercase tracking-wide hover:text-foreground`}
194
+ >
195
+ {t('bookmarks.lastOpened')}
196
+ {getSortIcon('last_accessed')}
197
+ </button>
198
+ </TableHead>
199
+ <TableHead className={`${cellClass} text-xs font-semibold uppercase tracking-wide`}>
200
+ {t('bookmarks.sortRecentlyAdded')}
201
+ </TableHead>
202
+ </>
203
+ )}
204
+ <TableHead className={`${cellClass} text-right ${headClass} font-semibold uppercase tracking-wide`}>
205
+ {t('common.actions')}
206
+ </TableHead>
207
+ </TableRow>
208
+ </TableHeader>
209
+ <TableBody>
210
+ {sortedBookmarks.map((bookmark) => {
211
+ const totalSharedTeams = (bookmark.shared_teams?.length || 0) +
212
+ (bookmark.folders?.reduce((sum, f) => sum + (f.shared_teams?.length || 0), 0) || 0);
213
+ const isShared = totalSharedTeams > 0;
214
+
215
+ return (
216
+ <TableRow
217
+ key={bookmark.id}
218
+ className={`group ${selectedBookmarks.has(bookmark.id) ? 'bg-primary/10' : ''} ${compact ? 'h-10' : ''}`}
219
+ data-state={selectedBookmarks.has(bookmark.id) ? 'selected' : undefined}
220
+ >
221
+ {bulkMode && (
222
+ <TableCell className={cellClass}>
223
+ <button
224
+ onClick={() => onSelect(bookmark.id)}
225
+ className="text-primary"
226
+ aria-label={t('bookmarks.selectAll')}
227
+ >
228
+ {selectedBookmarks.has(bookmark.id) ? (
229
+ <CheckSquare className={`${compact ? 'h-4 w-4' : 'h-5 w-5'}`} />
230
+ ) : (
231
+ <Square className={`${compact ? 'h-4 w-4' : 'h-5 w-5'}`} />
232
+ )}
233
+ </button>
234
+ </TableCell>
235
+ )}
236
+ <TableCell className={cellClass}>
237
+ <a
238
+ href={safeHref(bookmark.url)}
239
+ target="_blank"
240
+ rel="noopener noreferrer"
241
+ onClick={(e) => { if (onOpen) { e.preventDefault(); onOpen(bookmark); } }}
242
+ className={`flex items-center ${compact ? 'gap-2' : 'gap-3'} group/title hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded`}
243
+ >
244
+ <div className={`flex-shrink-0 ${compact ? 'w-6 h-6' : 'w-8 h-8'} rounded-lg bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/30 dark:to-blue-800/20 flex items-center justify-center border border-blue-100 dark:border-blue-800/50 overflow-hidden`}>
245
+ <Favicon url={bookmark.url} size={compact ? 12 : 16} />
246
+ </div>
247
+ <div className={`font-semibold text-gray-900 dark:text-white ${compact ? 'text-xs' : 'text-[15px]'} truncate`}>
248
+ {bookmark.title}
249
+ </div>
250
+ </a>
251
+ </TableCell>
252
+ {compact ? (
253
+ <>
254
+ <TableCell className={cellClass}>
255
+ <div className="flex flex-wrap gap-0.5 max-w-[120px]">
256
+ {bookmark.folders && bookmark.folders.length > 0 ? (
257
+ bookmark.folders.slice(0, 1).map((folder) => (
258
+ <span key={folder.id} className="text-xs text-muted-foreground truncate">
259
+ <FolderIcon iconName={folder.icon} size={10} className="inline mr-0.5" />
260
+ {folder.name}
261
+ </span>
262
+ ))
263
+ ) : (
264
+ <span className="text-xs text-muted-foreground">-</span>
265
+ )}
266
+ </div>
267
+ </TableCell>
268
+ <TableCell className={cellClass}>
269
+ <div className="flex flex-wrap gap-0.5 max-w-[100px] truncate">
270
+ {bookmark.tags && bookmark.tags.length > 0 ? (
271
+ <span className="text-xs text-muted-foreground truncate" title={bookmark.tags.map(t => t.name).join(', ')}>
272
+ {bookmark.tags.slice(0, 2).map(t => t.name).join(', ')}
273
+ {bookmark.tags.length > 2 ? ` +${bookmark.tags.length - 2}` : ''}
274
+ </span>
275
+ ) : (
276
+ <span className="text-xs text-muted-foreground">-</span>
277
+ )}
278
+ </div>
279
+ </TableCell>
280
+ <TableCell className={cellClass}>
281
+ <span className="text-xs text-muted-foreground">
282
+ {typeof bookmark.access_count === 'number' ? bookmark.access_count : '-'}
283
+ </span>
284
+ </TableCell>
285
+ <TableCell className={cellClass}>
286
+ {bookmark.last_accessed_at ? (
287
+ <Tooltip content={formatFullDateTime(bookmark.last_accessed_at)}>
288
+ <span className="text-xs text-muted-foreground cursor-help">
289
+ {formatRelativeTime(bookmark.last_accessed_at)}
290
+ </span>
291
+ </Tooltip>
292
+ ) : (
293
+ <span className="text-xs text-muted-foreground">{t('bookmarks.never')}</span>
294
+ )}
295
+ </TableCell>
296
+ <TableCell className={cellClass}>
297
+ <span className="text-xs text-muted-foreground">
298
+ {formatCreated(bookmark.created_at)}
299
+ </span>
300
+ </TableCell>
301
+ </>
302
+ ) : (
303
+ <>
304
+ <TableCell className={cellClass}>
305
+ <a
306
+ href={safeHref(bookmark.url)}
307
+ target="_blank"
308
+ rel="noopener noreferrer"
309
+ className="text-sm text-muted-foreground hover:text-foreground truncate max-w-xs block"
310
+ >
311
+ {bookmark.url}
312
+ </a>
313
+ </TableCell>
314
+ <TableCell className={cellClass}>
315
+ <div className="flex flex-wrap gap-1">
316
+ {bookmark.folders && bookmark.folders.length > 0 ? (
317
+ bookmark.folders.slice(0, 2).map((folder) => (
318
+ <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">
319
+ <FolderIcon iconName={folder.icon} size={12} className="mr-1" />
320
+ {folder.name}
321
+ </Badge>
322
+ ))
323
+ ) : (
324
+ <span className="text-xs text-muted-foreground">-</span>
325
+ )}
326
+ </div>
327
+ </TableCell>
328
+ <TableCell className={cellClass}>
329
+ <div className="flex flex-wrap gap-1">
330
+ {bookmark.tags && bookmark.tags.length > 0 ? (
331
+ bookmark.tags.slice(0, 3).map((tag) => (
332
+ <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">
333
+ <TagIcon className="h-3 w-3 mr-1" />
334
+ {tag.name}
335
+ </Badge>
336
+ ))
337
+ ) : (
338
+ <span className="text-xs text-muted-foreground">-</span>
339
+ )}
340
+ </div>
341
+ </TableCell>
342
+ <TableCell className={cellClass}>
343
+ {isShared ? (
344
+ <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">
345
+ <Share2 className="h-3 w-3" />
346
+ {totalSharedTeams > 0
347
+ ? t('bookmarks.sharedWithTeams', { count: totalSharedTeams, teams: totalSharedTeams === 1 ? t('common.team') : t('common.teams') })
348
+ : t('bookmarks.shared')}
349
+ </span>
350
+ ) : (
351
+ <span className="text-xs text-muted-foreground">-</span>
352
+ )}
353
+ </TableCell>
354
+ <TableCell className={cellClass}>
355
+ <span className="text-xs text-muted-foreground">
356
+ {typeof bookmark.access_count === 'number' ? bookmark.access_count : '-'}
357
+ </span>
358
+ </TableCell>
359
+ <TableCell className={cellClass}>
360
+ {bookmark.last_accessed_at ? (
361
+ <Tooltip content={formatFullDateTime(bookmark.last_accessed_at)}>
362
+ <span className="text-xs text-muted-foreground cursor-help">
363
+ {formatRelativeTime(bookmark.last_accessed_at)}
364
+ </span>
365
+ </Tooltip>
366
+ ) : (
367
+ <span className="text-xs text-muted-foreground">{t('bookmarks.never')}</span>
368
+ )}
369
+ </TableCell>
370
+ <TableCell className={cellClass}>
371
+ <span className="text-xs text-muted-foreground">
372
+ {formatCreated(bookmark.created_at)}
373
+ </span>
374
+ </TableCell>
375
+ </>
376
+ )}
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
+ )}
384
+ {onOpen ? (
385
+ <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
+ </Tooltip>
388
+ ) : (
389
+ <Tooltip content={t('bookmarks.open')}>
390
+ <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
+ </a>
393
+ </Tooltip>
394
+ )}
395
+ {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
+ </>
409
+ )}
410
+ </div>
411
+ </TableCell>
412
+ </TableRow>
413
+ );
414
+ })}
415
+ </TableBody>
416
+ </Table>
417
+ </Card>
418
+ );
419
+ }