@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.
- package/frontend/index.tsx +3 -3
- package/frontend/public/favicon.svg +1 -0
- package/frontend/public/slugbase_icon_blue.svg +1 -0
- package/frontend/public/slugbase_icon_white.png +0 -0
- package/frontend/public/slugbase_icon_white.svg +1 -0
- package/frontend/src/App.tsx +179 -0
- package/frontend/src/api/client.ts +134 -0
- package/frontend/src/components/AppSidebar.tsx +214 -0
- package/frontend/src/components/EmptyState.tsx +33 -0
- package/frontend/src/components/Favicon.tsx +76 -0
- package/frontend/src/components/FilterChips.tsx +60 -0
- package/frontend/src/components/FolderIcon.tsx +207 -0
- package/frontend/src/components/GlobalSearch.tsx +275 -0
- package/frontend/src/components/Layout.tsx +60 -0
- package/frontend/src/components/PageHeader.tsx +31 -0
- package/frontend/src/components/ScopeSegmentedControl.tsx +42 -0
- package/frontend/src/components/SentryDebug.tsx +32 -0
- package/frontend/src/components/StatCard.tsx +66 -0
- package/frontend/src/components/TopBar.tsx +63 -0
- package/frontend/src/components/UserDropdown.tsx +86 -0
- package/frontend/src/components/admin/AdminAI.tsx +207 -0
- package/frontend/src/components/admin/AdminOIDCProviders.tsx +183 -0
- package/frontend/src/components/admin/AdminSettings.tsx +413 -0
- package/frontend/src/components/admin/AdminTeams.tsx +177 -0
- package/frontend/src/components/admin/AdminUsers.tsx +225 -0
- package/frontend/src/components/bookmarks/BookmarkCard.tsx +312 -0
- package/frontend/src/components/bookmarks/BookmarkListItem.tsx +159 -0
- package/frontend/src/components/bookmarks/BookmarkTableView.tsx +419 -0
- package/frontend/src/components/bookmarks/BulkActionModals.tsx +162 -0
- package/frontend/src/components/bookmarks/FilterChips.tsx +5 -0
- package/frontend/src/components/modals/BookmarkModal.tsx +493 -0
- package/frontend/src/components/modals/FolderModal.tsx +306 -0
- package/frontend/src/components/modals/ImportModal.tsx +232 -0
- package/frontend/src/components/modals/OIDCProviderModal.tsx +284 -0
- package/frontend/src/components/modals/SharingModal.tsx +96 -0
- package/frontend/src/components/modals/TagModal.tsx +101 -0
- package/frontend/src/components/modals/TeamAssignmentModal.tsx +354 -0
- package/frontend/src/components/modals/TeamModal.tsx +117 -0
- package/frontend/src/components/modals/UserModal.tsx +225 -0
- package/frontend/src/components/profile/CreateTokenModal.tsx +172 -0
- package/frontend/src/components/sharing/ShareResourceDialog.tsx +422 -0
- package/frontend/src/components/ui/Autocomplete.tsx +155 -0
- package/frontend/src/components/ui/Button.tsx +68 -0
- package/frontend/src/components/ui/ConfirmDialog.tsx +79 -0
- package/frontend/src/components/ui/FormFieldWrapper.tsx +36 -0
- package/frontend/src/components/ui/ModalFooterActions.tsx +49 -0
- package/frontend/src/components/ui/ModalSection.tsx +34 -0
- package/frontend/src/components/ui/PageLoadingSkeleton.tsx +24 -0
- package/frontend/src/components/ui/Select.tsx +61 -0
- package/frontend/src/components/ui/SharingField.tsx +298 -0
- package/frontend/src/components/ui/Toast.tsx +47 -0
- package/frontend/src/components/ui/Tooltip.tsx +21 -0
- package/frontend/src/components/ui/alert-dialog.tsx +139 -0
- package/frontend/src/components/ui/badge.tsx +36 -0
- package/frontend/src/components/ui/button-base.tsx +57 -0
- package/frontend/src/components/ui/card.tsx +76 -0
- package/frontend/src/components/ui/command.tsx +161 -0
- package/frontend/src/components/ui/dialog.tsx +120 -0
- package/frontend/src/components/ui/dropdown-menu.tsx +199 -0
- package/frontend/src/components/ui/input.tsx +22 -0
- package/frontend/src/components/ui/label.tsx +24 -0
- package/frontend/src/components/ui/popover.tsx +33 -0
- package/frontend/src/components/ui/progress.tsx +26 -0
- package/frontend/src/components/ui/scroll-area.tsx +48 -0
- package/frontend/src/components/ui/select-base.tsx +159 -0
- package/frontend/src/components/ui/separator.tsx +29 -0
- package/frontend/src/components/ui/sheet.tsx +140 -0
- package/frontend/src/components/ui/sidebar.tsx +783 -0
- package/frontend/src/components/ui/skeleton.tsx +15 -0
- package/frontend/src/components/ui/sonner.tsx +46 -0
- package/frontend/src/components/ui/switch.tsx +28 -0
- package/frontend/src/components/ui/table.tsx +120 -0
- package/frontend/src/components/ui/tooltip-base.tsx +30 -0
- package/frontend/src/config/api.ts +16 -0
- package/frontend/src/config/mode.ts +6 -0
- package/frontend/src/contexts/AppConfigContext.tsx +39 -0
- package/frontend/src/contexts/AuthContext.tsx +137 -0
- package/frontend/src/contexts/SearchCommandContext.tsx +28 -0
- package/frontend/src/hooks/use-mobile.tsx +19 -0
- package/frontend/src/hooks/useConfirmDialog.ts +63 -0
- package/frontend/src/hooks/useMarketingTheme.ts +47 -0
- package/frontend/src/i18n.ts +39 -0
- package/frontend/src/index.css +117 -0
- package/frontend/src/instrument.ts +20 -0
- package/frontend/src/lib/utils.ts +6 -0
- package/frontend/src/locales/de.json +899 -0
- package/frontend/src/locales/en.json +937 -0
- package/frontend/src/locales/es.json +884 -0
- package/frontend/src/locales/fr.json +550 -0
- package/frontend/src/locales/it.json +535 -0
- package/frontend/src/locales/ja.json +535 -0
- package/frontend/src/locales/nl.json +550 -0
- package/frontend/src/locales/pl.json +535 -0
- package/frontend/src/locales/pt.json +535 -0
- package/frontend/src/locales/ru.json +535 -0
- package/frontend/src/locales/zh.json +535 -0
- package/frontend/src/main.tsx +44 -0
- package/frontend/src/pages/Bookmarks.tsx +1004 -0
- package/frontend/src/pages/Dashboard.tsx +427 -0
- package/frontend/src/pages/Folders.tsx +578 -0
- package/frontend/src/pages/GoPreferences.tsx +134 -0
- package/frontend/src/pages/Login.tsx +196 -0
- package/frontend/src/pages/PasswordReset.tsx +242 -0
- package/frontend/src/pages/Profile.tsx +593 -0
- package/frontend/src/pages/SearchEngineGuide.tsx +135 -0
- package/frontend/src/pages/Setup.tsx +210 -0
- package/frontend/src/pages/Signup.tsx +199 -0
- package/frontend/src/pages/Tags.tsx +421 -0
- package/frontend/src/pages/VerifyEmail.tsx +254 -0
- package/frontend/src/pages/admin/AdminAIPage.tsx +5 -0
- package/frontend/src/pages/admin/AdminLayout.tsx +40 -0
- package/frontend/src/pages/admin/AdminMembersPage.tsx +5 -0
- package/frontend/src/pages/admin/AdminOIDCPage.tsx +5 -0
- package/frontend/src/pages/admin/AdminSettingsPage.tsx +5 -0
- package/frontend/src/pages/admin/AdminTeamsPage.tsx +5 -0
- package/frontend/src/utils/favicon.ts +36 -0
- package/frontend/src/utils/formatRelativeTime.ts +37 -0
- package/frontend/src/utils/safeHref.ts +31 -0
- package/frontend/src/vite-env.d.ts +10 -0
- 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
|
+
|