@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,225 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import api from '../../api/client';
4
+ import { Plus, Edit, Trash2, Shield, Mail, Network, MoreHorizontal } from 'lucide-react';
5
+ import UserModal from '../modals/UserModal';
6
+ import TeamAssignmentModal from '../modals/TeamAssignmentModal';
7
+ import Button from '../ui/Button';
8
+ import ConfirmDialog from '../ui/ConfirmDialog';
9
+ import { useConfirmDialog } from '../../hooks/useConfirmDialog';
10
+ import {
11
+ DropdownMenu,
12
+ DropdownMenuContent,
13
+ DropdownMenuItem,
14
+ DropdownMenuTrigger,
15
+ } from '../ui/dropdown-menu';
16
+ import {
17
+ Table,
18
+ TableBody,
19
+ TableCell,
20
+ TableHead,
21
+ TableHeader,
22
+ TableRow,
23
+ } from '../ui/table';
24
+ import { Card } from '../ui/card';
25
+ import { Skeleton } from '../ui/skeleton';
26
+
27
+ interface User {
28
+ id: string;
29
+ email: string;
30
+ name: string;
31
+ is_admin: boolean;
32
+ oidc_provider: string | null;
33
+ created_at: string;
34
+ }
35
+
36
+ export default function AdminUsers() {
37
+ const { t } = useTranslation();
38
+ const { showConfirm, dialogState } = useConfirmDialog();
39
+ const [users, setUsers] = useState<User[]>([]);
40
+ const [loading, setLoading] = useState(true);
41
+ const [modalOpen, setModalOpen] = useState(false);
42
+ const [assignmentModalOpen, setAssignmentModalOpen] = useState(false);
43
+ const [editingUser, setEditingUser] = useState<User | null>(null);
44
+ const [selectedUserForAssignment, setSelectedUserForAssignment] = useState<User | null>(null);
45
+
46
+ useEffect(() => {
47
+ loadUsers();
48
+ }, []);
49
+
50
+ const loadUsers = async () => {
51
+ try {
52
+ const response = await api.get('/admin/users');
53
+ setUsers(response.data);
54
+ } catch (error) {
55
+ console.error('Failed to load users:', error);
56
+ } finally {
57
+ setLoading(false);
58
+ }
59
+ };
60
+
61
+ const handleEdit = (user: User) => {
62
+ setEditingUser(user);
63
+ setModalOpen(true);
64
+ };
65
+
66
+ const handleManageTeams = (user: User) => {
67
+ setSelectedUserForAssignment(user);
68
+ setAssignmentModalOpen(true);
69
+ };
70
+
71
+ const handleDelete = (id: string) => {
72
+ showConfirm(
73
+ t('admin.confirmDeleteUser'),
74
+ t('admin.confirmDeleteUser'),
75
+ async () => {
76
+ try {
77
+ await api.delete(`/admin/users/${id}`);
78
+ loadUsers();
79
+ } catch (error: any) {
80
+ alert(error.response?.data?.error || t('common.error'));
81
+ }
82
+ },
83
+ { variant: 'danger', confirmText: t('common.delete'), cancelText: t('common.cancel') }
84
+ );
85
+ };
86
+
87
+ const handleModalClose = () => {
88
+ setModalOpen(false);
89
+ setEditingUser(null);
90
+ };
91
+
92
+ const handleAssignmentModalClose = () => {
93
+ setAssignmentModalOpen(false);
94
+ setSelectedUserForAssignment(null);
95
+ };
96
+
97
+ if (loading) {
98
+ return (
99
+ <div className="space-y-6">
100
+ <div className="space-y-2">
101
+ <Skeleton className="h-6 w-32" />
102
+ <Skeleton className="h-4 w-48" />
103
+ </div>
104
+ <Card>
105
+ <div className="p-6 space-y-4">
106
+ {Array.from({ length: 5 }).map((_, i) => (
107
+ <Skeleton key={i} className="h-16 w-full" />
108
+ ))}
109
+ </div>
110
+ </Card>
111
+ </div>
112
+ );
113
+ }
114
+
115
+ return (
116
+ <div className="space-y-6">
117
+ <div className="flex items-center justify-between flex-wrap gap-4">
118
+ <div>
119
+ <h2 className="text-lg font-semibold text-gray-900 dark:text-white">{t('admin.users')}</h2>
120
+ <p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
121
+ {`${users.length} ${users.length === 1 ? t('common.user') : t('common.users')}`}
122
+ </p>
123
+ </div>
124
+ <Button onClick={() => setModalOpen(true)} icon={Plus}>
125
+ {t('admin.addUser')}
126
+ </Button>
127
+ </div>
128
+
129
+ <Card>
130
+ <Table>
131
+ <TableHeader>
132
+ <TableRow>
133
+ <TableHead className="w-[50px]">
134
+ <span className="sr-only">{t('admin.user')}</span>
135
+ </TableHead>
136
+ <TableHead>{t('admin.user')}</TableHead>
137
+ <TableHead className="hidden sm:table-cell">{t('auth.email')}</TableHead>
138
+ <TableHead className="w-[80px] text-right">{t('common.actions')}</TableHead>
139
+ </TableRow>
140
+ </TableHeader>
141
+ <TableBody>
142
+ {users.map((user) => (
143
+ <TableRow key={user.id}>
144
+ <TableCell>
145
+ <div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
146
+ <Mail className="h-5 w-5 text-primary" />
147
+ </div>
148
+ </TableCell>
149
+ <TableCell>
150
+ <div className="flex items-center gap-2">
151
+ <p className="font-medium">{user.name}</p>
152
+ {Boolean(user.is_admin) && (
153
+ <span title={t('admin.admin')}>
154
+ <Shield className="h-4 w-4 text-yellow-500" />
155
+ </span>
156
+ )}
157
+ </div>
158
+ <p className="text-sm text-muted-foreground truncate sm:hidden">{user.email}</p>
159
+ {user.oidc_provider && (
160
+ <p className="text-xs text-muted-foreground mt-0.5">
161
+ {t('admin.oidcUser')}: {user.oidc_provider}
162
+ </p>
163
+ )}
164
+ </TableCell>
165
+ <TableCell className="hidden sm:table-cell text-muted-foreground">
166
+ {user.email}
167
+ </TableCell>
168
+ <TableCell className="text-right">
169
+ <DropdownMenu>
170
+ <DropdownMenuTrigger asChild>
171
+ <button
172
+ type="button"
173
+ className="inline-flex items-center justify-center h-8 w-8 rounded-md hover:bg-accent hover:text-accent-foreground"
174
+ >
175
+ <MoreHorizontal className="h-4 w-4" />
176
+ <span className="sr-only">{t('common.actions')}</span>
177
+ </button>
178
+ </DropdownMenuTrigger>
179
+ <DropdownMenuContent align="end">
180
+ <DropdownMenuItem onClick={() => handleManageTeams(user)}>
181
+ <Network className="mr-2 h-4 w-4" />
182
+ {t('admin.manageTeams')}
183
+ </DropdownMenuItem>
184
+ <DropdownMenuItem onClick={() => handleEdit(user)}>
185
+ <Edit className="mr-2 h-4 w-4" />
186
+ {t('common.edit')}
187
+ </DropdownMenuItem>
188
+ <DropdownMenuItem
189
+ onClick={() => handleDelete(user.id)}
190
+ className="text-destructive focus:text-destructive"
191
+ >
192
+ <Trash2 className="mr-2 h-4 w-4" />
193
+ {t('common.delete')}
194
+ </DropdownMenuItem>
195
+ </DropdownMenuContent>
196
+ </DropdownMenu>
197
+ </TableCell>
198
+ </TableRow>
199
+ ))}
200
+ </TableBody>
201
+ </Table>
202
+ </Card>
203
+
204
+ <UserModal
205
+ user={editingUser}
206
+ isOpen={modalOpen}
207
+ onClose={handleModalClose}
208
+ onSuccess={loadUsers}
209
+ />
210
+
211
+ {selectedUserForAssignment && (
212
+ <TeamAssignmentModal
213
+ mode="user"
214
+ userId={selectedUserForAssignment.id}
215
+ userName={selectedUserForAssignment.name}
216
+ isOpen={assignmentModalOpen}
217
+ onClose={handleAssignmentModalClose}
218
+ onSuccess={loadUsers}
219
+ />
220
+ )}
221
+
222
+ <ConfirmDialog {...dialogState} />
223
+ </div>
224
+ );
225
+ }
@@ -0,0 +1,312 @@
1
+ import { Share2, Tag as TagIcon, ExternalLink, Copy, Edit, Trash2, CheckSquare, Square, Pin } 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 { Badge } from '../ui/badge';
7
+ import { safeHref } from '../../utils/safeHref';
8
+ import { formatRelativeTime, formatFullDateTime } from '../../utils/formatRelativeTime';
9
+
10
+ interface Bookmark {
11
+ id: string;
12
+ title: string;
13
+ url: string;
14
+ slug: string;
15
+ forwarding_enabled: boolean;
16
+ folders?: Array<{ id: string; name: string; icon?: string | null; shared_teams?: Array<{ id: string; name: string }>; shared_users?: Array<{ id: string; name: string; email: string }> }>;
17
+ tags?: Array<{ id: string; name: string }>;
18
+ shared_teams?: Array<{ id: string; name: string }>;
19
+ shared_users?: Array<{ id: string; name: string; email: string }>;
20
+ bookmark_type?: 'own' | 'shared';
21
+ pinned?: boolean;
22
+ access_count?: number;
23
+ last_accessed_at?: string | null;
24
+ }
25
+
26
+ interface BookmarkCardProps {
27
+ bookmark: Bookmark;
28
+ compact: boolean;
29
+ selected: boolean;
30
+ onSelect: () => void;
31
+ onEdit: () => void;
32
+ onDelete: () => void;
33
+ onCopyUrl: () => void;
34
+ onShare?: () => void;
35
+ onOpen?: () => void;
36
+ onPinToggle?: () => void;
37
+ bulkMode: boolean;
38
+ t: any;
39
+ }
40
+
41
+ export default function BookmarkCard({
42
+ bookmark,
43
+ compact,
44
+ selected,
45
+ onSelect,
46
+ onEdit,
47
+ onDelete,
48
+ onCopyUrl,
49
+ onShare,
50
+ onOpen,
51
+ onPinToggle,
52
+ bulkMode,
53
+ t,
54
+ }: BookmarkCardProps) {
55
+ const totalSharedTeams = (bookmark.shared_teams?.length || 0) +
56
+ (bookmark.folders?.reduce((sum, f) => sum + (f.shared_teams?.length || 0), 0) || 0);
57
+ const totalSharedUsers = (bookmark.shared_users?.length || 0) +
58
+ (bookmark.folders?.reduce((sum, f) => sum + (f.shared_users?.length || 0), 0) || 0);
59
+ const isShared = totalSharedTeams > 0 || totalSharedUsers > 0;
60
+
61
+ function handleCardClick(e: React.MouseEvent) {
62
+ if (bulkMode) return;
63
+ const target = e.target as HTMLElement;
64
+ if (target.closest('button') || target.closest('a') || target.closest('[data-card-action]')) return;
65
+ if (onOpen) onOpen();
66
+ else window.open(bookmark.url, '_blank', 'noopener,noreferrer');
67
+ }
68
+
69
+ function handleCardKeyDown(e: React.KeyboardEvent) {
70
+ if (e.key === 'Enter' || e.key === ' ') {
71
+ e.preventDefault();
72
+ if (bulkMode) return;
73
+ if (onOpen) onOpen();
74
+ else window.open(bookmark.url, '_blank', 'noopener,noreferrer');
75
+ }
76
+ }
77
+
78
+ return (
79
+ <div
80
+ role="button"
81
+ tabIndex={0}
82
+ onClick={handleCardClick}
83
+ onKeyDown={handleCardKeyDown}
84
+ className={`group bg-card rounded-lg border ${
85
+ selected
86
+ ? '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]'}`}
89
+ >
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`}>
106
+ {bookmark.title}
107
+ </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>
146
+ </div>
147
+ </div>
148
+
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>
169
+ ))}
170
+ </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
+ )}
185
+ </div>
186
+
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
+ </>
216
+ ) : (
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>
221
+ )}
222
+ </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>
233
+ ) : (
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')}
251
+ >
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
+ <Button
282
+ variant="ghost"
283
+ 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}
290
+ />
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>
297
+ )}
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>
311
+ );
312
+ }