@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,1004 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { Link, useSearchParams } from 'react-router-dom';
4
+ import { useAuth } from '../contexts/AuthContext';
5
+ import api from '../api/client';
6
+ import ConfirmDialog from '../components/ui/ConfirmDialog';
7
+ import { useConfirmDialog } from '../hooks/useConfirmDialog';
8
+ import { useToast } from '../components/ui/Toast';
9
+ import { Plus, LayoutGrid, List, CheckSquare, Download, Upload, Bookmark as BookmarkIcon, ExternalLink, FolderPlus, Tag as TagIcon, Share2, Trash2, Copy, ChevronLeft, ChevronRight, Pin, Search } from 'lucide-react';
10
+ import BookmarkModal from '../components/modals/BookmarkModal';
11
+ import ImportModal from '../components/modals/ImportModal';
12
+ import ShareResourceDialog from '../components/sharing/ShareResourceDialog';
13
+ import Button from '../components/ui/Button';
14
+ import Select from '../components/ui/Select';
15
+ import BookmarkCard from '../components/bookmarks/BookmarkCard';
16
+ import BookmarkTableView from '../components/bookmarks/BookmarkTableView';
17
+ import { BulkMoveModal, BulkTagModal, BulkShareModal } from '../components/bookmarks/BulkActionModals';
18
+ import { FilterChips, type FilterKey } from '../components/bookmarks/FilterChips';
19
+ import { ScopeSegmentedControl } from '../components/ScopeSegmentedControl';
20
+ import { PageLoadingSkeleton } from '../components/ui/PageLoadingSkeleton';
21
+ import { Card } from '../components/ui/card';
22
+ import { PageHeader } from '../components/PageHeader';
23
+ import { useSidebar } from '../components/ui/sidebar';
24
+ import { useAppConfig } from '../contexts/AppConfigContext';
25
+
26
+ interface Bookmark {
27
+ id: string;
28
+ title: string;
29
+ url: string;
30
+ slug: string;
31
+ forwarding_enabled: boolean;
32
+ /** Owner's user_key for canonical forwarding URL (own or shared) */
33
+ owner_user_key?: string;
34
+ 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 }> }>;
35
+ tags?: Array<{ id: string; name: string }>;
36
+ shared_teams?: Array<{ id: string; name: string }>;
37
+ shared_users?: Array<{ id: string; name: string; email: string }>;
38
+ bookmark_type?: 'own' | 'shared';
39
+ created_at?: string;
40
+ access_count?: number;
41
+ last_accessed_at?: string | null;
42
+ pinned?: boolean;
43
+ }
44
+
45
+ type ViewMode = 'card' | 'list';
46
+ type SortOption = 'recently_added' | 'alphabetical' | 'most_used' | 'recently_accessed';
47
+
48
+ export default function Bookmarks() {
49
+ const { t } = useTranslation();
50
+ const { appBasePath } = useAppConfig();
51
+ const { user } = useAuth();
52
+ const { isMobile, state: sidebarState } = useSidebar();
53
+ const [searchParams, setSearchParams] = useSearchParams();
54
+ const { showConfirm, dialogState } = useConfirmDialog();
55
+ const { showToast } = useToast();
56
+ const [bookmarks, setBookmarks] = useState<Bookmark[]>([]);
57
+ const [folders, setFolders] = useState<any[]>([]);
58
+ const [tags, setTags] = useState<any[]>([]);
59
+ const [teams, setTeams] = useState<any[]>([]);
60
+ const [loading, setLoading] = useState(true);
61
+ const [viewMode, setViewMode] = useState<ViewMode>(() => {
62
+ const saved = localStorage.getItem('bookmarks-view-mode');
63
+ return (saved === 'list' || saved === 'card') ? saved : 'card';
64
+ });
65
+ const [compactMode, setCompactMode] = useState(() => {
66
+ return localStorage.getItem('bookmarks-compact-mode') === 'true';
67
+ });
68
+ const [sortBy, setSortBy] = useState<SortOption>('recently_added');
69
+ const [selectedBookmarks, setSelectedBookmarks] = useState<Set<string>>(new Set());
70
+ const [allSelectedAcrossPages, setAllSelectedAcrossPages] = useState(false);
71
+ const [bulkMode, setBulkMode] = useState(false);
72
+ const [bulkMoveModalOpen, setBulkMoveModalOpen] = useState(false);
73
+ const [bulkTagModalOpen, setBulkTagModalOpen] = useState(false);
74
+ const [bulkShareModalOpen, setBulkShareModalOpen] = useState(false);
75
+ const [importModalOpen, setImportModalOpen] = useState(false);
76
+ const [searchInputValue, setSearchInputValue] = useState('');
77
+
78
+ const selectedFolder = searchParams.get('folder_id') || '';
79
+ const selectedTag = searchParams.get('tag_id') || '';
80
+ const scopeParam = searchParams.get('scope');
81
+ const scope = (scopeParam === 'mine' || scopeParam === 'shared_with_me' || scopeParam === 'shared_by_me' || scopeParam === 'shared')
82
+ ? (scopeParam === 'shared' ? 'shared_with_me' : scopeParam)
83
+ : 'all';
84
+ const pinnedFilter = searchParams.get('pinned') === 'true';
85
+ const searchQuery = searchParams.get('q') || '';
86
+ const [modalOpen, setModalOpen] = useState(false);
87
+ const [editingBookmark, setEditingBookmark] = useState<Bookmark | null>(null);
88
+ const [shareDialogOpen, setShareDialogOpen] = useState(false);
89
+ const [sharingBookmark, setSharingBookmark] = useState<Bookmark | null>(null);
90
+ const [page, setPage] = useState(0);
91
+ const [total, setTotal] = useState(0);
92
+ const PAGE_SIZE_OPTIONS = [50, 100, 200, 500] as const;
93
+ const limitParam = searchParams.get('limit');
94
+ const pageSize = (limitParam && PAGE_SIZE_OPTIONS.includes(Number(limitParam) as typeof PAGE_SIZE_OPTIONS[number]))
95
+ ? Number(limitParam)
96
+ : 50;
97
+
98
+ useEffect(() => {
99
+ setPage(0);
100
+ setAllSelectedAcrossPages(false);
101
+ }, [selectedFolder, selectedTag, sortBy, scope, pinnedFilter, searchQuery, pageSize]);
102
+
103
+ useEffect(() => {
104
+ loadData();
105
+ }, [selectedFolder, selectedTag, sortBy, page, scope, pinnedFilter, searchQuery, pageSize]);
106
+
107
+ // Handle query params from GlobalSearch and dashboard Edit link
108
+ useEffect(() => {
109
+ const createParam = searchParams.get('create');
110
+ const importParam = searchParams.get('import');
111
+ const exportParam = searchParams.get('export');
112
+ const editId = searchParams.get('edit');
113
+
114
+ if (createParam === 'true') {
115
+ handleCreate();
116
+ const params = new URLSearchParams(searchParams);
117
+ params.delete('create');
118
+ setSearchParams(params, { replace: true });
119
+ } else if (importParam === 'true') {
120
+ setImportModalOpen(true);
121
+ const params = new URLSearchParams(searchParams);
122
+ params.delete('import');
123
+ setSearchParams(params, { replace: true });
124
+ } else if (exportParam === 'true') {
125
+ handleExport();
126
+ const params = new URLSearchParams(searchParams);
127
+ params.delete('export');
128
+ setSearchParams(params, { replace: true });
129
+ } else if (editId) {
130
+ api.get(`/bookmarks/${editId}`)
131
+ .then((res) => {
132
+ setEditingBookmark(res.data);
133
+ setModalOpen(true);
134
+ })
135
+ .catch((err: any) => {
136
+ const status = err.response?.status;
137
+ if (status === 403 || status === 404) {
138
+ showToast(t('common.notFoundOrNoAccess'), 'error');
139
+ }
140
+ });
141
+ const params = new URLSearchParams(searchParams);
142
+ params.delete('edit');
143
+ setSearchParams(params, { replace: true });
144
+ }
145
+ }, [searchParams]);
146
+
147
+ function handleExport() {
148
+ api.get('/bookmarks/export')
149
+ .then(response => {
150
+ const dataStr = JSON.stringify(response.data, null, 2);
151
+ const dataBlob = new Blob([dataStr], { type: 'application/json' });
152
+ const url = URL.createObjectURL(dataBlob);
153
+ const link = document.createElement('a');
154
+ link.href = url;
155
+ link.download = `slugbase-bookmarks-${new Date().toISOString().split('T')[0]}.json`;
156
+ document.body.appendChild(link);
157
+ link.click();
158
+ document.body.removeChild(link);
159
+ URL.revokeObjectURL(url);
160
+ showToast(t('common.success'), 'success');
161
+ })
162
+ .catch(error => {
163
+ console.error('Export failed:', error);
164
+ showToast(t('common.error'), 'error');
165
+ });
166
+ }
167
+
168
+ useEffect(() => {
169
+ localStorage.setItem('bookmarks-view-mode', viewMode);
170
+ }, [viewMode]);
171
+
172
+ useEffect(() => {
173
+ localStorage.setItem('bookmarks-compact-mode', compactMode.toString());
174
+ }, [compactMode]);
175
+
176
+ useEffect(() => {
177
+ setSearchInputValue(searchQuery);
178
+ }, [searchQuery]);
179
+
180
+ async function loadData() {
181
+ try {
182
+ const [bookmarksRes, foldersRes, tagsRes, teamsRes] = await Promise.all([
183
+ api.get('/bookmarks', {
184
+ params: {
185
+ folder_id: selectedFolder || undefined,
186
+ tag_id: selectedTag || undefined,
187
+ sort_by: sortBy,
188
+ limit: pageSize,
189
+ offset: page * pageSize,
190
+ scope: scope !== 'all' ? scope : undefined,
191
+ pinned: pinnedFilter ? 'true' : undefined,
192
+ q: searchQuery.trim() || undefined,
193
+ },
194
+ }),
195
+ api.get('/folders'),
196
+ api.get('/tags'),
197
+ api.get('/teams'),
198
+ ]);
199
+ const payload = bookmarksRes.data;
200
+ const items = payload.items ?? [];
201
+ setTotal(payload.total ?? 0);
202
+ setBookmarks(items);
203
+ setFolders(foldersRes.data);
204
+ setTags(tagsRes.data);
205
+ setTeams(teamsRes.data);
206
+ } catch (error) {
207
+ console.error('Failed to load data:', error);
208
+ } finally {
209
+ setLoading(false);
210
+ }
211
+ }
212
+
213
+ const displayedBookmarks = bookmarks;
214
+
215
+ const hasActiveFilters =
216
+ !!selectedFolder ||
217
+ !!selectedTag ||
218
+ scope !== 'all' ||
219
+ pinnedFilter ||
220
+ !!searchQuery.trim() ||
221
+ sortBy !== 'recently_added';
222
+
223
+ function updateParams(updates: Record<string, string | undefined>) {
224
+ const params = new URLSearchParams(searchParams);
225
+ (Object.entries(updates) as [string, string | undefined][]).forEach(([k, v]) => {
226
+ if (v === undefined || v === '') params.delete(k);
227
+ else params.set(k, v);
228
+ });
229
+ setSearchParams(params);
230
+ }
231
+
232
+ function handleRemoveFilter(key: FilterKey) {
233
+ if (key === 'folder_id') updateParams({ folder_id: undefined });
234
+ else if (key === 'tag_id') updateParams({ tag_id: undefined });
235
+ else if (key === 'sort') setSortBy('recently_added');
236
+ else if (key === 'q') updateParams({ q: undefined });
237
+ else if (key === 'pinned') updateParams({ pinned: undefined });
238
+ else if (key === 'scope') updateParams({ scope: undefined });
239
+ }
240
+
241
+ function handleCreate() {
242
+ setEditingBookmark(null);
243
+ setModalOpen(true);
244
+ }
245
+
246
+ function handleEdit(bookmark: Bookmark) {
247
+ setEditingBookmark(bookmark);
248
+ setModalOpen(true);
249
+ }
250
+
251
+ async function handlePinToggle(bookmark: Bookmark) {
252
+ if (bookmark.bookmark_type !== 'own') return;
253
+ try {
254
+ await api.put(`/bookmarks/${bookmark.id}`, { pinned: !bookmark.pinned });
255
+ loadData();
256
+ showToast(bookmark.pinned ? t('bookmarks.unpinned') : t('bookmarks.pinned'), 'success');
257
+ } catch {
258
+ showToast(t('common.error'), 'error');
259
+ }
260
+ }
261
+
262
+ function handleDelete(id: string, name?: string) {
263
+ const bookmark = bookmarks.find(b => b.id === id);
264
+ const bookmarkName = name || bookmark?.title || 'this bookmark';
265
+ showConfirm(
266
+ t('bookmarks.deleteBookmark'),
267
+ t('bookmarks.deleteConfirmWithName', { name: bookmarkName }),
268
+ async () => {
269
+ try {
270
+ await api.delete(`/bookmarks/${id}`);
271
+ loadData();
272
+ setSelectedBookmarks(prev => {
273
+ const next = new Set(prev);
274
+ next.delete(id);
275
+ return next;
276
+ });
277
+ showToast(t('common.success'), 'success');
278
+ } catch (error) {
279
+ console.error('Failed to delete bookmark:', error);
280
+ showToast(t('common.error'), 'error');
281
+ }
282
+ },
283
+ { variant: 'danger', confirmText: t('common.delete'), cancelText: t('common.cancel') }
284
+ );
285
+ }
286
+
287
+ function handleBulkDelete() {
288
+ const count = selectedBookmarks.size;
289
+ showConfirm(
290
+ t('bookmarks.deleteBookmark'),
291
+ t('bookmarks.deleteConfirm').replace('this bookmark', `${count} bookmarks`),
292
+ async () => {
293
+ try {
294
+ await Promise.all(Array.from(selectedBookmarks).map(id => api.delete(`/bookmarks/${id}`)));
295
+ loadData();
296
+ setSelectedBookmarks(new Set());
297
+ setAllSelectedAcrossPages(false);
298
+ setBulkMode(false);
299
+ showToast(t('common.success'), 'success');
300
+ } catch (error) {
301
+ console.error('Failed to delete bookmarks:', error);
302
+ showToast(t('common.error'), 'error');
303
+ }
304
+ },
305
+ { variant: 'danger', confirmText: t('common.delete'), cancelText: t('common.cancel') }
306
+ );
307
+ }
308
+
309
+ async function handleBulkMove(folderIds: string[]) {
310
+ try {
311
+ await Promise.all(Array.from(selectedBookmarks).map(id =>
312
+ api.put(`/bookmarks/${id}`, { folder_ids: folderIds })
313
+ ));
314
+ loadData();
315
+ setSelectedBookmarks(new Set());
316
+ setAllSelectedAcrossPages(false);
317
+ setBulkMoveModalOpen(false);
318
+ showToast(t('common.success'), 'success');
319
+ } catch (error) {
320
+ console.error('Failed to move bookmarks:', error);
321
+ showToast(t('common.error'), 'error');
322
+ }
323
+ }
324
+
325
+ async function handleBulkAddTags(tagIds: string[]) {
326
+ try {
327
+ // Get current tags for each bookmark and merge
328
+ const bookmarkPromises = Array.from(selectedBookmarks).map(async (id) => {
329
+ const bookmark = bookmarks.find(b => b.id === id);
330
+ const currentTagIds = bookmark?.tags?.map(t => t.id) || [];
331
+ const mergedTagIds = [...new Set([...currentTagIds, ...tagIds])];
332
+ return api.put(`/bookmarks/${id}`, { tag_ids: mergedTagIds });
333
+ });
334
+ await Promise.all(bookmarkPromises);
335
+ loadData();
336
+ setSelectedBookmarks(new Set());
337
+ setAllSelectedAcrossPages(false);
338
+ setBulkTagModalOpen(false);
339
+ showToast(t('common.success'), 'success');
340
+ } catch (error) {
341
+ console.error('Failed to add tags:', error);
342
+ showToast(t('common.error'), 'error');
343
+ }
344
+ }
345
+
346
+ async function handleBulkShare(sharing: { team_ids: string[]; user_ids: string[]; share_all_teams: boolean }) {
347
+ try {
348
+ await Promise.all(Array.from(selectedBookmarks).map(id =>
349
+ api.put(`/bookmarks/${id}`, {
350
+ team_ids: sharing.team_ids,
351
+ user_ids: sharing.user_ids,
352
+ share_all_teams: sharing.share_all_teams,
353
+ })
354
+ ));
355
+ loadData();
356
+ setSelectedBookmarks(new Set());
357
+ setAllSelectedAcrossPages(false);
358
+ setBulkShareModalOpen(false);
359
+ showToast(t('common.success'), 'success');
360
+ } catch (error) {
361
+ console.error('Failed to share bookmarks:', error);
362
+ showToast(t('common.error'), 'error');
363
+ }
364
+ }
365
+
366
+ function handleCopyUrl(bookmark: Bookmark) {
367
+ const baseUrl = window.location.origin;
368
+ const url = bookmark.slug ? `${baseUrl}/go/${bookmark.slug}` : '';
369
+ if (url) {
370
+ navigator.clipboard.writeText(url);
371
+ showToast(t('common.copied'), 'success');
372
+ }
373
+ }
374
+
375
+ async function handleOpenBookmark(bookmark: Bookmark) {
376
+ // Track access asynchronously (don't wait for it to complete)
377
+ api.post(`/bookmarks/${bookmark.id}/track-access`).catch((error) => {
378
+ console.error('Failed to track bookmark access:', error);
379
+ // Don't show error to user, just log it
380
+ });
381
+
382
+ // Open the bookmark URL
383
+ window.open(bookmark.url, '_blank', 'noopener,noreferrer');
384
+ }
385
+
386
+ function handleModalClose() {
387
+ setModalOpen(false);
388
+ setEditingBookmark(null);
389
+ loadData();
390
+ }
391
+
392
+ function handleResetFilters() {
393
+ setSearchParams({});
394
+ setSortBy('recently_added');
395
+ }
396
+
397
+ function toggleSelectBookmark(id: string) {
398
+ setSelectedBookmarks(prev => {
399
+ const next = new Set(prev);
400
+ if (next.has(id)) {
401
+ next.delete(id);
402
+ } else {
403
+ next.add(id);
404
+ }
405
+ return next;
406
+ });
407
+ }
408
+
409
+ function toggleSelectAll() {
410
+ if (selectedBookmarks.size === displayedBookmarks.length) {
411
+ setSelectedBookmarks(new Set());
412
+ } else {
413
+ setSelectedBookmarks(new Set(displayedBookmarks.map(b => b.id)));
414
+ }
415
+ }
416
+
417
+ async function handleSelectAllRemaining() {
418
+ try {
419
+ const res = await api.get('/bookmarks/ids', {
420
+ params: {
421
+ folder_id: selectedFolder || undefined,
422
+ tag_id: selectedTag || undefined,
423
+ sort_by: sortBy,
424
+ scope: scope !== 'all' ? scope : undefined,
425
+ pinned: pinnedFilter ? 'true' : undefined,
426
+ q: searchQuery.trim() || undefined,
427
+ },
428
+ });
429
+ const ids = res.data?.ids ?? [];
430
+ setSelectedBookmarks(prev => new Set([...prev, ...ids]));
431
+ setAllSelectedAcrossPages(true);
432
+ } catch (err) {
433
+ console.error('Failed to fetch bookmark IDs:', err);
434
+ showToast(t('common.error'), 'error');
435
+ }
436
+ }
437
+
438
+ function handleDeselectAll() {
439
+ setSelectedBookmarks(new Set());
440
+ setAllSelectedAcrossPages(false);
441
+ }
442
+
443
+ const ALL_FILTER = '__all__';
444
+ const folderOptions = [
445
+ { value: ALL_FILTER, label: t('bookmarks.allFolders') },
446
+ ...folders.map((f) => ({ value: f.id, label: f.name, icon: (f as any).icon })),
447
+ ];
448
+
449
+ const tagOptions = [
450
+ { value: ALL_FILTER, label: t('bookmarks.allTags') },
451
+ ...tags.map((t) => ({ value: t.id, label: t.name })),
452
+ ];
453
+
454
+ const sortOptions = [
455
+ { value: 'recently_added', label: t('bookmarks.sortRecentlyAdded') },
456
+ { value: 'alphabetical', label: t('bookmarks.sortAlphabetical') },
457
+ { value: 'most_used', label: t('bookmarks.sortMostUsed') },
458
+ { value: 'recently_accessed', label: t('bookmarks.sortRecentlyAccessed') },
459
+ ];
460
+
461
+ const filterChips = (() => {
462
+ const list: { key: FilterKey; label: string; ariaLabel: string }[] = [];
463
+ if (selectedFolder) {
464
+ const name = folders.find((f: any) => f.id === selectedFolder)?.name ?? selectedFolder;
465
+ list.push({ key: 'folder_id', label: `${t('bookmarks.folder')}: ${name}`, ariaLabel: t('bookmarks.clearFilters') + ` ${t('bookmarks.folder')}: ${name}` });
466
+ }
467
+ if (selectedTag) {
468
+ const name = tags.find((t: any) => t.id === selectedTag)?.name ?? selectedTag;
469
+ list.push({ key: 'tag_id', label: `${t('bookmarks.tags')}: ${name}`, ariaLabel: t('bookmarks.clearFilters') + ` ${t('bookmarks.tags')}: ${name}` });
470
+ }
471
+ if (sortBy !== 'recently_added') {
472
+ const label = sortOptions.find(o => o.value === sortBy)?.label ?? sortBy;
473
+ list.push({ key: 'sort', label: `Sort: ${label}`, ariaLabel: t('bookmarks.clearFilters') + ' Sort' });
474
+ }
475
+ if (searchQuery.trim()) {
476
+ list.push({ key: 'q', label: `${t('common.search')}: ${searchQuery.trim()}`, ariaLabel: t('bookmarks.clearFilters') + ' ' + t('common.search') });
477
+ }
478
+ if (pinnedFilter) {
479
+ list.push({ key: 'pinned', label: t('bookmarks.pinned'), ariaLabel: t('bookmarks.clearFilters') + ' ' + t('bookmarks.pinned') });
480
+ }
481
+ if (scope === 'mine') {
482
+ list.push({ key: 'scope', label: t('bookmarks.scopeMine'), ariaLabel: t('bookmarks.clearFilters') + ' ' + t('bookmarks.scopeMine') });
483
+ }
484
+ if (scope === 'shared_with_me') {
485
+ list.push({ key: 'scope', label: t('common.scopeSharedWithMe'), ariaLabel: t('bookmarks.clearFilters') + ' ' + t('common.scopeSharedWithMe') });
486
+ }
487
+ if (scope === 'shared_by_me') {
488
+ list.push({ key: 'scope', label: t('common.scopeSharedByMe'), ariaLabel: t('bookmarks.clearFilters') + ' ' + t('common.scopeSharedByMe') });
489
+ }
490
+ return list;
491
+ })();
492
+
493
+ if (loading) {
494
+ return <PageLoadingSkeleton lines={6} />;
495
+ }
496
+
497
+ return (
498
+ <div className="space-y-6 pb-24">
499
+ {/* Sticky controls bar: header + filters/toolbar - stays visible when scrolling */}
500
+ <div className="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 border-b shadow-sm">
501
+ <PageHeader
502
+ className="pt-4"
503
+ title={`${t('bookmarks.title')} (${total})`}
504
+ subtitle={
505
+ hasActiveFilters
506
+ ? t('bookmarks.showingXOfY', { x: displayedBookmarks.length, y: total })
507
+ : undefined
508
+ }
509
+ actions={
510
+ <div className="flex flex-wrap items-center gap-2">
511
+ <ScopeSegmentedControl
512
+ value={scope}
513
+ onChange={(s) => updateParams({ scope: s === 'all' ? undefined : s })}
514
+ options={[
515
+ { value: 'all', label: t('bookmarks.scopeAll') },
516
+ { value: 'mine', label: t('bookmarks.scopeMine') },
517
+ { value: 'shared_with_me', label: t('common.scopeSharedWithMe') },
518
+ { value: 'shared_by_me', label: t('common.scopeSharedByMe') },
519
+ ]}
520
+ ariaLabel={t('bookmarks.scopeAll')}
521
+ />
522
+ <Button
523
+ variant={pinnedFilter ? 'secondary' : 'ghost'}
524
+ size="sm"
525
+ icon={Pin}
526
+ onClick={() => updateParams({ pinned: pinnedFilter ? undefined : 'true' })}
527
+ title={t('bookmarks.pinned')}
528
+ aria-pressed={pinnedFilter}
529
+ >
530
+ <span className="hidden sm:inline">{t('bookmarks.pinned')}</span>
531
+ </Button>
532
+ <Button
533
+ variant="ghost"
534
+ size="sm"
535
+ icon={Upload}
536
+ onClick={() => setImportModalOpen(true)}
537
+ title={t('bookmarks.import')}
538
+ >
539
+ <span className="hidden sm:inline">{t('bookmarks.import')}</span>
540
+ </Button>
541
+ <Button
542
+ variant="ghost"
543
+ size="sm"
544
+ icon={Download}
545
+ onClick={handleExport}
546
+ title={t('bookmarks.export')}
547
+ >
548
+ <span className="hidden sm:inline">{t('bookmarks.export')}</span>
549
+ </Button>
550
+ <Button onClick={handleCreate} icon={Plus}>
551
+ {t('bookmarks.create')}
552
+ </Button>
553
+ </div>
554
+ }
555
+ />
556
+
557
+ <FilterChips
558
+ chips={filterChips}
559
+ onRemove={(key) => handleRemoveFilter(key as FilterKey)}
560
+ onClearAll={handleResetFilters}
561
+ clearAllLabel={t('bookmarks.clearAllFilters')}
562
+ clearAllAriaLabel={t('bookmarks.clearAllFilters')}
563
+ />
564
+
565
+ {/* Toolbar: Search, Filters, Sort, View Modes */}
566
+ <div className="flex flex-wrap items-center gap-3 bg-card rounded-lg border p-4 shadow-sm">
567
+ <div className="flex items-center gap-2 min-w-[200px] flex-1">
568
+ <Search className="h-4 w-4 text-muted-foreground flex-shrink-0" aria-hidden />
569
+ <input
570
+ type="search"
571
+ value={searchInputValue}
572
+ onChange={(e) => setSearchInputValue(e.target.value)}
573
+ onKeyDown={(e) => {
574
+ if (e.key === 'Enter') updateParams({ q: (e.target as HTMLInputElement).value.trim() || undefined });
575
+ }}
576
+ placeholder={t('common.searchPlaceholder')}
577
+ 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"
578
+ aria-label={t('common.searchPlaceholder')}
579
+ />
580
+ </div>
581
+ {/* Filters */}
582
+ <div className="flex flex-wrap gap-3 flex-1 min-w-[200px]">
583
+ <div className="flex-1 min-w-[180px]">
584
+ <Select
585
+ value={selectedFolder || ALL_FILTER}
586
+ onChange={(value) => {
587
+ const params = new URLSearchParams(searchParams);
588
+ if (value && value !== ALL_FILTER) {
589
+ params.set('folder_id', value);
590
+ } else {
591
+ params.delete('folder_id');
592
+ }
593
+ setSearchParams(params);
594
+ }}
595
+ options={folderOptions}
596
+ placeholder={t('bookmarks.filterByFolder')}
597
+ />
598
+ </div>
599
+ <div className="flex-1 min-w-[180px]">
600
+ <Select
601
+ value={selectedTag || ALL_FILTER}
602
+ onChange={(value) => {
603
+ const params = new URLSearchParams(searchParams);
604
+ if (value && value !== ALL_FILTER) {
605
+ params.set('tag_id', value);
606
+ } else {
607
+ params.delete('tag_id');
608
+ }
609
+ setSearchParams(params);
610
+ }}
611
+ options={tagOptions}
612
+ placeholder={t('bookmarks.filterByTag')}
613
+ />
614
+ </div>
615
+ </div>
616
+
617
+ {/* Sort */}
618
+ <div className="flex items-center gap-2">
619
+ <Select
620
+ value={sortBy}
621
+ onChange={(value) => setSortBy(value as SortOption)}
622
+ options={sortOptions}
623
+ className="min-w-[160px]"
624
+ />
625
+ </div>
626
+
627
+ {/* Page size */}
628
+ <div className="flex items-center gap-2">
629
+ <Select
630
+ value={String(pageSize)}
631
+ onChange={(value) => {
632
+ updateParams({ limit: value });
633
+ setPage(0);
634
+ }}
635
+ options={PAGE_SIZE_OPTIONS.map((n) => ({ value: String(n), label: String(n) }))}
636
+ className="min-w-[80px]"
637
+ />
638
+ <span className="text-sm text-muted-foreground whitespace-nowrap">{t('bookmarks.perPage')}</span>
639
+ </div>
640
+
641
+ {/* View Mode Toggle */}
642
+ <div className="flex items-center gap-2 border-l border-gray-200 dark:border-gray-700 pl-3">
643
+ <div className="flex items-center gap-1 bg-gray-100 dark:bg-gray-700 rounded-lg p-1">
644
+ <button
645
+ onClick={() => setViewMode('card')}
646
+ className={`p-1.5 rounded transition-colors ${
647
+ viewMode === 'card'
648
+ ? 'bg-card text-primary shadow-sm'
649
+ : 'text-muted-foreground hover:text-foreground'
650
+ }`}
651
+ title={t('bookmarks.viewCard')}
652
+ >
653
+ <LayoutGrid className="h-4 w-4" />
654
+ </button>
655
+ <button
656
+ onClick={() => setViewMode('list')}
657
+ className={`p-1.5 rounded transition-colors ${
658
+ viewMode === 'list'
659
+ ? 'bg-card text-primary shadow-sm'
660
+ : 'text-muted-foreground hover:text-foreground'
661
+ }`}
662
+ title={t('bookmarks.viewList')}
663
+ >
664
+ <List className="h-4 w-4" />
665
+ </button>
666
+ </div>
667
+ <button
668
+ onClick={() => setCompactMode(!compactMode)}
669
+ className={`px-3 py-1.5 text-sm rounded-lg transition-colors ${
670
+ compactMode
671
+ ? 'bg-primary/20 text-primary'
672
+ : 'bg-muted text-muted-foreground hover:bg-accent'
673
+ }`}
674
+ title={t('bookmarks.compactMode')}
675
+ >
676
+ {t('bookmarks.compactMode')}
677
+ </button>
678
+ </div>
679
+
680
+ {/* Bulk Select Toggle */}
681
+ {!bulkMode && displayedBookmarks.length > 0 && (
682
+ <div className="flex items-center gap-2 border-l border-gray-200 dark:border-gray-700 pl-3">
683
+ <Button
684
+ variant="ghost"
685
+ size="sm"
686
+ icon={CheckSquare}
687
+ onClick={() => setBulkMode(true)}
688
+ >
689
+ {t('bookmarks.bulkSelect')}
690
+ </Button>
691
+ </div>
692
+ )}
693
+ </div>
694
+ </div>
695
+
696
+ {/* Bulk Actions Bar - sticky bottom, visible when selecting */}
697
+ {bulkMode && (
698
+ <div
699
+ className="fixed bottom-0 right-0 z-50 flex items-center justify-between bg-background border-t-2 border-primary shadow-[0_-4px_12px_rgba(0,0,0,0.08)] dark:shadow-[0_-4px_12px_rgba(0,0,0,0.3)] p-4"
700
+ style={
701
+ !isMobile
702
+ ? { left: sidebarState === 'expanded' ? '16rem' : '3rem' }
703
+ : { left: 0 }
704
+ }
705
+ >
706
+ <div className="flex items-center gap-3">
707
+ {(allSelectedAcrossPages || selectedBookmarks.size === total) && total > 0 ? (
708
+ <>
709
+ <span className="text-sm text-gray-700 dark:text-gray-300">
710
+ {t('bookmarks.allSelected', { total })}
711
+ </span>
712
+ <button
713
+ onClick={handleDeselectAll}
714
+ className="text-sm text-primary hover:text-primary/90"
715
+ >
716
+ {t('bookmarks.deselectAll')}
717
+ </button>
718
+ </>
719
+ ) : selectedBookmarks.size === displayedBookmarks.length && displayedBookmarks.length > 0 && total > pageSize ? (
720
+ <>
721
+ <span className="text-sm text-gray-700 dark:text-gray-300">
722
+ {t('bookmarks.selectAllOnPageAndRemaining', { pageCount: displayedBookmarks.length, total })}
723
+ </span>
724
+ <button
725
+ onClick={handleSelectAllRemaining}
726
+ className="text-sm text-primary hover:text-primary/90"
727
+ >
728
+ {t('bookmarks.selectAllRemaining', { total })}
729
+ </button>
730
+ </>
731
+ ) : (
732
+ <>
733
+ <span className="text-sm font-medium text-foreground">
734
+ {t('bookmarks.selectedCount', { count: selectedBookmarks.size })}
735
+ </span>
736
+ <button
737
+ onClick={toggleSelectAll}
738
+ className="text-sm text-primary hover:text-primary/90"
739
+ >
740
+ {selectedBookmarks.size === displayedBookmarks.length ? t('bookmarks.deselectAll') : t('bookmarks.selectAll')}
741
+ </button>
742
+ </>
743
+ )}
744
+ </div>
745
+ <div className="flex items-center gap-2">
746
+ <Button
747
+ variant="secondary"
748
+ size="sm"
749
+ icon={FolderPlus}
750
+ onClick={() => setBulkMoveModalOpen(true)}
751
+ disabled={selectedBookmarks.size === 0}
752
+ >
753
+ {t('bookmarks.bulkMoveToFolder')}
754
+ </Button>
755
+ <Button
756
+ variant="secondary"
757
+ size="sm"
758
+ icon={TagIcon}
759
+ onClick={() => setBulkTagModalOpen(true)}
760
+ disabled={selectedBookmarks.size === 0}
761
+ >
762
+ {t('bookmarks.bulkAddTags')}
763
+ </Button>
764
+ <Button
765
+ variant="secondary"
766
+ size="sm"
767
+ icon={Share2}
768
+ onClick={() => setBulkShareModalOpen(true)}
769
+ disabled={selectedBookmarks.size === 0}
770
+ >
771
+ {t('bookmarks.bulkShare')}
772
+ </Button>
773
+ <Button
774
+ variant="danger"
775
+ size="sm"
776
+ icon={Trash2}
777
+ onClick={handleBulkDelete}
778
+ disabled={selectedBookmarks.size === 0}
779
+ >
780
+ {t('bookmarks.bulkDelete')}
781
+ </Button>
782
+ <Button
783
+ variant="ghost"
784
+ size="sm"
785
+ onClick={() => {
786
+ setBulkMode(false);
787
+ setSelectedBookmarks(new Set());
788
+ setAllSelectedAcrossPages(false);
789
+ }}
790
+ >
791
+ {t('common.cancel')}
792
+ </Button>
793
+ </div>
794
+ </div>
795
+ )}
796
+
797
+ {/* Bookmarks Display */}
798
+ {displayedBookmarks.length === 0 ? (
799
+ <Card className="flex flex-col items-center justify-center py-20 px-4">
800
+ <div className="w-16 h-16 rounded-full bg-primary/20 flex items-center justify-center mb-4">
801
+ <BookmarkIcon className="h-8 w-8 text-primary" />
802
+ </div>
803
+ <h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
804
+ {hasActiveFilters ? t('bookmarks.noMatches') : t('bookmarks.empty')}
805
+ </h3>
806
+ <p className="text-sm text-gray-600 dark:text-gray-400 mb-6 text-center max-w-md">
807
+ {hasActiveFilters ? '' : t('bookmarks.emptyDescription')}
808
+ </p>
809
+ <div className="flex flex-wrap gap-3 justify-center">
810
+ {hasActiveFilters ? (
811
+ <Button onClick={handleResetFilters} variant="primary">
812
+ {t('bookmarks.clearFilters')}
813
+ </Button>
814
+ ) : (
815
+ <>
816
+ <Button onClick={handleCreate} variant="primary" icon={Plus}>
817
+ {t('bookmarks.emptyCreateFirst')}
818
+ </Button>
819
+ <Button
820
+ variant="secondary"
821
+ icon={Upload}
822
+ onClick={() => setImportModalOpen(true)}
823
+ >
824
+ {t('bookmarks.emptyImport')}
825
+ </Button>
826
+ <Link to={`${appBasePath}/search-engine-guide`}>
827
+ <Button variant="ghost" icon={ExternalLink}>
828
+ {t('bookmarks.emptyLearnForwarding')}
829
+ </Button>
830
+ </Link>
831
+ </>
832
+ )}
833
+ </div>
834
+ </Card>
835
+ ) : viewMode === 'card' ? (
836
+ <div className={`grid grid-cols-1 gap-3 items-stretch ${
837
+ compactMode
838
+ ? 'sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-8'
839
+ : 'sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6'
840
+ }`}>
841
+ {displayedBookmarks.map((bookmark) => (
842
+ <BookmarkCard
843
+ key={bookmark.id}
844
+ bookmark={bookmark}
845
+ compact={compactMode}
846
+ selected={selectedBookmarks.has(bookmark.id)}
847
+ onSelect={() => toggleSelectBookmark(bookmark.id)}
848
+ onEdit={() => handleEdit(bookmark)}
849
+ onDelete={() => handleDelete(bookmark.id, bookmark.title)}
850
+ onCopyUrl={() => handleCopyUrl(bookmark)}
851
+ onShare={() => { setSharingBookmark(bookmark); setShareDialogOpen(true); }}
852
+ onOpen={() => handleOpenBookmark(bookmark)}
853
+ onPinToggle={bookmark.bookmark_type === 'own' ? () => handlePinToggle(bookmark) : undefined}
854
+ bulkMode={bulkMode}
855
+ t={t}
856
+ />
857
+ ))}
858
+ </div>
859
+ ) : viewMode === 'list' ? (
860
+ <BookmarkTableView
861
+ bookmarks={displayedBookmarks}
862
+ selectedBookmarks={selectedBookmarks}
863
+ onSelect={toggleSelectBookmark}
864
+ onSelectAll={toggleSelectAll}
865
+ onEdit={handleEdit}
866
+ onDelete={handleDelete}
867
+ onCopyUrl={handleCopyUrl}
868
+ onShare={(bookmark) => { setSharingBookmark(bookmark); setShareDialogOpen(true); }}
869
+ onOpen={handleOpenBookmark}
870
+ bulkMode={bulkMode}
871
+ user={user}
872
+ t={t}
873
+ compact={compactMode}
874
+ />
875
+ ) : null}
876
+
877
+ {/* Pagination */}
878
+ {total > pageSize && displayedBookmarks.length > 0 && (
879
+ <div className="flex items-center justify-between gap-4 mt-6 py-4 border-t border-gray-200 dark:border-gray-700">
880
+ <p className="text-sm text-gray-600 dark:text-gray-400">
881
+ {t('bookmarks.paginationShowing', {
882
+ from: page * pageSize + 1,
883
+ to: Math.min(page * pageSize + displayedBookmarks.length, total),
884
+ total,
885
+ })}
886
+ </p>
887
+ <div className="flex gap-2">
888
+ <Button
889
+ variant="secondary"
890
+ size="sm"
891
+ icon={ChevronLeft}
892
+ onClick={() => setPage((p) => Math.max(0, p - 1))}
893
+ disabled={page === 0}
894
+ >
895
+ {t('bookmarks.paginationPrevious')}
896
+ </Button>
897
+ <Button
898
+ variant="secondary"
899
+ size="sm"
900
+ icon={ChevronRight}
901
+ iconPosition="right"
902
+ onClick={() => setPage((p) => p + 1)}
903
+ disabled={page * pageSize + displayedBookmarks.length >= total}
904
+ >
905
+ {t('bookmarks.paginationNext')}
906
+ </Button>
907
+ </div>
908
+ </div>
909
+ )}
910
+
911
+ {/* Search Engine Setup Guide Note */}
912
+ {displayedBookmarks.length > 0 && (
913
+ <Card className="mt-8 bg-primary/5 border-primary/20 p-4">
914
+ <div className="flex items-start gap-3">
915
+ <div className="flex-shrink-0">
916
+ <div className="h-8 w-8 rounded-lg bg-primary/20 flex items-center justify-center">
917
+ <Copy className="h-4 w-4 text-primary" />
918
+ </div>
919
+ </div>
920
+ <div className="flex-1 min-w-0">
921
+ <p className="text-sm text-gray-700 dark:text-gray-300">
922
+ {t('bookmarks.searchEngineNote')}{' '}
923
+ <Link
924
+ to={`${appBasePath}/search-engine-guide`}
925
+ className="text-primary hover:text-primary/90 font-medium underline"
926
+ >
927
+ {t('bookmarks.searchEngineGuideLink')}
928
+ </Link>
929
+ </p>
930
+ </div>
931
+ </div>
932
+ </Card>
933
+ )}
934
+
935
+ <BookmarkModal
936
+ bookmark={editingBookmark}
937
+ folders={folders}
938
+ tags={tags}
939
+ isOpen={modalOpen}
940
+ onClose={handleModalClose}
941
+ onTagCreated={(newTag) => {
942
+ setTags([...tags, newTag]);
943
+ }}
944
+ />
945
+
946
+ {sharingBookmark && (
947
+ <ShareResourceDialog
948
+ resourceType="bookmark"
949
+ resourceId={sharingBookmark.id}
950
+ resourceName={sharingBookmark.title}
951
+ isOpen={shareDialogOpen}
952
+ onClose={() => { setShareDialogOpen(false); setSharingBookmark(null); }}
953
+ onSuccess={loadData}
954
+ />
955
+ )}
956
+
957
+ <ImportModal
958
+ isOpen={importModalOpen}
959
+ onClose={() => setImportModalOpen(false)}
960
+ onSuccess={() => {
961
+ loadData();
962
+ }}
963
+ />
964
+
965
+ {/* Bulk Move Modal */}
966
+ {bulkMoveModalOpen && (
967
+ <BulkMoveModal
968
+ isOpen={bulkMoveModalOpen}
969
+ onClose={() => setBulkMoveModalOpen(false)}
970
+ onSave={handleBulkMove}
971
+ folders={folders}
972
+ t={t}
973
+ />
974
+ )}
975
+
976
+ {/* Bulk Tag Modal */}
977
+ {bulkTagModalOpen && (
978
+ <BulkTagModal
979
+ isOpen={bulkTagModalOpen}
980
+ onClose={() => setBulkTagModalOpen(false)}
981
+ onSave={handleBulkAddTags}
982
+ tags={tags}
983
+ onTagCreated={(newTag) => setTags([...tags, newTag])}
984
+ t={t}
985
+ />
986
+ )}
987
+
988
+ {/* Bulk Share Modal */}
989
+ {bulkShareModalOpen && (
990
+ <BulkShareModal
991
+ isOpen={bulkShareModalOpen}
992
+ onClose={() => setBulkShareModalOpen(false)}
993
+ onSave={handleBulkShare}
994
+ teams={teams}
995
+ t={t}
996
+ />
997
+ )}
998
+
999
+ <ConfirmDialog {...dialogState} />
1000
+ </div>
1001
+ );
1002
+ }
1003
+
1004
+