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