@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,578 @@
|
|
|
1
|
+
import { useState, useEffect, useMemo } from 'react';
|
|
2
|
+
import { Link, useSearchParams } from 'react-router-dom';
|
|
3
|
+
import { useTranslation } from 'react-i18next';
|
|
4
|
+
import api from '../api/client';
|
|
5
|
+
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
|
6
|
+
import { useConfirmDialog } from '../hooks/useConfirmDialog';
|
|
7
|
+
import { Plus, Edit, Trash2, Share2, LayoutGrid, List, Folder, ChevronLeft, ChevronRight } from 'lucide-react';
|
|
8
|
+
import FolderModal from '../components/modals/FolderModal';
|
|
9
|
+
import ShareResourceDialog from '../components/sharing/ShareResourceDialog';
|
|
10
|
+
import Button from '../components/ui/Button';
|
|
11
|
+
import Select from '../components/ui/Select';
|
|
12
|
+
import Tooltip from '../components/ui/Tooltip';
|
|
13
|
+
import FolderIcon from '../components/FolderIcon';
|
|
14
|
+
import { PageHeader } from '../components/PageHeader';
|
|
15
|
+
import { EmptyState } from '../components/EmptyState';
|
|
16
|
+
import { PageLoadingSkeleton } from '../components/ui/PageLoadingSkeleton';
|
|
17
|
+
import { useAppConfig } from '../contexts/AppConfigContext';
|
|
18
|
+
import { ScopeSegmentedControl } from '../components/ScopeSegmentedControl';
|
|
19
|
+
import { FilterChips } from '../components/FilterChips';
|
|
20
|
+
|
|
21
|
+
interface Folder {
|
|
22
|
+
id: string;
|
|
23
|
+
name: string;
|
|
24
|
+
icon?: string | null;
|
|
25
|
+
shared_teams?: Array<{ id: string; name: string }>;
|
|
26
|
+
shared_users?: Array<{ id: string; name: string; email: string }>;
|
|
27
|
+
folder_type?: 'own' | 'shared';
|
|
28
|
+
created_at?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type ViewMode = 'card' | 'list';
|
|
32
|
+
type SortOption = 'alphabetical' | 'recently_added';
|
|
33
|
+
|
|
34
|
+
const DEFAULT_SORT: SortOption = 'alphabetical';
|
|
35
|
+
|
|
36
|
+
export default function Folders() {
|
|
37
|
+
const { t } = useTranslation();
|
|
38
|
+
const { appBasePath } = useAppConfig();
|
|
39
|
+
const { showConfirm, dialogState } = useConfirmDialog();
|
|
40
|
+
const [searchParams, setSearchParams] = useSearchParams();
|
|
41
|
+
const [folders, setFolders] = useState<Folder[]>([]);
|
|
42
|
+
const [loading, setLoading] = useState(true);
|
|
43
|
+
const [modalOpen, setModalOpen] = useState(false);
|
|
44
|
+
const [editingFolder, setEditingFolder] = useState<Folder | null>(null);
|
|
45
|
+
const [shareDialogOpen, setShareDialogOpen] = useState(false);
|
|
46
|
+
const [sharingFolder, setSharingFolder] = useState<Folder | null>(null);
|
|
47
|
+
const [viewMode, setViewMode] = useState<ViewMode>(() => {
|
|
48
|
+
const saved = localStorage.getItem('folders-view-mode');
|
|
49
|
+
return (saved === 'list' || saved === 'card') ? saved : 'card';
|
|
50
|
+
});
|
|
51
|
+
const [compactMode, setCompactMode] = useState(() => {
|
|
52
|
+
return localStorage.getItem('folders-compact-mode') === 'true';
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const scopeParam = searchParams.get('scope');
|
|
56
|
+
const scope = (scopeParam === 'mine' || scopeParam === 'shared_with_me' || scopeParam === 'shared_by_me')
|
|
57
|
+
? scopeParam
|
|
58
|
+
: 'all';
|
|
59
|
+
const sortParam = searchParams.get('sort');
|
|
60
|
+
const sortBy = (sortParam === 'recently_added' || sortParam === 'alphabetical') ? sortParam : DEFAULT_SORT;
|
|
61
|
+
const PAGE_SIZE_OPTIONS = [50, 100, 200, 500] as const;
|
|
62
|
+
const limitParam = searchParams.get('limit');
|
|
63
|
+
const pageSize = (limitParam && PAGE_SIZE_OPTIONS.includes(Number(limitParam) as typeof PAGE_SIZE_OPTIONS[number]))
|
|
64
|
+
? Number(limitParam)
|
|
65
|
+
: 50;
|
|
66
|
+
const page = Math.max(0, parseInt(searchParams.get('page') || '0', 10));
|
|
67
|
+
const [totalFolders, setTotalFolders] = useState(0);
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
localStorage.setItem('folders-view-mode', viewMode);
|
|
71
|
+
}, [viewMode]);
|
|
72
|
+
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
localStorage.setItem('folders-compact-mode', compactMode.toString());
|
|
75
|
+
}, [compactMode]);
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
loadData();
|
|
79
|
+
}, [sortBy, scope, page, pageSize]);
|
|
80
|
+
|
|
81
|
+
async function loadData() {
|
|
82
|
+
try {
|
|
83
|
+
const foldersRes = await api.get('/folders', {
|
|
84
|
+
params: {
|
|
85
|
+
sort_by: sortBy,
|
|
86
|
+
scope: scope !== 'all' ? scope : undefined,
|
|
87
|
+
limit: pageSize,
|
|
88
|
+
offset: page * pageSize,
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
const data = foldersRes.data;
|
|
92
|
+
if (data && typeof data === 'object' && 'items' in data && 'total' in data) {
|
|
93
|
+
setFolders(Array.isArray((data as { items: Folder[] }).items) ? (data as { items: Folder[] }).items : []);
|
|
94
|
+
setTotalFolders(Number((data as { total: number }).total) || 0);
|
|
95
|
+
} else {
|
|
96
|
+
setFolders(Array.isArray(data) ? data : []);
|
|
97
|
+
setTotalFolders(Array.isArray(data) ? data.length : 0);
|
|
98
|
+
}
|
|
99
|
+
} catch (error) {
|
|
100
|
+
console.error('Failed to load data:', error);
|
|
101
|
+
} finally {
|
|
102
|
+
setLoading(false);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function updateParams(updates: { scope?: string; sort?: string; limit?: string; page?: string }) {
|
|
107
|
+
const params = new URLSearchParams(searchParams);
|
|
108
|
+
if (updates.scope !== undefined) {
|
|
109
|
+
if (updates.scope === 'all' || updates.scope === '') params.delete('scope');
|
|
110
|
+
else params.set('scope', updates.scope);
|
|
111
|
+
}
|
|
112
|
+
if (updates.sort !== undefined) {
|
|
113
|
+
if (updates.sort === DEFAULT_SORT || updates.sort === '') params.delete('sort');
|
|
114
|
+
else params.set('sort', updates.sort);
|
|
115
|
+
}
|
|
116
|
+
if (updates.limit !== undefined) {
|
|
117
|
+
if (updates.limit === '' || updates.limit === '50') params.delete('limit');
|
|
118
|
+
else params.set('limit', updates.limit);
|
|
119
|
+
params.set('page', '0');
|
|
120
|
+
}
|
|
121
|
+
if (updates.page !== undefined) {
|
|
122
|
+
if (updates.page === '0' || updates.page === '') params.delete('page');
|
|
123
|
+
else params.set('page', updates.page);
|
|
124
|
+
}
|
|
125
|
+
setSearchParams(params);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const hasActiveFilters = scope !== 'all' || sortBy !== DEFAULT_SORT;
|
|
129
|
+
|
|
130
|
+
function handleRemoveFilter(key: string) {
|
|
131
|
+
if (key === 'scope') updateParams({ scope: 'all' });
|
|
132
|
+
else if (key === 'sort') updateParams({ sort: DEFAULT_SORT });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function handleResetFilters() {
|
|
136
|
+
updateParams({ scope: 'all', sort: DEFAULT_SORT });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const sortOptions = [
|
|
140
|
+
{ value: 'alphabetical' as const, label: t('folders.sortAlphabetical') },
|
|
141
|
+
{ value: 'recently_added' as const, label: t('folders.sortRecentlyAdded') },
|
|
142
|
+
];
|
|
143
|
+
|
|
144
|
+
const filterChips = useMemo(() => {
|
|
145
|
+
const list: { key: string; label: string; ariaLabel: string }[] = [];
|
|
146
|
+
if (scope !== 'all') {
|
|
147
|
+
const scopeLabel = scope === 'mine' ? t('bookmarks.scopeMine') : scope === 'shared_with_me' ? t('common.scopeSharedWithMe') : t('common.scopeSharedByMe');
|
|
148
|
+
list.push({ key: 'scope', label: scopeLabel, ariaLabel: t('folders.clearFilters') + ' ' + scopeLabel });
|
|
149
|
+
}
|
|
150
|
+
if (sortBy !== DEFAULT_SORT) {
|
|
151
|
+
const sortLabel = sortBy === 'recently_added' ? t('folders.sortRecentlyAdded') : t('folders.sortAlphabetical');
|
|
152
|
+
list.push({ key: 'sort', label: `Sort: ${sortLabel}`, ariaLabel: t('folders.clearFilters') + ' Sort' });
|
|
153
|
+
}
|
|
154
|
+
return list;
|
|
155
|
+
}, [scope, sortBy, t]);
|
|
156
|
+
|
|
157
|
+
const sortedFolders = useMemo(() => [...folders], [folders]);
|
|
158
|
+
|
|
159
|
+
function handleCreate() {
|
|
160
|
+
setEditingFolder(null);
|
|
161
|
+
setModalOpen(true);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function handleEdit(folder: Folder) {
|
|
165
|
+
setEditingFolder(folder);
|
|
166
|
+
setModalOpen(true);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function handleDelete(id: string) {
|
|
170
|
+
const folder = folders.find(f => f.id === id);
|
|
171
|
+
const folderName = folder?.name || 'this folder';
|
|
172
|
+
showConfirm(
|
|
173
|
+
t('folders.deleteFolder'),
|
|
174
|
+
t('folders.deleteConfirmWithName', { name: folderName }),
|
|
175
|
+
async () => {
|
|
176
|
+
try {
|
|
177
|
+
await api.delete(`/folders/${id}`);
|
|
178
|
+
loadData();
|
|
179
|
+
} catch (error) {
|
|
180
|
+
console.error('Failed to delete folder:', error);
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
{ variant: 'danger', confirmText: t('common.delete'), cancelText: t('common.cancel') }
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function handleModalClose() {
|
|
188
|
+
setModalOpen(false);
|
|
189
|
+
setEditingFolder(null);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (loading) {
|
|
193
|
+
return <PageLoadingSkeleton lines={6} />;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return (
|
|
197
|
+
<div className="space-y-6 pb-24">
|
|
198
|
+
{/* Sticky controls bar: header + toolbar - stays visible when scrolling */}
|
|
199
|
+
<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">
|
|
200
|
+
<PageHeader
|
|
201
|
+
className="pt-4"
|
|
202
|
+
title={`${t('folders.title')} (${totalFolders})`}
|
|
203
|
+
subtitle={
|
|
204
|
+
hasActiveFilters || totalFolders > pageSize
|
|
205
|
+
? t('bookmarks.showingXOfY', { x: sortedFolders.length, y: totalFolders })
|
|
206
|
+
: undefined
|
|
207
|
+
}
|
|
208
|
+
actions={
|
|
209
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
210
|
+
<ScopeSegmentedControl
|
|
211
|
+
value={scope}
|
|
212
|
+
onChange={(s) => updateParams({ scope: s === 'all' ? undefined : s })}
|
|
213
|
+
options={[
|
|
214
|
+
{ value: 'all', label: t('bookmarks.scopeAll') },
|
|
215
|
+
{ value: 'mine', label: t('bookmarks.scopeMine') },
|
|
216
|
+
{ value: 'shared_with_me', label: t('common.scopeSharedWithMe') },
|
|
217
|
+
{ value: 'shared_by_me', label: t('common.scopeSharedByMe') },
|
|
218
|
+
]}
|
|
219
|
+
ariaLabel={t('bookmarks.scopeAll')}
|
|
220
|
+
/>
|
|
221
|
+
<Button onClick={handleCreate} icon={Plus}>
|
|
222
|
+
{t('folders.create')}
|
|
223
|
+
</Button>
|
|
224
|
+
</div>
|
|
225
|
+
}
|
|
226
|
+
/>
|
|
227
|
+
|
|
228
|
+
<FilterChips
|
|
229
|
+
chips={filterChips}
|
|
230
|
+
onRemove={handleRemoveFilter}
|
|
231
|
+
onClearAll={handleResetFilters}
|
|
232
|
+
clearAllLabel={t('bookmarks.clearAllFilters')}
|
|
233
|
+
clearAllAriaLabel={t('bookmarks.clearAllFilters')}
|
|
234
|
+
/>
|
|
235
|
+
|
|
236
|
+
{/* Toolbar: Sort, Page size, View Modes */}
|
|
237
|
+
<div className="flex flex-wrap items-center gap-3 bg-card rounded-lg border p-4 shadow-sm">
|
|
238
|
+
{/* Sort */}
|
|
239
|
+
<div className="flex items-center gap-2">
|
|
240
|
+
<Select
|
|
241
|
+
value={sortBy}
|
|
242
|
+
onChange={(value) => updateParams({ sort: value as SortOption })}
|
|
243
|
+
options={sortOptions}
|
|
244
|
+
className="min-w-[160px]"
|
|
245
|
+
/>
|
|
246
|
+
</div>
|
|
247
|
+
<div className="flex items-center gap-2">
|
|
248
|
+
<Select
|
|
249
|
+
value={String(pageSize)}
|
|
250
|
+
onChange={(value) => updateParams({ limit: value })}
|
|
251
|
+
options={PAGE_SIZE_OPTIONS.map((n) => ({ value: String(n), label: String(n) }))}
|
|
252
|
+
className="min-w-[80px]"
|
|
253
|
+
/>
|
|
254
|
+
<span className="text-sm text-muted-foreground whitespace-nowrap">{t('bookmarks.perPage')}</span>
|
|
255
|
+
</div>
|
|
256
|
+
{/* View Mode Toggle */}
|
|
257
|
+
<div className="flex items-center gap-2 border-l border-gray-200 dark:border-gray-700 pl-3 ml-auto">
|
|
258
|
+
<div className="flex items-center gap-1 bg-gray-100 dark:bg-gray-700 rounded-lg p-1">
|
|
259
|
+
<button
|
|
260
|
+
onClick={() => setViewMode('card')}
|
|
261
|
+
className={`p-1.5 rounded transition-colors ${
|
|
262
|
+
viewMode === 'card'
|
|
263
|
+
? 'bg-card text-primary shadow-sm'
|
|
264
|
+
: 'text-muted-foreground hover:text-foreground'
|
|
265
|
+
}`}
|
|
266
|
+
title={t('folders.viewCard')}
|
|
267
|
+
>
|
|
268
|
+
<LayoutGrid className="h-4 w-4" />
|
|
269
|
+
</button>
|
|
270
|
+
<button
|
|
271
|
+
onClick={() => setViewMode('list')}
|
|
272
|
+
className={`p-1.5 rounded transition-colors ${
|
|
273
|
+
viewMode === 'list'
|
|
274
|
+
? 'bg-card text-primary shadow-sm'
|
|
275
|
+
: 'text-muted-foreground hover:text-foreground'
|
|
276
|
+
}`}
|
|
277
|
+
title={t('folders.viewList')}
|
|
278
|
+
>
|
|
279
|
+
<List className="h-4 w-4" />
|
|
280
|
+
</button>
|
|
281
|
+
</div>
|
|
282
|
+
<button
|
|
283
|
+
onClick={() => setCompactMode(!compactMode)}
|
|
284
|
+
className={`px-3 py-1.5 text-sm rounded-lg transition-colors ${
|
|
285
|
+
compactMode
|
|
286
|
+
? 'bg-primary/20 text-primary'
|
|
287
|
+
: 'bg-muted text-muted-foreground hover:bg-accent'
|
|
288
|
+
}`}
|
|
289
|
+
title={t('folders.compactMode')}
|
|
290
|
+
>
|
|
291
|
+
{t('folders.compactMode')}
|
|
292
|
+
</button>
|
|
293
|
+
</div>
|
|
294
|
+
</div>
|
|
295
|
+
</div>
|
|
296
|
+
|
|
297
|
+
{/* Folders Display */}
|
|
298
|
+
{sortedFolders.length === 0 ? (
|
|
299
|
+
hasActiveFilters ? (
|
|
300
|
+
<EmptyState
|
|
301
|
+
icon={Folder}
|
|
302
|
+
title={t('bookmarks.noMatches')}
|
|
303
|
+
description={t('folders.noMatchesDescription')}
|
|
304
|
+
action={
|
|
305
|
+
<Button onClick={handleResetFilters} variant="secondary">
|
|
306
|
+
{t('bookmarks.clearAllFilters')}
|
|
307
|
+
</Button>
|
|
308
|
+
}
|
|
309
|
+
/>
|
|
310
|
+
) : (
|
|
311
|
+
<EmptyState
|
|
312
|
+
icon={Folder}
|
|
313
|
+
title={t('folders.empty')}
|
|
314
|
+
description={t('folders.emptyDescription')}
|
|
315
|
+
action={
|
|
316
|
+
<Button onClick={handleCreate} variant="primary" icon={Plus}>
|
|
317
|
+
{t('folders.create')}
|
|
318
|
+
</Button>
|
|
319
|
+
}
|
|
320
|
+
/>
|
|
321
|
+
)
|
|
322
|
+
) : viewMode === 'card' ? (
|
|
323
|
+
<div className={`grid grid-cols-1 gap-3 items-stretch ${
|
|
324
|
+
compactMode
|
|
325
|
+
? 'sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-8'
|
|
326
|
+
: 'sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6'
|
|
327
|
+
}`}>
|
|
328
|
+
{sortedFolders.map((folder) => (
|
|
329
|
+
<div
|
|
330
|
+
key={folder.id}
|
|
331
|
+
className={`group bg-card rounded-lg border border-border hover:border-primary/70 hover:bg-muted/50 hover:shadow-md transition-all duration-200 flex flex-col h-full min-h-0 ${compactMode ? 'p-2.5 min-h-[160px]' : 'p-2.5 min-h-[140px]'}`}
|
|
332
|
+
>
|
|
333
|
+
<Link
|
|
334
|
+
to={`${appBasePath}/bookmarks?folder_id=${folder.id}`}
|
|
335
|
+
className="flex-1 flex flex-col min-w-0 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded"
|
|
336
|
+
>
|
|
337
|
+
<div className="space-y-3 flex-1 flex flex-col">
|
|
338
|
+
<div className="flex items-start gap-3">
|
|
339
|
+
<div className={`flex-shrink-0 ${compactMode ? 'w-9 h-9' : 'w-10 h-10'} rounded-xl bg-primary/20 flex items-center justify-center border border-primary/30`}>
|
|
340
|
+
<FolderIcon iconName={folder.icon} size={compactMode ? 18 : 20} className="text-primary" />
|
|
341
|
+
</div>
|
|
342
|
+
<div className="flex-1 min-w-0 pt-0.5">
|
|
343
|
+
<h3 className={`${compactMode ? 'text-xs' : 'text-sm'} font-medium text-foreground truncate mb-1`}>
|
|
344
|
+
{folder.name}
|
|
345
|
+
</h3>
|
|
346
|
+
{folder.shared_teams && folder.shared_teams.length > 0 && (
|
|
347
|
+
<Tooltip
|
|
348
|
+
content={
|
|
349
|
+
<div className="space-y-1">
|
|
350
|
+
<div className="font-semibold mb-1">{t('folders.sharedWith')}</div>
|
|
351
|
+
{folder.shared_teams.map((team) => (
|
|
352
|
+
<div key={team.id} className="text-xs">• {team.name}</div>
|
|
353
|
+
))}
|
|
354
|
+
{folder.shared_users && folder.shared_users.length > 0 && (
|
|
355
|
+
folder.shared_users.map((user) => (
|
|
356
|
+
<div key={user.id} className="text-xs">• {user.name || user.email}</div>
|
|
357
|
+
))
|
|
358
|
+
)}
|
|
359
|
+
</div>
|
|
360
|
+
}
|
|
361
|
+
>
|
|
362
|
+
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300 rounded-md border border-green-200 dark:border-green-800/50 cursor-help">
|
|
363
|
+
<Share2 className="h-3 w-3" />
|
|
364
|
+
{t('folders.shared')}
|
|
365
|
+
</span>
|
|
366
|
+
</Tooltip>
|
|
367
|
+
)}
|
|
368
|
+
</div>
|
|
369
|
+
</div>
|
|
370
|
+
{/* TODO: Add bookmark_count when backend supports it */}
|
|
371
|
+
<p className="text-xs text-muted-foreground">—</p>
|
|
372
|
+
</div>
|
|
373
|
+
</Link>
|
|
374
|
+
{folder.folder_type === 'own' && (
|
|
375
|
+
<div className={`flex gap-1.5 pt-2.5 mt-auto shrink-0 border-t border-border ${compactMode ? 'pt-2' : ''}`}>
|
|
376
|
+
<Button
|
|
377
|
+
variant="ghost"
|
|
378
|
+
size="sm"
|
|
379
|
+
icon={Share2}
|
|
380
|
+
iconClassName="h-3.5 w-3.5 stroke-[1.5]"
|
|
381
|
+
onClick={() => { setSharingFolder(folder); setShareDialogOpen(true); }}
|
|
382
|
+
title={t('sharing.shareFolder')}
|
|
383
|
+
className="h-8 w-8 p-0 flex-shrink-0"
|
|
384
|
+
/>
|
|
385
|
+
<Button
|
|
386
|
+
variant="ghost"
|
|
387
|
+
size="sm"
|
|
388
|
+
icon={Edit}
|
|
389
|
+
iconClassName="h-3.5 w-3.5 stroke-[1.5]"
|
|
390
|
+
onClick={() => handleEdit(folder)}
|
|
391
|
+
className="flex-1 h-8 min-w-0 text-xs"
|
|
392
|
+
>
|
|
393
|
+
{t('common.edit')}
|
|
394
|
+
</Button>
|
|
395
|
+
<Button
|
|
396
|
+
variant="ghost"
|
|
397
|
+
size="sm"
|
|
398
|
+
icon={Trash2}
|
|
399
|
+
iconClassName="h-3.5 w-3.5 stroke-[1.5]"
|
|
400
|
+
onClick={() => handleDelete(folder.id)}
|
|
401
|
+
title={t('common.delete')}
|
|
402
|
+
className="h-8 w-8 p-0 flex-shrink-0 text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
|
|
403
|
+
/>
|
|
404
|
+
</div>
|
|
405
|
+
)}
|
|
406
|
+
</div>
|
|
407
|
+
))}
|
|
408
|
+
</div>
|
|
409
|
+
) : (
|
|
410
|
+
<div className="overflow-x-auto bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
|
411
|
+
<table className="w-full">
|
|
412
|
+
<thead className="bg-gray-50 dark:bg-gray-900/50 border-b border-gray-200 dark:border-gray-700">
|
|
413
|
+
<tr>
|
|
414
|
+
<th className={`${compactMode ? 'px-2 py-1.5' : 'px-4 py-3'} text-left ${compactMode ? 'text-[10px]' : 'text-xs'} font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide`}>
|
|
415
|
+
{t('folders.name')}
|
|
416
|
+
</th>
|
|
417
|
+
{!compactMode && (
|
|
418
|
+
<th className={`${compactMode ? 'px-2 py-1.5' : 'px-4 py-3'} text-left ${compactMode ? 'text-[10px]' : 'text-xs'} font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide`}>
|
|
419
|
+
{t('bookmarks.title')}
|
|
420
|
+
</th>
|
|
421
|
+
)}
|
|
422
|
+
{!compactMode && (
|
|
423
|
+
<th className={`${compactMode ? 'px-2 py-1.5' : 'px-4 py-3'} text-left ${compactMode ? 'text-[10px]' : 'text-xs'} font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide`}>
|
|
424
|
+
{t('folders.shared')}
|
|
425
|
+
</th>
|
|
426
|
+
)}
|
|
427
|
+
<th className={`${compactMode ? 'px-2 py-1.5' : 'px-4 py-3'} text-right ${compactMode ? 'text-[10px]' : 'text-xs'} font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide`}>
|
|
428
|
+
{t('common.actions')}
|
|
429
|
+
</th>
|
|
430
|
+
</tr>
|
|
431
|
+
</thead>
|
|
432
|
+
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
|
433
|
+
{sortedFolders.map((folder) => (
|
|
434
|
+
<tr
|
|
435
|
+
key={folder.id}
|
|
436
|
+
className={`hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors ${compactMode ? 'h-10' : ''}`}
|
|
437
|
+
>
|
|
438
|
+
<td className={`${compactMode ? 'px-2 py-1.5' : 'px-4 py-3'}`}>
|
|
439
|
+
<Link
|
|
440
|
+
to={`${appBasePath}/bookmarks?folder_id=${folder.id}`}
|
|
441
|
+
className={`flex items-center ${compactMode ? 'gap-2' : 'gap-3'} hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded`}
|
|
442
|
+
>
|
|
443
|
+
<div className={`flex-shrink-0 ${compactMode ? 'w-6 h-6' : 'w-8 h-8'} rounded-lg bg-primary/20 flex items-center justify-center border border-primary/30`}>
|
|
444
|
+
<FolderIcon iconName={folder.icon} size={compactMode ? 12 : 16} className="text-primary" />
|
|
445
|
+
</div>
|
|
446
|
+
<div className={`font-medium text-gray-900 dark:text-white ${compactMode ? 'text-xs' : 'text-[15px]'}`}>
|
|
447
|
+
{folder.name}
|
|
448
|
+
</div>
|
|
449
|
+
</Link>
|
|
450
|
+
</td>
|
|
451
|
+
{!compactMode && (
|
|
452
|
+
<td className="px-4 py-3 text-xs text-muted-foreground">—</td>
|
|
453
|
+
)}
|
|
454
|
+
{!compactMode && (
|
|
455
|
+
<td className={`${compactMode ? 'px-2 py-1.5' : 'px-4 py-3'}`}>
|
|
456
|
+
{folder.shared_teams && folder.shared_teams.length > 0 ? (
|
|
457
|
+
<Tooltip
|
|
458
|
+
content={
|
|
459
|
+
<div className="space-y-1">
|
|
460
|
+
<div className="font-semibold mb-1">{t('folders.sharedWith')}</div>
|
|
461
|
+
{folder.shared_teams.map((team) => (
|
|
462
|
+
<div key={team.id} className="text-xs">
|
|
463
|
+
• {team.name}
|
|
464
|
+
</div>
|
|
465
|
+
))}
|
|
466
|
+
{folder.shared_users && folder.shared_users.length > 0 && (
|
|
467
|
+
<>
|
|
468
|
+
{folder.shared_users.map((user) => (
|
|
469
|
+
<div key={user.id} className="text-xs">
|
|
470
|
+
• {user.name || user.email}
|
|
471
|
+
</div>
|
|
472
|
+
))}
|
|
473
|
+
</>
|
|
474
|
+
)}
|
|
475
|
+
</div>
|
|
476
|
+
}
|
|
477
|
+
>
|
|
478
|
+
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300 rounded-md cursor-help">
|
|
479
|
+
<Share2 className="h-3 w-3" />
|
|
480
|
+
{t('folders.shared')}
|
|
481
|
+
</span>
|
|
482
|
+
</Tooltip>
|
|
483
|
+
) : (
|
|
484
|
+
<span className="text-xs text-gray-500 dark:text-gray-400">-</span>
|
|
485
|
+
)}
|
|
486
|
+
</td>
|
|
487
|
+
)}
|
|
488
|
+
<td className={`${compactMode ? 'px-2 py-1.5' : 'px-4 py-3'}`}>
|
|
489
|
+
{folder.folder_type === 'own' && (
|
|
490
|
+
<div className={`flex items-center justify-end ${compactMode ? 'gap-1' : 'gap-2'}`}>
|
|
491
|
+
<Button
|
|
492
|
+
variant="ghost"
|
|
493
|
+
size="sm"
|
|
494
|
+
icon={Share2}
|
|
495
|
+
onClick={() => { setSharingFolder(folder); setShareDialogOpen(true); }}
|
|
496
|
+
title={t('sharing.shareFolder')}
|
|
497
|
+
className={compactMode ? 'px-1 h-6' : 'px-2'}
|
|
498
|
+
/>
|
|
499
|
+
<Button
|
|
500
|
+
variant="ghost"
|
|
501
|
+
size="sm"
|
|
502
|
+
icon={Edit}
|
|
503
|
+
onClick={() => handleEdit(folder)}
|
|
504
|
+
className={compactMode ? 'px-1 h-6' : 'px-2'}
|
|
505
|
+
/>
|
|
506
|
+
<Button
|
|
507
|
+
variant="ghost"
|
|
508
|
+
size="sm"
|
|
509
|
+
icon={Trash2}
|
|
510
|
+
onClick={() => handleDelete(folder.id)}
|
|
511
|
+
title={t('common.delete')}
|
|
512
|
+
className={`text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 ${compactMode ? 'px-1 h-6' : 'px-2'}`}
|
|
513
|
+
/>
|
|
514
|
+
</div>
|
|
515
|
+
)}
|
|
516
|
+
</td>
|
|
517
|
+
</tr>
|
|
518
|
+
))}
|
|
519
|
+
</tbody>
|
|
520
|
+
</table>
|
|
521
|
+
</div>
|
|
522
|
+
)}
|
|
523
|
+
|
|
524
|
+
{totalFolders > pageSize && sortedFolders.length > 0 && (
|
|
525
|
+
<div className="flex items-center justify-between gap-4 mt-6 py-4 border-t border-border">
|
|
526
|
+
<p className="text-sm text-muted-foreground">
|
|
527
|
+
{t('bookmarks.paginationShowing', {
|
|
528
|
+
from: page * pageSize + 1,
|
|
529
|
+
to: Math.min(page * pageSize + sortedFolders.length, totalFolders),
|
|
530
|
+
total: totalFolders,
|
|
531
|
+
})}
|
|
532
|
+
</p>
|
|
533
|
+
<div className="flex gap-2">
|
|
534
|
+
<Button
|
|
535
|
+
variant="secondary"
|
|
536
|
+
size="sm"
|
|
537
|
+
icon={ChevronLeft}
|
|
538
|
+
onClick={() => updateParams({ page: String(Math.max(0, page - 1)) })}
|
|
539
|
+
disabled={page === 0}
|
|
540
|
+
>
|
|
541
|
+
{t('bookmarks.paginationPrevious')}
|
|
542
|
+
</Button>
|
|
543
|
+
<Button
|
|
544
|
+
variant="secondary"
|
|
545
|
+
size="sm"
|
|
546
|
+
icon={ChevronRight}
|
|
547
|
+
iconPosition="right"
|
|
548
|
+
onClick={() => updateParams({ page: String(page + 1) })}
|
|
549
|
+
disabled={page * pageSize + sortedFolders.length >= totalFolders}
|
|
550
|
+
>
|
|
551
|
+
{t('bookmarks.paginationNext')}
|
|
552
|
+
</Button>
|
|
553
|
+
</div>
|
|
554
|
+
</div>
|
|
555
|
+
)}
|
|
556
|
+
|
|
557
|
+
<FolderModal
|
|
558
|
+
folder={editingFolder}
|
|
559
|
+
isOpen={modalOpen}
|
|
560
|
+
onClose={handleModalClose}
|
|
561
|
+
onSuccess={loadData}
|
|
562
|
+
/>
|
|
563
|
+
|
|
564
|
+
{sharingFolder && (
|
|
565
|
+
<ShareResourceDialog
|
|
566
|
+
resourceType="folder"
|
|
567
|
+
resourceId={sharingFolder.id}
|
|
568
|
+
resourceName={sharingFolder.name}
|
|
569
|
+
isOpen={shareDialogOpen}
|
|
570
|
+
onClose={() => { setShareDialogOpen(false); setSharingFolder(null); }}
|
|
571
|
+
onSuccess={loadData}
|
|
572
|
+
/>
|
|
573
|
+
)}
|
|
574
|
+
|
|
575
|
+
<ConfirmDialog {...dialogState} />
|
|
576
|
+
</div>
|
|
577
|
+
);
|
|
578
|
+
}
|