@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,421 @@
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, Tag as TagIcon, LayoutGrid, List, ChevronLeft, ChevronRight } from 'lucide-react';
8
+ import TagModal from '../components/modals/TagModal';
9
+ import Button from '../components/ui/Button';
10
+ import Select from '../components/ui/Select';
11
+ import { PageHeader } from '../components/PageHeader';
12
+ import { EmptyState } from '../components/EmptyState';
13
+ import { PageLoadingSkeleton } from '../components/ui/PageLoadingSkeleton';
14
+ import { useAppConfig } from '../contexts/AppConfigContext';
15
+ import { FilterChips } from '../components/FilterChips';
16
+
17
+ interface Tag {
18
+ id: string;
19
+ name: string;
20
+ created_at?: string;
21
+ }
22
+
23
+ type ViewMode = 'card' | 'list';
24
+ type SortOption = 'alphabetical' | 'recently_added';
25
+
26
+ const DEFAULT_SORT: SortOption = 'alphabetical';
27
+
28
+ export default function Tags() {
29
+ const { t } = useTranslation();
30
+ const { appBasePath } = useAppConfig();
31
+ const { showConfirm, dialogState } = useConfirmDialog();
32
+ const [searchParams, setSearchParams] = useSearchParams();
33
+ const [tags, setTags] = useState<Tag[]>([]);
34
+ const [loading, setLoading] = useState(true);
35
+ const [modalOpen, setModalOpen] = useState(false);
36
+ const [editingTag, setEditingTag] = useState<Tag | null>(null);
37
+ const [viewMode, setViewMode] = useState<ViewMode>(() => {
38
+ const saved = localStorage.getItem('tags-view-mode');
39
+ return (saved === 'list' || saved === 'card') ? saved : 'card';
40
+ });
41
+ const [compactMode, setCompactMode] = useState(() => {
42
+ return localStorage.getItem('tags-compact-mode') === 'true';
43
+ });
44
+
45
+ const sortParam = searchParams.get('sort');
46
+ const sortBy = (sortParam === 'recently_added' || sortParam === 'alphabetical') ? sortParam : DEFAULT_SORT;
47
+ const PAGE_SIZE_OPTIONS = [50, 100, 200, 500] as const;
48
+ const limitParam = searchParams.get('limit');
49
+ const pageSize = (limitParam && PAGE_SIZE_OPTIONS.includes(Number(limitParam) as typeof PAGE_SIZE_OPTIONS[number]))
50
+ ? Number(limitParam)
51
+ : 50;
52
+ const page = Math.max(0, parseInt(searchParams.get('page') || '0', 10));
53
+ const [totalTags, setTotalTags] = useState(0);
54
+
55
+ useEffect(() => {
56
+ localStorage.setItem('tags-view-mode', viewMode);
57
+ }, [viewMode]);
58
+
59
+ useEffect(() => {
60
+ localStorage.setItem('tags-compact-mode', compactMode.toString());
61
+ }, [compactMode]);
62
+
63
+ useEffect(() => {
64
+ loadTags();
65
+ }, [sortBy, page, pageSize]);
66
+
67
+ function updateParams(updates: { sort?: string; limit?: string; page?: string }) {
68
+ const params = new URLSearchParams(searchParams);
69
+ if (updates.sort !== undefined) {
70
+ if (updates.sort === DEFAULT_SORT || updates.sort === '') params.delete('sort');
71
+ else params.set('sort', updates.sort);
72
+ }
73
+ if (updates.limit !== undefined) {
74
+ if (updates.limit === '' || updates.limit === '50') params.delete('limit');
75
+ else params.set('limit', updates.limit);
76
+ params.set('page', '0');
77
+ }
78
+ if (updates.page !== undefined) {
79
+ if (updates.page === '0' || updates.page === '') params.delete('page');
80
+ else params.set('page', updates.page);
81
+ }
82
+ setSearchParams(params);
83
+ }
84
+
85
+ const hasActiveFilters = sortBy !== DEFAULT_SORT;
86
+
87
+ function handleRemoveFilter(key: string) {
88
+ if (key === 'sort') updateParams({ sort: DEFAULT_SORT });
89
+ }
90
+
91
+ function handleResetFilters() {
92
+ updateParams({ sort: DEFAULT_SORT });
93
+ }
94
+
95
+ const filterChips = useMemo(() => {
96
+ const list: { key: string; label: string; ariaLabel: string }[] = [];
97
+ if (sortBy !== DEFAULT_SORT) {
98
+ const sortLabel = sortBy === 'recently_added' ? t('tags.sortRecentlyAdded') : t('tags.sortAlphabetical');
99
+ list.push({ key: 'sort', label: `Sort: ${sortLabel}`, ariaLabel: t('tags.clearFilters') + ' Sort' });
100
+ }
101
+ return list;
102
+ }, [sortBy, t]);
103
+
104
+ async function loadTags() {
105
+ try {
106
+ const res = await api.get('/tags', {
107
+ params: { sort_by: sortBy, limit: pageSize, offset: page * pageSize },
108
+ });
109
+ const data = res.data;
110
+ if (data && typeof data === 'object' && 'items' in data && 'total' in data) {
111
+ setTags(Array.isArray((data as { items: Tag[] }).items) ? (data as { items: Tag[] }).items : []);
112
+ setTotalTags(Number((data as { total: number }).total) || 0);
113
+ } else {
114
+ setTags(Array.isArray(data) ? data : []);
115
+ setTotalTags(Array.isArray(data) ? data.length : 0);
116
+ }
117
+ } catch (error) {
118
+ console.error('Failed to load tags:', error);
119
+ } finally {
120
+ setLoading(false);
121
+ }
122
+ }
123
+
124
+ const sortedTags = useMemo(() => {
125
+ // Backend already sorts, but we can do client-side sorting if needed
126
+ return [...tags];
127
+ }, [tags]);
128
+
129
+ function handleCreate() {
130
+ setEditingTag(null);
131
+ setModalOpen(true);
132
+ }
133
+
134
+ function handleEdit(tag: Tag) {
135
+ setEditingTag(tag);
136
+ setModalOpen(true);
137
+ }
138
+
139
+ function handleDelete(id: string) {
140
+ const tag = tags.find(t => t.id === id);
141
+ const tagName = tag?.name || 'this tag';
142
+ showConfirm(
143
+ t('tags.deleteTag'),
144
+ t('tags.deleteConfirmWithName', { name: tagName }),
145
+ async () => {
146
+ try {
147
+ await api.delete(`/tags/${id}`);
148
+ loadTags();
149
+ } catch (error) {
150
+ console.error('Failed to delete tag:', error);
151
+ }
152
+ },
153
+ { variant: 'danger', confirmText: t('common.delete'), cancelText: t('common.cancel') }
154
+ );
155
+ }
156
+
157
+ function handleModalClose() {
158
+ setModalOpen(false);
159
+ setEditingTag(null);
160
+ }
161
+
162
+ const sortOptions = [
163
+ { value: 'alphabetical', label: t('tags.sortAlphabetical') },
164
+ { value: 'recently_added', label: t('tags.sortRecentlyAdded') },
165
+ ];
166
+
167
+ if (loading) {
168
+ return <PageLoadingSkeleton lines={6} />;
169
+ }
170
+
171
+ return (
172
+ <div className="space-y-6 pb-24">
173
+ {/* Sticky controls bar: header + toolbar - stays visible when scrolling */}
174
+ <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">
175
+ <PageHeader
176
+ className="pt-4"
177
+ title={`${t('tags.title')} (${totalTags})`}
178
+ subtitle={
179
+ hasActiveFilters || totalTags > pageSize
180
+ ? t('bookmarks.showingXOfY', { x: sortedTags.length, y: totalTags })
181
+ : undefined
182
+ }
183
+ actions={
184
+ <Button onClick={handleCreate} icon={Plus}>
185
+ {t('tags.create')}
186
+ </Button>
187
+ }
188
+ />
189
+
190
+ <FilterChips
191
+ chips={filterChips}
192
+ onRemove={handleRemoveFilter}
193
+ onClearAll={handleResetFilters}
194
+ clearAllLabel={t('bookmarks.clearAllFilters')}
195
+ clearAllAriaLabel={t('bookmarks.clearAllFilters')}
196
+ />
197
+
198
+ {/* Toolbar: Sort, Page size, View Modes - same card style as Bookmarks/Folders */}
199
+ <div className="flex flex-wrap items-center gap-3 bg-card rounded-lg border border-border p-4 shadow-sm">
200
+ <div className="flex items-center gap-2">
201
+ <Select
202
+ value={sortBy}
203
+ onChange={(value) => updateParams({ sort: value as SortOption })}
204
+ options={sortOptions}
205
+ className="min-w-[160px]"
206
+ />
207
+ </div>
208
+ <div className="flex items-center gap-2">
209
+ <Select
210
+ value={String(pageSize)}
211
+ onChange={(value) => updateParams({ limit: value })}
212
+ options={PAGE_SIZE_OPTIONS.map((n) => ({ value: String(n), label: String(n) }))}
213
+ className="min-w-[80px]"
214
+ />
215
+ <span className="text-sm text-muted-foreground whitespace-nowrap">{t('bookmarks.perPage')}</span>
216
+ </div>
217
+ <div className="flex items-center gap-2 border-l border-border pl-3 ml-auto">
218
+ <div className="flex items-center gap-1 bg-muted/50 rounded-lg p-1 border border-border">
219
+ <button
220
+ onClick={() => setViewMode('card')}
221
+ className={`p-1.5 rounded transition-colors ${
222
+ viewMode === 'card' ? 'bg-background text-primary shadow-sm' : 'text-muted-foreground hover:text-foreground'
223
+ }`}
224
+ title={t('tags.viewCard')}
225
+ >
226
+ <LayoutGrid className="h-4 w-4" />
227
+ </button>
228
+ <button
229
+ onClick={() => setViewMode('list')}
230
+ className={`p-1.5 rounded transition-colors ${
231
+ viewMode === 'list' ? 'bg-background text-primary shadow-sm' : 'text-muted-foreground hover:text-foreground'
232
+ }`}
233
+ title={t('tags.viewList')}
234
+ >
235
+ <List className="h-4 w-4" />
236
+ </button>
237
+ </div>
238
+ <button
239
+ onClick={() => setCompactMode(!compactMode)}
240
+ className={`px-3 py-1.5 text-sm rounded-lg transition-colors ${
241
+ compactMode ? 'bg-primary/20 text-primary' : 'bg-muted text-muted-foreground hover:bg-accent'
242
+ }`}
243
+ title={t('tags.compactMode')}
244
+ >
245
+ {t('tags.compactMode')}
246
+ </button>
247
+ </div>
248
+ </div>
249
+ </div>
250
+
251
+ {/* Tags Display */}
252
+ {sortedTags.length === 0 ? (
253
+ <EmptyState
254
+ icon={TagIcon}
255
+ title={t('tags.empty')}
256
+ description={t('tags.emptyDescription')}
257
+ action={
258
+ <Button onClick={handleCreate} variant="primary" icon={Plus}>
259
+ {t('tags.create')}
260
+ </Button>
261
+ }
262
+ />
263
+ ) : viewMode === 'card' ? (
264
+ <div className={`grid grid-cols-1 gap-3 items-stretch ${
265
+ compactMode
266
+ ? 'sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-8'
267
+ : 'sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6'
268
+ }`}>
269
+ {sortedTags.map((tag) => (
270
+ <div
271
+ key={tag.id}
272
+ 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]'}`}
273
+ >
274
+ <Link
275
+ to={`${appBasePath}/bookmarks?tag_id=${tag.id}`}
276
+ 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"
277
+ >
278
+ <div className="space-y-3 flex-1 flex flex-col">
279
+ <div className="flex items-start gap-3">
280
+ <div className={`flex-shrink-0 ${compactMode ? 'w-9 h-9' : 'w-10 h-10'} rounded-xl bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-900/30 dark:to-purple-800/20 flex items-center justify-center border border-purple-100 dark:border-purple-800/50`}>
281
+ <TagIcon className={`${compactMode ? 'h-4 w-4' : 'h-5 w-5'} text-purple-600 dark:text-purple-400`} />
282
+ </div>
283
+ <div className="flex-1 min-w-0 pt-0.5">
284
+ <h3 className={`${compactMode ? 'text-xs' : 'text-sm'} font-medium text-foreground truncate`}>
285
+ {tag.name}
286
+ </h3>
287
+ </div>
288
+ </div>
289
+ {/* TODO: Add bookmark_count when backend supports it */}
290
+ <p className="text-xs text-muted-foreground">—</p>
291
+ </div>
292
+ </Link>
293
+ <div className={`flex gap-1.5 pt-2.5 mt-auto shrink-0 border-t border-border ${compactMode ? 'pt-2' : ''}`}>
294
+ <Button
295
+ variant="ghost"
296
+ size="sm"
297
+ icon={Edit}
298
+ iconClassName="h-3.5 w-3.5 stroke-[1.5]"
299
+ onClick={() => handleEdit(tag)}
300
+ className="flex-1 h-8 min-w-0 text-xs"
301
+ >
302
+ {t('common.edit')}
303
+ </Button>
304
+ <Button
305
+ variant="ghost"
306
+ size="sm"
307
+ icon={Trash2}
308
+ iconClassName="h-3.5 w-3.5 stroke-[1.5]"
309
+ onClick={() => handleDelete(tag.id)}
310
+ title={t('common.delete')}
311
+ 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"
312
+ />
313
+ </div>
314
+ </div>
315
+ ))}
316
+ </div>
317
+ ) : (
318
+ <div className="overflow-x-auto bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
319
+ <table className="w-full">
320
+ <thead className="bg-gray-50 dark:bg-gray-900/50 border-b border-gray-200 dark:border-gray-700">
321
+ <tr>
322
+ <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`}>
323
+ {t('tags.name')}
324
+ </th>
325
+ <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`}>
326
+ {t('common.actions')}
327
+ </th>
328
+ </tr>
329
+ </thead>
330
+ <tbody className="divide-y divide-gray-200 dark:divide-gray-700">
331
+ {sortedTags.map((tag) => (
332
+ <tr
333
+ key={tag.id}
334
+ className={`hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors ${compactMode ? 'h-10' : ''}`}
335
+ >
336
+ <td className={`${compactMode ? 'px-2 py-1.5' : 'px-4 py-3'}`}>
337
+ <Link
338
+ to={`${appBasePath}/bookmarks?tag_id=${tag.id}`}
339
+ 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`}
340
+ >
341
+ <div className={`flex-shrink-0 ${compactMode ? 'w-6 h-6' : 'w-8 h-8'} rounded-lg bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-900/30 dark:to-purple-800/20 flex items-center justify-center border border-purple-100 dark:border-purple-800/50`}>
342
+ <TagIcon className={`${compactMode ? 'h-3 w-3' : 'h-4 w-4'} text-purple-600 dark:text-purple-400`} />
343
+ </div>
344
+ <div className={`font-medium text-gray-900 dark:text-white ${compactMode ? 'text-xs' : 'text-[15px]'}`}>
345
+ {tag.name}
346
+ </div>
347
+ </Link>
348
+ </td>
349
+ {!compactMode && (
350
+ <td className="px-4 py-3 text-xs text-muted-foreground">—</td>
351
+ )}
352
+ <td className={`${compactMode ? 'px-2 py-1.5' : 'px-4 py-3'}`}>
353
+ <div className={`flex items-center justify-end ${compactMode ? 'gap-1' : 'gap-2'}`}>
354
+ <Button
355
+ variant="ghost"
356
+ size="sm"
357
+ icon={Edit}
358
+ onClick={() => handleEdit(tag)}
359
+ className={compactMode ? 'px-1 h-6' : 'px-2'}
360
+ />
361
+ <Button
362
+ variant="ghost"
363
+ size="sm"
364
+ icon={Trash2}
365
+ onClick={() => handleDelete(tag.id)}
366
+ title={t('common.delete')}
367
+ className={`text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 ${compactMode ? 'px-1 h-6' : 'px-2'}`}
368
+ />
369
+ </div>
370
+ </td>
371
+ </tr>
372
+ ))}
373
+ </tbody>
374
+ </table>
375
+ </div>
376
+ )}
377
+
378
+ {totalTags > pageSize && (
379
+ <div className="flex flex-wrap items-center justify-between gap-3 mt-4">
380
+ <p className="text-sm text-muted-foreground">
381
+ {t('bookmarks.paginationShowing', {
382
+ from: page * pageSize + 1,
383
+ to: Math.min(page * pageSize + sortedTags.length, totalTags),
384
+ total: totalTags,
385
+ })}
386
+ </p>
387
+ <div className="flex items-center gap-2">
388
+ <Button
389
+ variant="outline"
390
+ size="sm"
391
+ icon={ChevronLeft}
392
+ onClick={() => updateParams({ page: String(Math.max(0, page - 1)) })}
393
+ disabled={page === 0}
394
+ >
395
+ {t('bookmarks.paginationPrevious')}
396
+ </Button>
397
+ <Button
398
+ variant="outline"
399
+ size="sm"
400
+ icon={ChevronRight}
401
+ iconPosition="right"
402
+ onClick={() => updateParams({ page: String(page + 1) })}
403
+ disabled={(page + 1) * pageSize >= totalTags}
404
+ >
405
+ {t('bookmarks.paginationNext')}
406
+ </Button>
407
+ </div>
408
+ </div>
409
+ )}
410
+
411
+ <TagModal
412
+ tag={editingTag}
413
+ isOpen={modalOpen}
414
+ onClose={handleModalClose}
415
+ onSuccess={loadTags}
416
+ />
417
+
418
+ <ConfirmDialog {...dialogState} />
419
+ </div>
420
+ );
421
+ }
@@ -0,0 +1,254 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { useNavigate, useSearchParams, Link } from 'react-router-dom';
4
+ import { useAuth } from '../contexts/AuthContext';
5
+ import api from '../api/client';
6
+ import { CheckCircle, XCircle, Mail } from 'lucide-react';
7
+ import Button from '../components/ui/Button';
8
+
9
+ export default function VerifyEmail() {
10
+ const { t } = useTranslation();
11
+ const navigate = useNavigate();
12
+ const [searchParams] = useSearchParams();
13
+ const { checkAuth } = useAuth();
14
+ const profilePath = '/profile';
15
+ const loginPath = '/login';
16
+ const signupPath = '/login';
17
+ const [status, setStatus] = useState<'verifying' | 'success' | 'error' | 'resend' | 'noToken'>('verifying');
18
+ const [error, setError] = useState('');
19
+ const [newEmail, setNewEmail] = useState('');
20
+ const [signupVerified, setSignupVerified] = useState(false);
21
+ const [resendEmail, setResendEmail] = useState('');
22
+ const [resendToken, setResendToken] = useState('');
23
+ const [resendLoading, setResendLoading] = useState(false);
24
+ const [resendSuccess, setResendSuccess] = useState(false);
25
+
26
+ useEffect(() => {
27
+ const token = searchParams.get('token');
28
+ if (!token) {
29
+ setStatus('error');
30
+ setError(t('emailVerification.tokenRequired'));
31
+ return;
32
+ }
33
+
34
+ api.post('/auth/verify-signup', { token })
35
+ .then(() => {
36
+ setStatus('success');
37
+ setSignupVerified(true);
38
+ setTimeout(() => navigate(loginPath), 2500);
39
+ })
40
+ .catch(async () => {
41
+ const statusRes = await api.get('/auth/signup-verification/status', { params: { token } }).catch(() => ({ data: { status: 'invalid' } }));
42
+ const { status: tokenStatus, email } = statusRes.data;
43
+ if (tokenStatus === 'expired' && email) {
44
+ setStatus('resend');
45
+ setResendEmail(email);
46
+ setResendToken(token);
47
+ return;
48
+ }
49
+ if (tokenStatus === 'used') {
50
+ setStatus('error');
51
+ setError(t('emailVerification.alreadyVerified'));
52
+ return;
53
+ }
54
+
55
+ try {
56
+ const verifyRes = await api.get('/email-verification/verify', { params: { token } });
57
+ if (verifyRes.data.valid) {
58
+ setNewEmail(verifyRes.data.newEmail || '');
59
+ await api.post('/email-verification/confirm', { token });
60
+ setStatus('success');
61
+ await checkAuth();
62
+ setTimeout(() => navigate(profilePath), 3000);
63
+ return;
64
+ }
65
+ } catch {
66
+ // Not a profile token either
67
+ }
68
+ setStatus('error');
69
+ setError(t('emailVerification.invalidLink'));
70
+ });
71
+ }, [searchParams, t, navigate, checkAuth]);
72
+
73
+ const handleResendSubmit = async (e: React.FormEvent) => {
74
+ e.preventDefault();
75
+ setResendLoading(true);
76
+ setError('');
77
+ try {
78
+ await api.post('/auth/resend-signup-verification', {
79
+ token: resendToken,
80
+ newEmail: resendEmail.trim() || undefined,
81
+ });
82
+ setResendSuccess(true);
83
+ } catch (err: any) {
84
+ setError(err.response?.data?.error || t('common.error'));
85
+ } finally {
86
+ setResendLoading(false);
87
+ }
88
+ };
89
+
90
+ const handleRequestResendSubmit = async (e: React.FormEvent) => {
91
+ e.preventDefault();
92
+ const form = e.target as HTMLFormElement;
93
+ const emailInput = form.elements.namedItem('email') as HTMLInputElement | null;
94
+ const email = emailInput?.value?.trim();
95
+ if (!email) return;
96
+ setResendLoading(true);
97
+ setError('');
98
+ try {
99
+ await api.post('/auth/request-signup-resend', { email });
100
+ setResendSuccess(true);
101
+ } catch (err: any) {
102
+ setError(err.response?.data?.error || t('common.error'));
103
+ } finally {
104
+ setResendLoading(false);
105
+ }
106
+ };
107
+
108
+ return (
109
+ <div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8">
110
+ <div className="max-w-md w-full bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-lg p-4">
111
+ <div className="text-center space-y-4">
112
+ {status === 'verifying' && (
113
+ <>
114
+ <div className="mx-auto w-16 h-16 bg-blue-100 dark:bg-blue-900/30 rounded-full flex items-center justify-center">
115
+ <Mail className="h-8 w-8 text-blue-600 dark:text-blue-400 animate-pulse" />
116
+ </div>
117
+ <h2 className="text-2xl font-semibold text-gray-900 dark:text-white">
118
+ {t('emailVerification.verifying')}
119
+ </h2>
120
+ <p className="text-gray-600 dark:text-gray-400">
121
+ {t('emailVerification.verifyingDescription')}
122
+ </p>
123
+ </>
124
+ )}
125
+
126
+ {status === 'success' && (
127
+ <>
128
+ <div className="mx-auto w-16 h-16 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center">
129
+ <CheckCircle className="h-8 w-8 text-green-600 dark:text-green-400" />
130
+ </div>
131
+ <h2 className="text-2xl font-semibold text-gray-900 dark:text-white">
132
+ {signupVerified ? t('emailVerification.signupSuccess') : t('emailVerification.success')}
133
+ </h2>
134
+ <p className="text-gray-600 dark:text-gray-400">
135
+ {signupVerified ? t('emailVerification.signupSuccessDescription') : t('emailVerification.successDescription', { email: newEmail })}
136
+ </p>
137
+ {!signupVerified && (
138
+ <p className="text-sm text-gray-500 dark:text-gray-400">
139
+ {t('emailVerification.redirecting')}
140
+ </p>
141
+ )}
142
+ </>
143
+ )}
144
+
145
+ {(status === 'resend' || status === 'noToken') && (
146
+ <>
147
+ {resendSuccess ? (
148
+ <>
149
+ <div className="mx-auto w-16 h-16 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center">
150
+ <CheckCircle className="h-8 w-8 text-green-600 dark:text-green-400" />
151
+ </div>
152
+ <h2 className="text-2xl font-semibold text-gray-900 dark:text-white">
153
+ {t('emailVerification.resendSuccess')}
154
+ </h2>
155
+ <p className="text-gray-600 dark:text-gray-400">
156
+ {t('emailVerification.resendSuccessDescription')}
157
+ </p>
158
+ <Link to={loginPath} className="inline-block mt-2 text-sm font-medium text-blue-600 dark:text-blue-400 hover:underline">
159
+ {t('auth.login')}
160
+ </Link>
161
+ </>
162
+ ) : (
163
+ <>
164
+ <div className="mx-auto w-16 h-16 bg-amber-100 dark:bg-amber-900/30 rounded-full flex items-center justify-center">
165
+ <Mail className="h-8 w-8 text-amber-600 dark:text-amber-400" />
166
+ </div>
167
+ <h2 className="text-2xl font-semibold text-gray-900 dark:text-white">
168
+ {t('emailVerification.resendTitle')}
169
+ </h2>
170
+ <p className="text-gray-600 dark:text-gray-400">
171
+ {status === 'resend'
172
+ ? t('emailVerification.resendDescription')
173
+ : t('emailVerification.resendNoTokenDescription')}
174
+ </p>
175
+ <form onSubmit={status === 'resend' ? handleResendSubmit : handleRequestResendSubmit} className="space-y-4 text-left mt-4">
176
+ <div>
177
+ <label htmlFor="resend-email" className="block text-sm font-semibold text-gray-900 dark:text-white mb-2">
178
+ {t('emailVerification.editEmailLabel')}
179
+ </label>
180
+ <input
181
+ id="resend-email"
182
+ name="email"
183
+ type="email"
184
+ required
185
+ className="w-full px-4 h-9 text-sm text-gray-900 dark:text-white bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
186
+ placeholder={t('auth.emailPlaceholder')}
187
+ value={status === 'resend' ? resendEmail : undefined}
188
+ onChange={status === 'resend' ? (e) => setResendEmail(e.target.value) : undefined}
189
+ />
190
+ </div>
191
+ {error && (
192
+ <div className="px-4 py-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
193
+ <p className="text-sm text-red-800 dark:text-red-200">{error}</p>
194
+ </div>
195
+ )}
196
+ <Button
197
+ type="submit"
198
+ variant="primary"
199
+ disabled={resendLoading}
200
+ className="w-full"
201
+ >
202
+ {resendLoading ? t('common.loading') : t('emailVerification.resendButton')}
203
+ </Button>
204
+ </form>
205
+ <p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
206
+ <Link to={loginPath} className="font-medium text-blue-600 dark:text-blue-400 hover:underline">
207
+ {t('signup.backToLogin')}
208
+ </Link>
209
+ {' · '}
210
+ <Link to={signupPath} className="font-medium text-blue-600 dark:text-blue-400 hover:underline">
211
+ {t('auth.signUp')}
212
+ </Link>
213
+ </p>
214
+ </>
215
+ )}
216
+ </>
217
+ )}
218
+
219
+ {status === 'error' && (
220
+ <>
221
+ <div className="mx-auto w-16 h-16 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center">
222
+ <XCircle className="h-8 w-8 text-red-600 dark:text-red-400" />
223
+ </div>
224
+ <h2 className="text-2xl font-semibold text-gray-900 dark:text-white">
225
+ {t('emailVerification.error')}
226
+ </h2>
227
+ <p className="text-gray-600 dark:text-gray-400">
228
+ {error || t('emailVerification.errorDescription')}
229
+ </p>
230
+ <div className="pt-4 space-y-2">
231
+ {error === t('emailVerification.alreadyVerified') ? (
232
+ <Button variant="primary" onClick={() => navigate(loginPath)}>
233
+ {t('auth.login')}
234
+ </Button>
235
+ ) : (
236
+ <Button variant="primary" onClick={() => navigate(profilePath)}>
237
+ {t('emailVerification.backToProfile')}
238
+ </Button>
239
+ )}
240
+ {error !== t('emailVerification.alreadyVerified') && (
241
+ <p className="text-sm">
242
+ <Link to={signupPath} className="font-medium text-blue-600 dark:text-blue-400 hover:underline">
243
+ {t('auth.signUp')}
244
+ </Link>
245
+ </p>
246
+ )}
247
+ </div>
248
+ </>
249
+ )}
250
+ </div>
251
+ </div>
252
+ </div>
253
+ );
254
+ }
@@ -0,0 +1,5 @@
1
+ import AdminAI from '../../components/admin/AdminAI';
2
+
3
+ export default function AdminAIPage() {
4
+ return <AdminAI />;
5
+ }