@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,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
+ }