@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,306 @@
|
|
|
1
|
+
import React, { useState, useEffect, useMemo } from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import api from '../../api/client';
|
|
4
|
+
import { useToast } from '../ui/Toast';
|
|
5
|
+
import {
|
|
6
|
+
Dialog,
|
|
7
|
+
DialogContent,
|
|
8
|
+
DialogHeader,
|
|
9
|
+
DialogTitle,
|
|
10
|
+
DialogFooter,
|
|
11
|
+
} from '../ui/dialog';
|
|
12
|
+
import { Separator } from '../ui/separator';
|
|
13
|
+
import { FormFieldWrapper } from '../ui/FormFieldWrapper';
|
|
14
|
+
import { ModalSection } from '../ui/ModalSection';
|
|
15
|
+
import { ModalFooterActions } from '../ui/ModalFooterActions';
|
|
16
|
+
import { Input } from '../ui/input';
|
|
17
|
+
import FolderIcon, { popularIcons, getAllIcons } from '../FolderIcon';
|
|
18
|
+
import { Search, X } from 'lucide-react';
|
|
19
|
+
import * as LucideIcons from 'lucide-react';
|
|
20
|
+
|
|
21
|
+
interface Folder {
|
|
22
|
+
id: string;
|
|
23
|
+
name: string;
|
|
24
|
+
icon?: string | null;
|
|
25
|
+
shared_teams?: Array<{ id: string; name: string }>;
|
|
26
|
+
folder_type?: 'own' | 'shared';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface FolderModalProps {
|
|
30
|
+
folder: Folder | null;
|
|
31
|
+
isOpen: boolean;
|
|
32
|
+
onClose: () => void;
|
|
33
|
+
onSuccess: () => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export default function FolderModal({
|
|
37
|
+
folder,
|
|
38
|
+
isOpen,
|
|
39
|
+
onClose,
|
|
40
|
+
onSuccess,
|
|
41
|
+
}: FolderModalProps) {
|
|
42
|
+
const { t } = useTranslation();
|
|
43
|
+
const { showToast } = useToast();
|
|
44
|
+
const [formData, setFormData] = useState({
|
|
45
|
+
name: '',
|
|
46
|
+
icon: '',
|
|
47
|
+
});
|
|
48
|
+
const [loading, setLoading] = useState(false);
|
|
49
|
+
const [error, setError] = useState('');
|
|
50
|
+
const [iconSearchQuery, setIconSearchQuery] = useState('');
|
|
51
|
+
const [showAllIcons, setShowAllIcons] = useState(false);
|
|
52
|
+
|
|
53
|
+
const allIcons = useMemo(() => getAllIcons(), []);
|
|
54
|
+
|
|
55
|
+
const filteredIcons = useMemo(() => {
|
|
56
|
+
const iconsToSearch = showAllIcons ? allIcons : popularIcons;
|
|
57
|
+
if (!iconSearchQuery.trim()) {
|
|
58
|
+
return iconsToSearch;
|
|
59
|
+
}
|
|
60
|
+
const query = iconSearchQuery.toLowerCase();
|
|
61
|
+
const filtered = iconsToSearch.filter((iconName) =>
|
|
62
|
+
iconName.toLowerCase().includes(query)
|
|
63
|
+
);
|
|
64
|
+
const exactMatch = allIcons.find(icon => icon.toLowerCase() === query);
|
|
65
|
+
if (exactMatch && !filtered.includes(exactMatch)) {
|
|
66
|
+
return [exactMatch, ...filtered];
|
|
67
|
+
}
|
|
68
|
+
return filtered;
|
|
69
|
+
}, [iconSearchQuery, showAllIcons, allIcons]);
|
|
70
|
+
|
|
71
|
+
const isValidIconName = useMemo(() => {
|
|
72
|
+
if (!iconSearchQuery.trim()) return false;
|
|
73
|
+
const query = iconSearchQuery.trim();
|
|
74
|
+
return allIcons.some(icon => icon.toLowerCase() === query.toLowerCase());
|
|
75
|
+
}, [iconSearchQuery, allIcons]);
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (folder) {
|
|
79
|
+
setFormData({
|
|
80
|
+
name: folder.name,
|
|
81
|
+
icon: folder.icon || '',
|
|
82
|
+
});
|
|
83
|
+
} else {
|
|
84
|
+
setFormData({ name: '', icon: '' });
|
|
85
|
+
}
|
|
86
|
+
setError('');
|
|
87
|
+
setIconSearchQuery('');
|
|
88
|
+
setShowAllIcons(false);
|
|
89
|
+
}, [folder, isOpen]);
|
|
90
|
+
|
|
91
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
92
|
+
e.preventDefault();
|
|
93
|
+
setLoading(true);
|
|
94
|
+
setError('');
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const payload: any = {
|
|
98
|
+
name: formData.name,
|
|
99
|
+
icon: formData.icon || undefined,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
Object.keys(payload).forEach(key => {
|
|
103
|
+
if (payload[key] === undefined) {
|
|
104
|
+
delete payload[key];
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
if (folder) {
|
|
108
|
+
await api.put(`/folders/${folder.id}`, payload);
|
|
109
|
+
} else {
|
|
110
|
+
await api.post('/folders', payload);
|
|
111
|
+
}
|
|
112
|
+
onSuccess();
|
|
113
|
+
onClose();
|
|
114
|
+
} catch (err: any) {
|
|
115
|
+
const errorMessage = err.response?.data?.error || t('common.error');
|
|
116
|
+
const code = err.response?.data?.code;
|
|
117
|
+
setError(errorMessage);
|
|
118
|
+
if (code === 'PLAN_FOLDER_SHARING') {
|
|
119
|
+
showToast(errorMessage, 'error');
|
|
120
|
+
}
|
|
121
|
+
} finally {
|
|
122
|
+
setLoading(false);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const isValid = formData.name.trim();
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
|
130
|
+
<DialogContent className="max-w-[460px]">
|
|
131
|
+
<DialogHeader>
|
|
132
|
+
<DialogTitle>{folder ? t('folders.edit') : t('folders.create')}</DialogTitle>
|
|
133
|
+
</DialogHeader>
|
|
134
|
+
<Separator />
|
|
135
|
+
|
|
136
|
+
<form id="folder-form" onSubmit={handleSubmit} className="space-y-6">
|
|
137
|
+
<ModalSection>
|
|
138
|
+
<FormFieldWrapper label={t('folders.name')} required error={error}>
|
|
139
|
+
<Input
|
|
140
|
+
type="text"
|
|
141
|
+
required
|
|
142
|
+
value={formData.name}
|
|
143
|
+
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
144
|
+
placeholder={t('folders.name')}
|
|
145
|
+
/>
|
|
146
|
+
</FormFieldWrapper>
|
|
147
|
+
|
|
148
|
+
<div>
|
|
149
|
+
<label className="block text-sm font-medium mb-2">{t('folders.icon')}</label>
|
|
150
|
+
<div className="space-y-2">
|
|
151
|
+
<div className="relative">
|
|
152
|
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
153
|
+
<Input
|
|
154
|
+
type="text"
|
|
155
|
+
placeholder={t('folders.searchIcons')}
|
|
156
|
+
value={iconSearchQuery}
|
|
157
|
+
onChange={(e) => setIconSearchQuery(e.target.value)}
|
|
158
|
+
className="pl-9 pr-9"
|
|
159
|
+
/>
|
|
160
|
+
{iconSearchQuery && (
|
|
161
|
+
<button
|
|
162
|
+
type="button"
|
|
163
|
+
onClick={() => setIconSearchQuery('')}
|
|
164
|
+
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
165
|
+
aria-label={t('common.remove')}
|
|
166
|
+
>
|
|
167
|
+
<X className="h-4 w-4" />
|
|
168
|
+
</button>
|
|
169
|
+
)}
|
|
170
|
+
</div>
|
|
171
|
+
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
|
172
|
+
<span>
|
|
173
|
+
{showAllIcons
|
|
174
|
+
? t('folders.showingAllIcons', { count: filteredIcons.length })
|
|
175
|
+
: t('folders.showingPopular', { count: filteredIcons.length })}
|
|
176
|
+
</span>
|
|
177
|
+
<button
|
|
178
|
+
type="button"
|
|
179
|
+
onClick={() => {
|
|
180
|
+
setShowAllIcons(!showAllIcons);
|
|
181
|
+
setIconSearchQuery('');
|
|
182
|
+
}}
|
|
183
|
+
className="text-primary hover:underline"
|
|
184
|
+
>
|
|
185
|
+
{showAllIcons ? t('folders.showPopularOnly') : t('folders.showAllIcons')}
|
|
186
|
+
</button>
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
|
|
190
|
+
{isValidIconName && iconSearchQuery.trim() && !filteredIcons.some(icon => icon.toLowerCase() === iconSearchQuery.trim().toLowerCase()) && (
|
|
191
|
+
<button
|
|
192
|
+
type="button"
|
|
193
|
+
onClick={() => {
|
|
194
|
+
const query = iconSearchQuery.trim();
|
|
195
|
+
const matchedIcon = allIcons.find(icon => icon.toLowerCase() === query.toLowerCase());
|
|
196
|
+
if (matchedIcon) {
|
|
197
|
+
setFormData({ ...formData, icon: matchedIcon });
|
|
198
|
+
setIconSearchQuery('');
|
|
199
|
+
}
|
|
200
|
+
}}
|
|
201
|
+
className="w-full mt-2 px-3 py-2 text-sm font-medium rounded-lg border bg-muted/50 hover:bg-muted transition-colors flex items-center justify-center gap-2"
|
|
202
|
+
>
|
|
203
|
+
{t('folders.useIcon')}: <code className="px-1.5 py-0.5 bg-muted rounded">{allIcons.find(icon => icon.toLowerCase() === iconSearchQuery.trim().toLowerCase())}</code>
|
|
204
|
+
</button>
|
|
205
|
+
)}
|
|
206
|
+
|
|
207
|
+
<div className="grid grid-cols-8 gap-2 max-h-48 overflow-y-auto p-2 border rounded-lg mt-2">
|
|
208
|
+
<button
|
|
209
|
+
type="button"
|
|
210
|
+
onClick={() => setFormData({ ...formData, icon: '' })}
|
|
211
|
+
className={`p-2 rounded-lg border-2 transition-colors ${
|
|
212
|
+
formData.icon === ''
|
|
213
|
+
? 'border-primary bg-primary/10'
|
|
214
|
+
: 'border-border hover:border-muted-foreground/50'
|
|
215
|
+
}`}
|
|
216
|
+
title={t('folders.noIcon')}
|
|
217
|
+
>
|
|
218
|
+
<FolderIcon iconName={null} size={20} className="mx-auto text-muted-foreground" />
|
|
219
|
+
</button>
|
|
220
|
+
|
|
221
|
+
{filteredIcons.length === 0 ? (
|
|
222
|
+
<div className="col-span-8 text-center py-6 text-sm text-muted-foreground">
|
|
223
|
+
{t('folders.noIconsFound')}
|
|
224
|
+
{!isValidIconName && iconSearchQuery.trim() && (
|
|
225
|
+
<p className="mt-2 text-xs">{t('folders.typeIconName')}</p>
|
|
226
|
+
)}
|
|
227
|
+
</div>
|
|
228
|
+
) : (
|
|
229
|
+
filteredIcons.map((iconName) => {
|
|
230
|
+
let IconComponent = (LucideIcons as any)[iconName];
|
|
231
|
+
if (!IconComponent) {
|
|
232
|
+
const iconNameLower = iconName.toLowerCase();
|
|
233
|
+
for (const key in LucideIcons) {
|
|
234
|
+
if (key.toLowerCase() === iconNameLower) {
|
|
235
|
+
const candidate = (LucideIcons as any)[key];
|
|
236
|
+
const isValid = typeof candidate === 'function' ||
|
|
237
|
+
(candidate && typeof candidate === 'object' && (candidate.$$typeof || candidate.render));
|
|
238
|
+
if (isValid) {
|
|
239
|
+
IconComponent = candidate;
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const isValidComponent = IconComponent &&
|
|
247
|
+
(typeof IconComponent === 'function' ||
|
|
248
|
+
(typeof IconComponent === 'object' && IconComponent !== null && ((IconComponent as any).$$typeof || (IconComponent as any).render)));
|
|
249
|
+
|
|
250
|
+
if (!isValidComponent) return null;
|
|
251
|
+
|
|
252
|
+
return (
|
|
253
|
+
<button
|
|
254
|
+
key={iconName}
|
|
255
|
+
type="button"
|
|
256
|
+
onClick={() => setFormData({ ...formData, icon: iconName })}
|
|
257
|
+
className={`p-2 rounded-lg border-2 transition-colors ${
|
|
258
|
+
formData.icon === iconName
|
|
259
|
+
? 'border-primary bg-primary/10'
|
|
260
|
+
: 'border-border hover:border-muted-foreground/50'
|
|
261
|
+
}`}
|
|
262
|
+
title={iconName}
|
|
263
|
+
>
|
|
264
|
+
<IconComponent className="h-5 w-5 mx-auto text-muted-foreground" />
|
|
265
|
+
</button>
|
|
266
|
+
);
|
|
267
|
+
})
|
|
268
|
+
)}
|
|
269
|
+
</div>
|
|
270
|
+
|
|
271
|
+
{formData.icon && (
|
|
272
|
+
<div className="mt-2 flex items-center gap-2 p-2 rounded-lg border bg-muted/50">
|
|
273
|
+
<FolderIcon iconName={formData.icon} size={20} className="text-primary" />
|
|
274
|
+
<div className="flex-1 min-w-0">
|
|
275
|
+
<p className="text-xs font-medium">{t('folders.selectedIcon')}</p>
|
|
276
|
+
<p className="text-xs text-muted-foreground font-mono truncate">{formData.icon}</p>
|
|
277
|
+
</div>
|
|
278
|
+
<button
|
|
279
|
+
type="button"
|
|
280
|
+
onClick={() => setFormData({ ...formData, icon: '' })}
|
|
281
|
+
className="text-muted-foreground hover:text-foreground"
|
|
282
|
+
title={t('folders.clearIcon')}
|
|
283
|
+
aria-label={t('folders.clearIcon')}
|
|
284
|
+
>
|
|
285
|
+
<X className="h-4 w-4" />
|
|
286
|
+
</button>
|
|
287
|
+
</div>
|
|
288
|
+
)}
|
|
289
|
+
</div>
|
|
290
|
+
</ModalSection>
|
|
291
|
+
</form>
|
|
292
|
+
|
|
293
|
+
<Separator />
|
|
294
|
+
<DialogFooter className="flex-row justify-between sm:justify-end gap-2">
|
|
295
|
+
<ModalFooterActions
|
|
296
|
+
onCancel={onClose}
|
|
297
|
+
submitLabel={t('common.save')}
|
|
298
|
+
loading={loading}
|
|
299
|
+
submitDisabled={!isValid}
|
|
300
|
+
formId="folder-form"
|
|
301
|
+
/>
|
|
302
|
+
</DialogFooter>
|
|
303
|
+
</DialogContent>
|
|
304
|
+
</Dialog>
|
|
305
|
+
);
|
|
306
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import { Upload } from 'lucide-react';
|
|
4
|
+
import {
|
|
5
|
+
Dialog,
|
|
6
|
+
DialogContent,
|
|
7
|
+
DialogHeader,
|
|
8
|
+
DialogTitle,
|
|
9
|
+
DialogFooter,
|
|
10
|
+
} from '../ui/dialog';
|
|
11
|
+
import { Separator } from '../ui/separator';
|
|
12
|
+
import Button from '../ui/Button';
|
|
13
|
+
import api from '../../api/client';
|
|
14
|
+
import { useToast } from '../ui/Toast';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Decode HTML entities safely (only common ones, no script execution)
|
|
18
|
+
*/
|
|
19
|
+
function decodeHtmlEntities(text: string): string {
|
|
20
|
+
const entityMap: Record<string, string> = {
|
|
21
|
+
'&': '&',
|
|
22
|
+
'<': '<',
|
|
23
|
+
'>': '>',
|
|
24
|
+
'"': '"',
|
|
25
|
+
''': "'",
|
|
26
|
+
''': "'",
|
|
27
|
+
'/': '/',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
return text.replace(/&[#\w]+;/g, (entity) => {
|
|
31
|
+
return entityMap[entity] || entity;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Extract text content from HTML tag safely (without parsing HTML)
|
|
37
|
+
* Removes HTML tags and decodes entities
|
|
38
|
+
*/
|
|
39
|
+
function extractTextFromHtmlTag(html: string): string {
|
|
40
|
+
if (!html || typeof html !== 'string') {
|
|
41
|
+
return '';
|
|
42
|
+
}
|
|
43
|
+
let text = html;
|
|
44
|
+
let previous = '';
|
|
45
|
+
while (text !== previous) {
|
|
46
|
+
previous = text;
|
|
47
|
+
text = text.replace(/<[^>]*>/g, '');
|
|
48
|
+
}
|
|
49
|
+
text = text.replace(/[<>]/g, '');
|
|
50
|
+
text = decodeHtmlEntities(text);
|
|
51
|
+
return text.trim();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Validate and sanitize URL
|
|
56
|
+
*/
|
|
57
|
+
function validateAndSanitizeUrl(href: string): string | null {
|
|
58
|
+
if (!href || typeof href !== 'string') {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const trimmed = href.trim();
|
|
63
|
+
if (!trimmed) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const lowerHref = trimmed.toLowerCase();
|
|
68
|
+
if (lowerHref.startsWith('javascript:') ||
|
|
69
|
+
lowerHref.startsWith('data:') ||
|
|
70
|
+
lowerHref.startsWith('vbscript:') ||
|
|
71
|
+
lowerHref.startsWith('file:') ||
|
|
72
|
+
lowerHref.startsWith('about:')) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const url = new URL(trimmed, window.location.origin);
|
|
78
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
return url.href;
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Parse HTML bookmark file using regex (avoids DOMParser XSS concerns)
|
|
89
|
+
*/
|
|
90
|
+
function parseHtmlBookmarks(html: string): Array<{ title: string; url: string }> {
|
|
91
|
+
const bookmarks: Array<{ title: string; url: string }> = [];
|
|
92
|
+
const linkRegex = /<a\s+[^>]*href\s*=\s*["']([^"']+)["'][^>]*>(.*?)<\/a>/gi;
|
|
93
|
+
|
|
94
|
+
let match;
|
|
95
|
+
while ((match = linkRegex.exec(html)) !== null) {
|
|
96
|
+
const href = match[1];
|
|
97
|
+
const tagContent = match[2];
|
|
98
|
+
|
|
99
|
+
const validatedUrl = validateAndSanitizeUrl(href);
|
|
100
|
+
if (!validatedUrl) continue;
|
|
101
|
+
|
|
102
|
+
const title = extractTextFromHtmlTag(tagContent);
|
|
103
|
+
if (!title) continue;
|
|
104
|
+
|
|
105
|
+
bookmarks.push({ title, url: validatedUrl });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return bookmarks;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface ImportModalProps {
|
|
112
|
+
isOpen: boolean;
|
|
113
|
+
onClose: () => void;
|
|
114
|
+
onSuccess: () => void;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export default function ImportModal({ isOpen, onClose, onSuccess }: ImportModalProps) {
|
|
118
|
+
const { t } = useTranslation();
|
|
119
|
+
const { showToast } = useToast();
|
|
120
|
+
const [loading, setLoading] = useState(false);
|
|
121
|
+
const [error, setError] = useState('');
|
|
122
|
+
|
|
123
|
+
async function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
|
|
124
|
+
const file = e.target.files?.[0];
|
|
125
|
+
if (!file) return;
|
|
126
|
+
|
|
127
|
+
setLoading(true);
|
|
128
|
+
setError('');
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const text = await file.text();
|
|
132
|
+
let bookmarks: any[] = [];
|
|
133
|
+
|
|
134
|
+
if (file.name.endsWith('.json')) {
|
|
135
|
+
const data = JSON.parse(text);
|
|
136
|
+
if (Array.isArray(data)) {
|
|
137
|
+
bookmarks = data;
|
|
138
|
+
} else if (data && Array.isArray(data.bookmarks)) {
|
|
139
|
+
bookmarks = data.bookmarks;
|
|
140
|
+
} else {
|
|
141
|
+
bookmarks = [data];
|
|
142
|
+
}
|
|
143
|
+
} else if (file.name.endsWith('.html')) {
|
|
144
|
+
if (text.length > 10 * 1024 * 1024) {
|
|
145
|
+
throw new Error('HTML file is too large. Maximum size is 10MB.');
|
|
146
|
+
}
|
|
147
|
+
bookmarks = parseHtmlBookmarks(text);
|
|
148
|
+
} else {
|
|
149
|
+
throw new Error('Unsupported file format. Please use JSON or HTML.');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (bookmarks.length === 0) {
|
|
153
|
+
throw new Error('No bookmarks found in file.');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const response = await api.post('/bookmarks/import', { bookmarks });
|
|
157
|
+
showToast(
|
|
158
|
+
t('bookmarks.importSuccess', {
|
|
159
|
+
success: response.data.success,
|
|
160
|
+
failed: response.data.failed
|
|
161
|
+
}),
|
|
162
|
+
response.data.failed > 0 ? 'warning' : 'success'
|
|
163
|
+
);
|
|
164
|
+
onSuccess();
|
|
165
|
+
onClose();
|
|
166
|
+
} catch (err: any) {
|
|
167
|
+
setError(err.response?.data?.error || err.message || t('common.error'));
|
|
168
|
+
showToast(err.response?.data?.error || err.message || t('common.error'), 'error');
|
|
169
|
+
} finally {
|
|
170
|
+
setLoading(false);
|
|
171
|
+
e.target.value = '';
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
|
177
|
+
<DialogContent className="max-w-[460px]">
|
|
178
|
+
<DialogHeader>
|
|
179
|
+
<DialogTitle>{t('bookmarks.import')}</DialogTitle>
|
|
180
|
+
</DialogHeader>
|
|
181
|
+
<Separator />
|
|
182
|
+
|
|
183
|
+
<div className="space-y-4">
|
|
184
|
+
<p className="text-sm text-muted-foreground">
|
|
185
|
+
{t('bookmarks.importDescription')}
|
|
186
|
+
</p>
|
|
187
|
+
|
|
188
|
+
<div className="border-2 border-dashed border-border rounded-lg p-6 text-center">
|
|
189
|
+
<Upload className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
|
190
|
+
<label className="cursor-pointer">
|
|
191
|
+
<input
|
|
192
|
+
type="file"
|
|
193
|
+
accept=".json,.html"
|
|
194
|
+
onChange={handleFileSelect}
|
|
195
|
+
className="hidden"
|
|
196
|
+
disabled={loading}
|
|
197
|
+
/>
|
|
198
|
+
<Button
|
|
199
|
+
variant="primary"
|
|
200
|
+
icon={Upload}
|
|
201
|
+
disabled={loading}
|
|
202
|
+
loading={loading}
|
|
203
|
+
onClick={() => {
|
|
204
|
+
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
|
205
|
+
input?.click();
|
|
206
|
+
}}
|
|
207
|
+
>
|
|
208
|
+
{loading ? t('common.loading') : t('bookmarks.selectFile')}
|
|
209
|
+
</Button>
|
|
210
|
+
</label>
|
|
211
|
+
<p className="mt-2 text-xs text-muted-foreground">
|
|
212
|
+
{t('bookmarks.supportedFormats')}
|
|
213
|
+
</p>
|
|
214
|
+
</div>
|
|
215
|
+
|
|
216
|
+
{error && (
|
|
217
|
+
<div className="px-4 py-3 rounded-lg border bg-destructive/10 border-destructive/20">
|
|
218
|
+
<p className="text-sm text-destructive">{error}</p>
|
|
219
|
+
</div>
|
|
220
|
+
)}
|
|
221
|
+
</div>
|
|
222
|
+
|
|
223
|
+
<Separator />
|
|
224
|
+
<DialogFooter className="flex-row justify-end">
|
|
225
|
+
<Button variant="secondary" onClick={onClose} disabled={loading}>
|
|
226
|
+
{t('common.cancel')}
|
|
227
|
+
</Button>
|
|
228
|
+
</DialogFooter>
|
|
229
|
+
</DialogContent>
|
|
230
|
+
</Dialog>
|
|
231
|
+
);
|
|
232
|
+
}
|