@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,162 @@
1
+ import { useState } from 'react';
2
+ import {
3
+ Dialog,
4
+ DialogContent,
5
+ DialogHeader,
6
+ DialogTitle,
7
+ DialogFooter,
8
+ } from '../ui/dialog';
9
+ import { Separator } from '../ui/separator';
10
+ import { ModalSection } from '../ui/ModalSection';
11
+ import { ModalFooterActions } from '../ui/ModalFooterActions';
12
+ import { Label } from '../ui/label';
13
+ import Autocomplete from '../ui/Autocomplete';
14
+ import SharingModal from '../modals/SharingModal';
15
+ import api from '../../api/client';
16
+
17
+ interface BulkMoveModalProps {
18
+ isOpen: boolean;
19
+ onClose: () => void;
20
+ onSave: (folderIds: string[]) => void;
21
+ folders: Array<{ id: string; name: string }>;
22
+ t: any;
23
+ }
24
+
25
+ export function BulkMoveModal({ isOpen, onClose, onSave, folders, t }: BulkMoveModalProps) {
26
+ const [selectedFolders, setSelectedFolders] = useState<Array<{ id: string; name: string }>>([]);
27
+
28
+ function handleSubmit(e: React.FormEvent) {
29
+ e.preventDefault();
30
+ onSave(selectedFolders.map(f => f.id));
31
+ onClose();
32
+ }
33
+
34
+ const isValid = selectedFolders.length > 0;
35
+
36
+ return (
37
+ <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
38
+ <DialogContent className="max-w-[460px]">
39
+ <DialogHeader>
40
+ <DialogTitle>{t('bookmarks.bulkMoveToFolder')}</DialogTitle>
41
+ </DialogHeader>
42
+ <Separator />
43
+
44
+ <form id="bulk-move-form" onSubmit={handleSubmit} className="space-y-6">
45
+ <ModalSection>
46
+ <Label className="text-sm font-medium mb-2 block">{t('bookmarks.folders')}</Label>
47
+ <Autocomplete
48
+ value={selectedFolders}
49
+ onChange={setSelectedFolders}
50
+ options={folders}
51
+ placeholder={t('bookmarks.foldersDescription')}
52
+ />
53
+ </ModalSection>
54
+ </form>
55
+
56
+ <Separator />
57
+ <DialogFooter className="flex-row justify-between sm:justify-end gap-2">
58
+ <ModalFooterActions
59
+ onCancel={onClose}
60
+ submitLabel={t('common.save')}
61
+ submitDisabled={!isValid}
62
+ formId="bulk-move-form"
63
+ />
64
+ </DialogFooter>
65
+ </DialogContent>
66
+ </Dialog>
67
+ );
68
+ }
69
+
70
+ interface BulkTagModalProps {
71
+ isOpen: boolean;
72
+ onClose: () => void;
73
+ onSave: (tagIds: string[]) => void;
74
+ tags: Array<{ id: string; name: string }>;
75
+ onTagCreated?: (tag: { id: string; name: string }) => void;
76
+ t: any;
77
+ }
78
+
79
+ export function BulkTagModal({ isOpen, onClose, onSave, tags, onTagCreated, t }: BulkTagModalProps) {
80
+ const [selectedTags, setSelectedTags] = useState<Array<{ id: string; name: string }>>([]);
81
+
82
+ async function handleCreateTag(name: string): Promise<{ id: string; name: string } | null> {
83
+ try {
84
+ const response = await api.post('/tags', { name });
85
+ const newTag = { id: response.data.id, name: response.data.name };
86
+ if (onTagCreated) {
87
+ onTagCreated(newTag);
88
+ }
89
+ return newTag;
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ function handleSubmit(e: React.FormEvent) {
96
+ e.preventDefault();
97
+ onSave(selectedTags.map(tag => tag.id));
98
+ onClose();
99
+ }
100
+
101
+ const isValid = selectedTags.length > 0;
102
+
103
+ return (
104
+ <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
105
+ <DialogContent className="max-w-[460px]">
106
+ <DialogHeader>
107
+ <DialogTitle>{t('bookmarks.bulkAddTags')}</DialogTitle>
108
+ </DialogHeader>
109
+ <Separator />
110
+
111
+ <form id="bulk-tag-form" onSubmit={handleSubmit} className="space-y-6">
112
+ <ModalSection>
113
+ <Label className="text-sm font-medium mb-2 block">{t('bookmarks.tags')}</Label>
114
+ <Autocomplete
115
+ value={selectedTags}
116
+ onChange={setSelectedTags}
117
+ options={tags}
118
+ placeholder={t('bookmarks.tags')}
119
+ onCreateNew={handleCreateTag}
120
+ />
121
+ </ModalSection>
122
+ </form>
123
+
124
+ <Separator />
125
+ <DialogFooter className="flex-row justify-between sm:justify-end gap-2">
126
+ <ModalFooterActions
127
+ onCancel={onClose}
128
+ submitLabel={t('common.save')}
129
+ submitDisabled={!isValid}
130
+ formId="bulk-tag-form"
131
+ />
132
+ </DialogFooter>
133
+ </DialogContent>
134
+ </Dialog>
135
+ );
136
+ }
137
+
138
+ interface BulkShareModalProps {
139
+ isOpen: boolean;
140
+ onClose: () => void;
141
+ onSave: (sharing: { team_ids: string[]; user_ids: string[]; share_all_teams: boolean }) => void;
142
+ teams: Array<{ id: string; name: string }>;
143
+ t?: any;
144
+ }
145
+
146
+ export function BulkShareModal({ isOpen, onClose, onSave, teams }: BulkShareModalProps) {
147
+ return (
148
+ <SharingModal
149
+ isOpen={isOpen}
150
+ onClose={onClose}
151
+ onSave={onSave}
152
+ currentShares={{
153
+ team_ids: [],
154
+ user_ids: [],
155
+ share_all_teams: false,
156
+ }}
157
+ teams={teams}
158
+ type="bookmark"
159
+ allowTeamSharing={teams.length > 0}
160
+ />
161
+ );
162
+ }
@@ -0,0 +1,5 @@
1
+ export { FilterChips } from '../FilterChips';
2
+ export type { FilterChipItem } from '../FilterChips';
3
+
4
+ /** Filter key for bookmark filters (folder_id, tag_id, sort, q, pinned, scope). */
5
+ export type FilterKey = 'folder_id' | 'tag_id' | 'sort' | 'q' | 'pinned' | 'scope';
@@ -0,0 +1,493 @@
1
+ import React, { useState, useEffect, useRef, useCallback } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { useAuth } from '../../contexts/AuthContext';
4
+ import api from '../../api/client';
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 { Switch } from '../ui/switch';
17
+ import { Label } from '../ui/label';
18
+ import { Input } from '../ui/input';
19
+ import Autocomplete from '../ui/Autocomplete';
20
+ import { Copy, Check, Loader2, Plus } from 'lucide-react';
21
+ import { useToast } from '../ui/Toast';
22
+
23
+ const AI_DEBOUNCE_MS = 500;
24
+ const isValidUrl = (url: string): boolean => {
25
+ const t = url.trim();
26
+ return t.length > 0 && /^https?:\/\/.+/.test(t);
27
+ };
28
+
29
+ interface Bookmark {
30
+ id: string;
31
+ title: string;
32
+ url: string;
33
+ slug: string;
34
+ forwarding_enabled: boolean;
35
+ pinned?: boolean;
36
+ owner_user_key?: string;
37
+ folder_id?: string;
38
+ tags?: Array<{ id: string; name: string }>;
39
+ shared_teams?: Array<{ id: string; name: string }>;
40
+ }
41
+
42
+ interface BookmarkModalProps {
43
+ bookmark: Bookmark | null;
44
+ folders: Array<{ id: string; name: string }>;
45
+ tags: Array<{ id: string; name: string }>;
46
+ isOpen: boolean;
47
+ onClose: () => void;
48
+ onTagCreated?: (tag: { id: string; name: string }) => void;
49
+ }
50
+
51
+ export default function BookmarkModal({
52
+ bookmark,
53
+ folders,
54
+ tags,
55
+ isOpen,
56
+ onClose,
57
+ onTagCreated,
58
+ }: BookmarkModalProps) {
59
+ const { t } = useTranslation();
60
+ useAuth();
61
+ const { showToast } = useToast();
62
+ const [formData, setFormData] = useState({
63
+ title: '',
64
+ url: '',
65
+ slug: '',
66
+ forwarding_enabled: false,
67
+ pinned: false,
68
+ folder_ids: [] as string[],
69
+ tag_ids: [] as string[],
70
+ });
71
+ const [loading, setLoading] = useState(false);
72
+ const [error, setError] = useState('');
73
+ const [copied, setCopied] = useState(false);
74
+ const [aiEnabled, setAiEnabled] = useState(false);
75
+ const [aiLoading, setAiLoading] = useState(false);
76
+ const [aiSuggestions, setAiSuggestions] = useState<{
77
+ title?: string;
78
+ slug?: string;
79
+ tags?: string[];
80
+ } | null>(null);
81
+ const abortRef = useRef<AbortController | null>(null);
82
+ const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
83
+
84
+ const handleCreateTag = useCallback(async (name: string): Promise<{ id: string; name: string } | null> => {
85
+ try {
86
+ const response = await api.post('/tags', { name });
87
+ const newTag = { id: response.data.id, name: response.data.name };
88
+ if (onTagCreated) {
89
+ onTagCreated(newTag);
90
+ }
91
+ return newTag;
92
+ } catch {
93
+ return null;
94
+ }
95
+ }, [onTagCreated]);
96
+
97
+ useEffect(() => {
98
+ if (!isOpen) return;
99
+ api.get('/config/ai-suggestions')
100
+ .then((res) => setAiEnabled(res.data?.enabled === true))
101
+ .catch(() => setAiEnabled(false));
102
+ }, [isOpen]);
103
+
104
+ const fetchAISuggestions = useCallback(async (url: string) => {
105
+ if (!aiEnabled || bookmark) return;
106
+ abortRef.current?.abort();
107
+ abortRef.current = new AbortController();
108
+ setAiLoading(true);
109
+ setAiSuggestions(null);
110
+ try {
111
+ const res = await api.post(
112
+ '/bookmarks/ai-suggest',
113
+ { url },
114
+ { signal: abortRef.current.signal }
115
+ );
116
+ const { title, slug, tags: tagNames } = res.data;
117
+ setAiSuggestions({
118
+ title: title || undefined,
119
+ slug: slug || undefined,
120
+ tags: Array.isArray(tagNames) && tagNames.length > 0 ? tagNames : undefined,
121
+ });
122
+ } catch (err: any) {
123
+ if (err?.name !== 'AbortError' && err?.code !== 'ERR_CANCELED') {
124
+ // Silently ignore - bookmark creation never depends on AI
125
+ }
126
+ } finally {
127
+ setAiLoading(false);
128
+ abortRef.current = null;
129
+ }
130
+ }, [aiEnabled, bookmark]);
131
+
132
+ useEffect(() => {
133
+ if (bookmark || !aiEnabled || !isOpen) return;
134
+ const url = formData.url.trim();
135
+ if (!isValidUrl(url)) return;
136
+
137
+ if (debounceRef.current) clearTimeout(debounceRef.current);
138
+ debounceRef.current = setTimeout(() => {
139
+ debounceRef.current = null;
140
+ fetchAISuggestions(url);
141
+ }, AI_DEBOUNCE_MS);
142
+
143
+ return () => {
144
+ if (debounceRef.current) {
145
+ clearTimeout(debounceRef.current);
146
+ debounceRef.current = null;
147
+ }
148
+ };
149
+ }, [formData.url, aiEnabled, bookmark, isOpen, fetchAISuggestions]);
150
+
151
+ useEffect(() => {
152
+ if (bookmark) {
153
+ setFormData({
154
+ title: bookmark.title,
155
+ url: bookmark.url,
156
+ slug: (bookmark.slug && !bookmark.slug.startsWith('_internal_')) ? bookmark.slug : '',
157
+ forwarding_enabled: bookmark.forwarding_enabled,
158
+ pinned: bookmark.pinned ?? false,
159
+ folder_ids: (bookmark as any).folders?.map((f: any) => f.id) || [],
160
+ tag_ids: bookmark.tags?.map((t) => t.id) || [],
161
+ });
162
+ setAiSuggestions(null);
163
+ } else {
164
+ setFormData({
165
+ title: '',
166
+ url: '',
167
+ slug: '',
168
+ forwarding_enabled: false,
169
+ pinned: false,
170
+ folder_ids: [],
171
+ tag_ids: [],
172
+ });
173
+ if (!isOpen) setAiSuggestions(null);
174
+ }
175
+ }, [bookmark, isOpen]);
176
+
177
+ async function handleSubmit(e: React.FormEvent) {
178
+ e.preventDefault();
179
+ setLoading(true);
180
+ setError('');
181
+
182
+ try {
183
+ const payload: any = {
184
+ title: formData.title,
185
+ url: formData.url,
186
+ forwarding_enabled: formData.forwarding_enabled,
187
+ pinned: formData.pinned,
188
+ folder_ids: formData.folder_ids.length > 0 ? formData.folder_ids : undefined,
189
+ tag_ids: formData.tag_ids.length > 0 ? formData.tag_ids : undefined,
190
+ };
191
+
192
+ if (formData.forwarding_enabled) {
193
+ if (!formData.slug || !formData.slug.trim()) {
194
+ setError(t('bookmarks.slugRequired'));
195
+ setLoading(false);
196
+ return;
197
+ }
198
+ payload.slug = formData.slug.trim();
199
+ } else {
200
+ payload.slug = formData.slug && formData.slug.trim() ? formData.slug.trim() : '';
201
+ }
202
+
203
+ if (aiSuggestions != null) {
204
+ const titleUsed = Boolean(aiSuggestions.title && formData.title.trim() === aiSuggestions.title);
205
+ const slugUsed = Boolean(aiSuggestions.slug && formData.slug?.trim() === aiSuggestions.slug);
206
+ const selectedTagsForSubmit = tags.filter((t) => formData.tag_ids.includes(t.id));
207
+ const selectedTagNamesLower = new Set(selectedTagsForSubmit.map((t) => t.name.toLowerCase()));
208
+ const tagsUsed =
209
+ Boolean(aiSuggestions.tags?.length) &&
210
+ aiSuggestions.tags!.every((name: string) => selectedTagNamesLower.has(name.toLowerCase()));
211
+ payload.ai_suggestion_used = { title: titleUsed, slug: slugUsed, tags: tagsUsed };
212
+ }
213
+
214
+ Object.keys(payload).forEach(key => {
215
+ if (payload[key] === undefined) {
216
+ delete payload[key];
217
+ }
218
+ });
219
+ if (bookmark) {
220
+ await api.put(`/bookmarks/${bookmark.id}`, payload);
221
+ } else {
222
+ await api.post('/bookmarks', payload);
223
+ }
224
+ onClose();
225
+ } catch (err: any) {
226
+ const errorMessage = err.response?.data?.error || t('common.error');
227
+ const code = err.response?.data?.code;
228
+ setError(errorMessage);
229
+ if (code === 'PLAN_LIMIT_BOOKMARKS' || code === 'PLAN_SHARE_TO_TEAM' || code === 'PLAN_FOLDER_SHARING') {
230
+ showToast(errorMessage, 'error');
231
+ }
232
+ } finally {
233
+ setLoading(false);
234
+ }
235
+ }
236
+
237
+ const selectedTags = tags.filter((tag) => formData.tag_ids.includes(tag.id));
238
+ const selectedFolders = folders.filter((folder) => formData.folder_ids.includes(folder.id));
239
+ const isValid = formData.title.trim() && formData.url.trim();
240
+ const slugError = formData.forwarding_enabled && !formData.slug?.trim() ? t('bookmarks.slugRequired') : undefined;
241
+
242
+ const handleTagChange = (newTags: Array<{ id: string; name: string }>) => {
243
+ setFormData({ ...formData, tag_ids: newTags.map((t) => t.id) });
244
+ };
245
+
246
+ const handleFolderChange = (newFolders: Array<{ id: string; name: string }>) => {
247
+ setFormData({ ...formData, folder_ids: newFolders.map((f) => f.id) });
248
+ };
249
+
250
+ const handleAddTitleSuggestion = () => {
251
+ if (aiSuggestions?.title) {
252
+ setFormData((prev) => ({ ...prev, title: aiSuggestions.title! }));
253
+ }
254
+ };
255
+
256
+ const handleAddSlugSuggestion = () => {
257
+ if (aiSuggestions?.slug) {
258
+ setFormData((prev) => ({ ...prev, slug: aiSuggestions.slug! }));
259
+ }
260
+ };
261
+
262
+ const handleAddTagSuggestion = async (tagName: string) => {
263
+ const existingByName = new Map(tags.map((t) => [t.name.toLowerCase(), t]));
264
+ const existing = existingByName.get(tagName.toLowerCase());
265
+ if (existing) {
266
+ setFormData((prev) => ({
267
+ ...prev,
268
+ tag_ids: [...new Set([...prev.tag_ids, existing.id])],
269
+ }));
270
+ } else {
271
+ const created = await handleCreateTag(tagName);
272
+ if (created) {
273
+ setFormData((prev) => ({
274
+ ...prev,
275
+ tag_ids: [...new Set([...prev.tag_ids, created.id])],
276
+ }));
277
+ }
278
+ }
279
+ };
280
+
281
+ const selectedTagNames = new Set(selectedTags.map((t) => t.name.toLowerCase()));
282
+
283
+ return (
284
+ <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
285
+ <DialogContent className="max-w-2xl">
286
+ <DialogHeader>
287
+ <DialogTitle>{bookmark ? t('bookmarks.edit') : t('bookmarks.create')}</DialogTitle>
288
+ </DialogHeader>
289
+ <Separator />
290
+
291
+ <form id="bookmark-form" onSubmit={handleSubmit} className="space-y-6">
292
+ <ModalSection>
293
+ <FormFieldWrapper
294
+ label={t('bookmarks.name')}
295
+ required
296
+ error={error}
297
+ >
298
+ <Input
299
+ type="text"
300
+ required
301
+ value={formData.title}
302
+ onChange={(e) => setFormData({ ...formData, title: e.target.value })}
303
+ placeholder={t('bookmarks.name')}
304
+ />
305
+ {aiLoading && (
306
+ <p className="mt-1.5 flex items-center gap-1.5 text-xs text-muted-foreground">
307
+ <Loader2 className="h-3.5 w-3.5 animate-spin" />
308
+ {t('bookmarks.aiSuggesting')}
309
+ </p>
310
+ )}
311
+ {!aiLoading && aiSuggestions?.title && (
312
+ <div className="mt-1.5 flex flex-wrap items-center gap-1.5">
313
+ <span className="text-xs text-muted-foreground">{t('bookmarks.aiSuggestionsLabel')}</span>
314
+ <button
315
+ type="button"
316
+ onClick={handleAddTitleSuggestion}
317
+ className="inline-flex items-center gap-1 rounded-full bg-muted px-2.5 py-1 text-xs hover:bg-muted/80 transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
318
+ >
319
+ <span className="max-w-[200px] truncate">{aiSuggestions.title}</span>
320
+ <Plus className="h-3 w-3 shrink-0" aria-hidden />
321
+ </button>
322
+ </div>
323
+ )}
324
+ </FormFieldWrapper>
325
+ <FormFieldWrapper label={t('bookmarks.url')} required>
326
+ <Input
327
+ type="url"
328
+ required
329
+ value={formData.url}
330
+ onChange={(e) => setFormData({ ...formData, url: e.target.value })}
331
+ placeholder={t('bookmarks.url')}
332
+ />
333
+ </FormFieldWrapper>
334
+ </ModalSection>
335
+
336
+ <Separator />
337
+
338
+ <ModalSection title={t('bookmarks.folders')}>
339
+ {folders.length > 0 ? (
340
+ <Autocomplete
341
+ value={selectedFolders}
342
+ onChange={handleFolderChange}
343
+ options={folders}
344
+ placeholder={t('bookmarks.foldersDescription')}
345
+ />
346
+ ) : (
347
+ <p className="text-sm text-muted-foreground">
348
+ {t('bookmarks.noFoldersAvailable')}
349
+ </p>
350
+ )}
351
+ <div>
352
+ <Label className="text-sm font-medium mb-2 block">{t('bookmarks.tags')}</Label>
353
+ <Autocomplete
354
+ value={selectedTags}
355
+ onChange={handleTagChange}
356
+ options={tags}
357
+ placeholder={t('bookmarks.tags')}
358
+ onCreateNew={handleCreateTag}
359
+ />
360
+ {aiLoading && (
361
+ <p className="mt-1.5 flex items-center gap-1.5 text-xs text-muted-foreground">
362
+ <Loader2 className="h-3.5 w-3.5 animate-spin" />
363
+ {t('bookmarks.aiSuggesting')}
364
+ </p>
365
+ )}
366
+ {!aiLoading && aiSuggestions?.tags && aiSuggestions.tags.length > 0 && (
367
+ <div className="mt-1.5 flex flex-wrap items-center gap-1.5">
368
+ <span className="text-xs text-muted-foreground">{t('bookmarks.aiSuggestionsLabel')}</span>
369
+ {aiSuggestions.tags
370
+ .filter((name) => !selectedTagNames.has(name.toLowerCase()))
371
+ .map((tagName) => (
372
+ <button
373
+ key={tagName}
374
+ type="button"
375
+ onClick={() => handleAddTagSuggestion(tagName)}
376
+ className="inline-flex items-center gap-1 rounded-full bg-muted px-2.5 py-1 text-xs hover:bg-muted/80 transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
377
+ >
378
+ {tagName}
379
+ <Plus className="h-3 w-3 shrink-0" aria-hidden />
380
+ </button>
381
+ ))}
382
+ </div>
383
+ )}
384
+ </div>
385
+ </ModalSection>
386
+
387
+ <div className="flex items-center justify-between rounded-lg border p-3">
388
+ <Label htmlFor="pinned" className="text-sm font-medium cursor-pointer">
389
+ {t('bookmarks.pinned')}
390
+ </Label>
391
+ <Switch
392
+ id="pinned"
393
+ checked={formData.pinned}
394
+ onCheckedChange={(checked) => setFormData({ ...formData, pinned: !!checked })}
395
+ />
396
+ </div>
397
+ <div className="flex items-center justify-between rounded-lg border p-3">
398
+ <Label htmlFor="forwarding" className="text-sm font-medium cursor-pointer">
399
+ {t('bookmarks.forwardingEnabled')}
400
+ </Label>
401
+ <Switch
402
+ id="forwarding"
403
+ checked={formData.forwarding_enabled}
404
+ onCheckedChange={(checked) =>
405
+ setFormData({ ...formData, forwarding_enabled: checked })
406
+ }
407
+ />
408
+ </div>
409
+
410
+ {formData.forwarding_enabled && (
411
+ <>
412
+ <Separator />
413
+ <ModalSection>
414
+ <FormFieldWrapper
415
+ label={t('bookmarks.slug')}
416
+ required
417
+ error={slugError}
418
+ >
419
+ <Input
420
+ type="text"
421
+ value={formData.slug || ''}
422
+ onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
423
+ placeholder={t('bookmarks.slug')}
424
+ />
425
+ {aiLoading && (
426
+ <p className="mt-1.5 flex items-center gap-1.5 text-xs text-muted-foreground">
427
+ <Loader2 className="h-3.5 w-3.5 animate-spin" />
428
+ {t('bookmarks.aiSuggesting')}
429
+ </p>
430
+ )}
431
+ {!aiLoading && aiSuggestions?.slug && (
432
+ <div className="mt-1.5 flex flex-wrap items-center gap-1.5">
433
+ <span className="text-xs text-muted-foreground">{t('bookmarks.aiSuggestionsLabel')}</span>
434
+ <button
435
+ type="button"
436
+ onClick={handleAddSlugSuggestion}
437
+ className="inline-flex items-center gap-1 rounded-full bg-muted px-2.5 py-1 text-xs hover:bg-muted/80 transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
438
+ >
439
+ {aiSuggestions.slug}
440
+ <Plus className="h-3 w-3 shrink-0" aria-hidden />
441
+ </button>
442
+ </div>
443
+ )}
444
+ </FormFieldWrapper>
445
+ {formData.slug && (
446
+ <div className="rounded-lg border bg-muted/50 p-3 space-y-1">
447
+ <p className="text-xs font-medium text-muted-foreground">
448
+ {t('bookmarks.forwardingPreview')}
449
+ </p>
450
+ <div className="flex items-center gap-2">
451
+ <code className="flex-1 text-xs font-mono truncate">
452
+ {window.location.origin}/go/{formData.slug}
453
+ </code>
454
+ <button
455
+ type="button"
456
+ onClick={() => {
457
+ const url = `${window.location.origin}/go/${formData.slug}`;
458
+ navigator.clipboard.writeText(url);
459
+ setCopied(true);
460
+ showToast(t('common.copied'), 'success');
461
+ setTimeout(() => setCopied(false), 2000);
462
+ }}
463
+ className="p-1.5 rounded-md hover:bg-muted transition-colors focus-visible:ring-2 focus-visible:ring-ring"
464
+ title={t('bookmarks.copyUrl')}
465
+ aria-label={t('bookmarks.copyUrl')}
466
+ >
467
+ {copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
468
+ </button>
469
+ </div>
470
+ <p className="text-xs text-muted-foreground">
471
+ {t('bookmarks.forwardingPreviewDescription')}
472
+ </p>
473
+ </div>
474
+ )}
475
+ </ModalSection>
476
+ </>
477
+ )}
478
+ </form>
479
+
480
+ <Separator />
481
+ <DialogFooter className="flex-row justify-between sm:justify-end gap-2">
482
+ <ModalFooterActions
483
+ onCancel={onClose}
484
+ submitLabel={t('common.save')}
485
+ loading={loading}
486
+ submitDisabled={!isValid || !!slugError}
487
+ formId="bookmark-form"
488
+ />
489
+ </DialogFooter>
490
+ </DialogContent>
491
+ </Dialog>
492
+ );
493
+ }