@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,284 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import Select from '../ui/Select';
4
+ import { Key } from 'lucide-react';
5
+ import api from '../../api/client';
6
+ import {
7
+ Dialog,
8
+ DialogContent,
9
+ DialogHeader,
10
+ DialogTitle,
11
+ DialogFooter,
12
+ } from '../ui/dialog';
13
+ import { Separator } from '../ui/separator';
14
+ import { FormFieldWrapper } from '../ui/FormFieldWrapper';
15
+ import { ModalSection } from '../ui/ModalSection';
16
+ import { ModalFooterActions } from '../ui/ModalFooterActions';
17
+ import { Switch } from '../ui/switch';
18
+ import { Label } from '../ui/label';
19
+ import { Input } from '../ui/input';
20
+
21
+ interface OIDCProvider {
22
+ id: string;
23
+ provider_key: string;
24
+ issuer_url: string;
25
+ authorization_url?: string;
26
+ token_url?: string;
27
+ userinfo_url?: string;
28
+ scopes: string;
29
+ auto_create_users: boolean;
30
+ default_role: string;
31
+ }
32
+
33
+ interface OIDCProviderModalProps {
34
+ provider: OIDCProvider | null;
35
+ isOpen: boolean;
36
+ onClose: () => void;
37
+ onSuccess: () => void;
38
+ }
39
+
40
+ export default function OIDCProviderModal({
41
+ provider,
42
+ isOpen,
43
+ onClose,
44
+ onSuccess,
45
+ }: OIDCProviderModalProps) {
46
+ const { t } = useTranslation();
47
+ const [formData, setFormData] = useState({
48
+ provider_key: '',
49
+ client_id: '',
50
+ client_secret: '',
51
+ issuer_url: '',
52
+ authorization_url: '',
53
+ token_url: '',
54
+ userinfo_url: '',
55
+ scopes: 'openid profile email',
56
+ auto_create_users: true,
57
+ default_role: 'user',
58
+ });
59
+ const [loading, setLoading] = useState(false);
60
+ const [error, setError] = useState('');
61
+
62
+ useEffect(() => {
63
+ if (provider) {
64
+ setFormData({
65
+ provider_key: provider.provider_key,
66
+ client_id: '',
67
+ client_secret: '',
68
+ issuer_url: provider.issuer_url,
69
+ authorization_url: provider.authorization_url || '',
70
+ token_url: provider.token_url || '',
71
+ userinfo_url: provider.userinfo_url || '',
72
+ scopes: provider.scopes,
73
+ auto_create_users: provider.auto_create_users,
74
+ default_role: provider.default_role,
75
+ });
76
+ } else {
77
+ setFormData({
78
+ provider_key: '',
79
+ client_id: '',
80
+ client_secret: '',
81
+ issuer_url: '',
82
+ authorization_url: '',
83
+ token_url: '',
84
+ userinfo_url: '',
85
+ scopes: 'openid profile email',
86
+ auto_create_users: true,
87
+ default_role: 'user',
88
+ });
89
+ }
90
+ setError('');
91
+ }, [provider, isOpen]);
92
+
93
+ async function handleSubmit(e: React.FormEvent) {
94
+ e.preventDefault();
95
+ setLoading(true);
96
+ setError('');
97
+
98
+ try {
99
+ const payload: any = { ...formData };
100
+
101
+ if (provider) {
102
+ if (!payload.client_id || payload.client_id.trim() === '') {
103
+ delete payload.client_id;
104
+ }
105
+ if (!payload.client_secret || payload.client_secret.trim() === '') {
106
+ delete payload.client_secret;
107
+ }
108
+ } else {
109
+ if (!payload.client_id || payload.client_id.trim() === '') {
110
+ setError(t('admin.clientIdRequired'));
111
+ setLoading(false);
112
+ return;
113
+ }
114
+ }
115
+
116
+ if (!payload.authorization_url || payload.authorization_url.trim() === '') {
117
+ delete payload.authorization_url;
118
+ }
119
+ if (!payload.token_url || payload.token_url.trim() === '') {
120
+ delete payload.token_url;
121
+ }
122
+ if (!payload.userinfo_url || payload.userinfo_url.trim() === '') {
123
+ delete payload.userinfo_url;
124
+ }
125
+
126
+ if (provider) {
127
+ await api.put(`/oidc-providers/${provider.id}`, payload);
128
+ } else {
129
+ await api.post('/oidc-providers', payload);
130
+ }
131
+ onSuccess();
132
+ onClose();
133
+ } catch (err: any) {
134
+ setError(err.response?.data?.error || t('common.error'));
135
+ } finally {
136
+ setLoading(false);
137
+ }
138
+ }
139
+
140
+ const roleOptions = [
141
+ { value: 'user', label: t('admin.user') },
142
+ { value: 'admin', label: t('admin.admin') },
143
+ ];
144
+
145
+ const isValid = formData.provider_key.trim() && formData.issuer_url.trim() && (provider || formData.client_id.trim());
146
+
147
+ return (
148
+ <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
149
+ <DialogContent className="max-w-2xl max-h-[calc(100vh-4rem)] overflow-y-auto">
150
+ <DialogHeader>
151
+ <DialogTitle>{provider ? t('admin.editProvider') : t('admin.addProvider')}</DialogTitle>
152
+ </DialogHeader>
153
+ <Separator />
154
+
155
+ <form id="oidc-provider-form" onSubmit={handleSubmit} className="space-y-6">
156
+ <ModalSection>
157
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
158
+ <FormFieldWrapper label={t('admin.providerKey')} required error={error}>
159
+ <div className="flex items-center gap-2">
160
+ <Key className="h-4 w-4 text-muted-foreground" />
161
+ <Input
162
+ type="text"
163
+ required
164
+ value={formData.provider_key}
165
+ onChange={(e) => setFormData({ ...formData, provider_key: e.target.value })}
166
+ placeholder={t('admin.providerKey')}
167
+ />
168
+ </div>
169
+ </FormFieldWrapper>
170
+ <FormFieldWrapper label={t('admin.issuerUrl')} required>
171
+ <Input
172
+ type="url"
173
+ required
174
+ value={formData.issuer_url}
175
+ onChange={(e) => setFormData({ ...formData, issuer_url: e.target.value })}
176
+ placeholder={t('admin.issuerUrl')}
177
+ />
178
+ </FormFieldWrapper>
179
+ </div>
180
+
181
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
182
+ <FormFieldWrapper
183
+ label={provider ? `${t('admin.clientId')} (${t('admin.leaveBlankToKeep')})` : t('admin.clientId')}
184
+ required={!provider}
185
+ >
186
+ <Input
187
+ type="text"
188
+ required={!provider}
189
+ value={formData.client_id}
190
+ onChange={(e) => setFormData({ ...formData, client_id: e.target.value })}
191
+ placeholder={provider ? t('admin.leaveBlankToKeep') : ''}
192
+ />
193
+ </FormFieldWrapper>
194
+ <FormFieldWrapper
195
+ label={provider ? `${t('admin.clientSecret')} (${t('admin.leaveBlank')})` : t('admin.clientSecret')}
196
+ required={!provider}
197
+ >
198
+ <Input
199
+ type="password"
200
+ required={!provider}
201
+ value={formData.client_secret}
202
+ onChange={(e) => setFormData({ ...formData, client_secret: e.target.value })}
203
+ />
204
+ </FormFieldWrapper>
205
+ </div>
206
+
207
+ <FormFieldWrapper label={t('admin.scopes')}>
208
+ <Input
209
+ type="text"
210
+ value={formData.scopes}
211
+ onChange={(e) => setFormData({ ...formData, scopes: e.target.value })}
212
+ placeholder="openid profile email"
213
+ />
214
+ </FormFieldWrapper>
215
+ </ModalSection>
216
+
217
+ <Separator />
218
+
219
+ <ModalSection title={`${t('admin.customEndpoints')} (${t('admin.optional')})`} description={t('admin.customEndpointsDescription')}>
220
+ <div className="space-y-4">
221
+ <FormFieldWrapper label={t('admin.authorizationUrl')}>
222
+ <Input
223
+ type="url"
224
+ placeholder={`${formData.issuer_url || 'https://issuer.com'}/authorize`}
225
+ value={formData.authorization_url}
226
+ onChange={(e) => setFormData({ ...formData, authorization_url: e.target.value })}
227
+ />
228
+ </FormFieldWrapper>
229
+ <FormFieldWrapper label={t('admin.tokenUrl')}>
230
+ <Input
231
+ type="url"
232
+ placeholder={`${formData.issuer_url || 'https://issuer.com'}/token`}
233
+ value={formData.token_url}
234
+ onChange={(e) => setFormData({ ...formData, token_url: e.target.value })}
235
+ />
236
+ </FormFieldWrapper>
237
+ <FormFieldWrapper label={t('admin.userinfoUrl')}>
238
+ <Input
239
+ type="url"
240
+ placeholder={`${formData.issuer_url || 'https://issuer.com'}/userinfo`}
241
+ value={formData.userinfo_url}
242
+ onChange={(e) => setFormData({ ...formData, userinfo_url: e.target.value })}
243
+ />
244
+ </FormFieldWrapper>
245
+ </div>
246
+ </ModalSection>
247
+
248
+ <ModalSection>
249
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
250
+ <div className="flex items-center justify-between rounded-lg border p-3">
251
+ <Label htmlFor="auto_create" className="text-sm font-medium cursor-pointer">
252
+ {t('admin.autoCreate')}
253
+ </Label>
254
+ <Switch
255
+ id="auto_create"
256
+ checked={formData.auto_create_users}
257
+ onCheckedChange={(checked) => setFormData({ ...formData, auto_create_users: checked })}
258
+ />
259
+ </div>
260
+ <FormFieldWrapper label={t('admin.defaultRole')}>
261
+ <Select
262
+ value={formData.default_role}
263
+ onChange={(value) => setFormData({ ...formData, default_role: value })}
264
+ options={roleOptions}
265
+ />
266
+ </FormFieldWrapper>
267
+ </div>
268
+ </ModalSection>
269
+ </form>
270
+
271
+ <Separator />
272
+ <DialogFooter className="flex-row justify-between sm:justify-end gap-2">
273
+ <ModalFooterActions
274
+ onCancel={onClose}
275
+ submitLabel={t('common.save')}
276
+ loading={loading}
277
+ submitDisabled={!isValid}
278
+ formId="oidc-provider-form"
279
+ />
280
+ </DialogFooter>
281
+ </DialogContent>
282
+ </Dialog>
283
+ );
284
+ }
@@ -0,0 +1,96 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import {
4
+ Dialog,
5
+ DialogContent,
6
+ DialogHeader,
7
+ DialogTitle,
8
+ DialogFooter,
9
+ } from '../ui/dialog';
10
+ import { Separator } from '../ui/separator';
11
+ import { ModalFooterActions } from '../ui/ModalFooterActions';
12
+ import { SharingField } from '../ui/SharingField';
13
+
14
+ interface Team {
15
+ id: string;
16
+ name: string;
17
+ description?: string;
18
+ }
19
+
20
+ interface SharingModalProps {
21
+ isOpen: boolean;
22
+ onClose: () => void;
23
+ onSave: (sharing: { user_ids: string[]; team_ids: string[]; share_all_teams: boolean }) => void;
24
+ currentShares?: {
25
+ user_ids?: string[];
26
+ team_ids?: string[];
27
+ share_all_teams?: boolean;
28
+ };
29
+ teams: Team[];
30
+ type?: 'bookmark' | 'folder';
31
+ allowTeamSharing?: boolean;
32
+ }
33
+
34
+ export default function SharingModal({
35
+ isOpen,
36
+ onClose,
37
+ onSave,
38
+ currentShares = {},
39
+ teams,
40
+ allowTeamSharing = true,
41
+ }: SharingModalProps) {
42
+ const { t } = useTranslation();
43
+ const [sharing, setSharing] = useState({
44
+ user_ids: currentShares.user_ids || [],
45
+ team_ids: currentShares.team_ids || [],
46
+ share_all_teams: currentShares.share_all_teams || false,
47
+ });
48
+
49
+ useEffect(() => {
50
+ if (isOpen) {
51
+ setSharing({
52
+ user_ids: currentShares.user_ids || [],
53
+ team_ids: currentShares.team_ids || [],
54
+ share_all_teams: currentShares.share_all_teams || false,
55
+ });
56
+ }
57
+ }, [isOpen, currentShares.user_ids, currentShares.team_ids, currentShares.share_all_teams]);
58
+
59
+ function handleSave() {
60
+ onSave({
61
+ user_ids: sharing.user_ids,
62
+ team_ids: allowTeamSharing ? (sharing.share_all_teams ? [] : sharing.team_ids) : [],
63
+ share_all_teams: allowTeamSharing && sharing.share_all_teams,
64
+ });
65
+ onClose();
66
+ }
67
+
68
+ return (
69
+ <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
70
+ <DialogContent className="max-w-lg">
71
+ <DialogHeader>
72
+ <DialogTitle>{t('bookmarks.shareWithTeams')}</DialogTitle>
73
+ </DialogHeader>
74
+ <Separator />
75
+
76
+ <form id="sharing-form" onSubmit={(e) => { e.preventDefault(); handleSave(); }} className="space-y-6">
77
+ <SharingField
78
+ value={sharing}
79
+ onChange={setSharing}
80
+ teams={teams}
81
+ allowTeamSharing={allowTeamSharing}
82
+ />
83
+ </form>
84
+
85
+ <Separator />
86
+ <DialogFooter className="flex-row justify-between sm:justify-end gap-2">
87
+ <ModalFooterActions
88
+ onCancel={onClose}
89
+ submitLabel={t('common.save')}
90
+ formId="sharing-form"
91
+ />
92
+ </DialogFooter>
93
+ </DialogContent>
94
+ </Dialog>
95
+ );
96
+ }
@@ -0,0 +1,101 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import api from '../../api/client';
4
+ import {
5
+ Dialog,
6
+ DialogContent,
7
+ DialogHeader,
8
+ DialogTitle,
9
+ DialogFooter,
10
+ } from '../ui/dialog';
11
+ import { Separator } from '../ui/separator';
12
+ import { FormFieldWrapper } from '../ui/FormFieldWrapper';
13
+ import { ModalSection } from '../ui/ModalSection';
14
+ import { ModalFooterActions } from '../ui/ModalFooterActions';
15
+ import { Input } from '../ui/input';
16
+
17
+ interface Tag {
18
+ id: string;
19
+ name: string;
20
+ }
21
+
22
+ interface TagModalProps {
23
+ tag: Tag | null;
24
+ isOpen: boolean;
25
+ onClose: () => void;
26
+ onSuccess: () => void;
27
+ }
28
+
29
+ export default function TagModal({ tag, isOpen, onClose, onSuccess }: TagModalProps) {
30
+ const { t } = useTranslation();
31
+ const [formData, setFormData] = useState({ name: '' });
32
+ const [loading, setLoading] = useState(false);
33
+ const [error, setError] = useState('');
34
+
35
+ useEffect(() => {
36
+ if (tag) {
37
+ setFormData({ name: tag.name });
38
+ } else {
39
+ setFormData({ name: '' });
40
+ }
41
+ setError('');
42
+ }, [tag, isOpen]);
43
+
44
+ async function handleSubmit(e: React.FormEvent) {
45
+ e.preventDefault();
46
+ setLoading(true);
47
+ setError('');
48
+
49
+ try {
50
+ if (tag) {
51
+ await api.put(`/tags/${tag.id}`, formData);
52
+ } else {
53
+ await api.post('/tags', formData);
54
+ }
55
+ onSuccess();
56
+ onClose();
57
+ } catch (err: any) {
58
+ setError(err.response?.data?.error || t('common.error'));
59
+ } finally {
60
+ setLoading(false);
61
+ }
62
+ }
63
+
64
+ const isValid = formData.name.trim();
65
+
66
+ return (
67
+ <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
68
+ <DialogContent className="max-w-md">
69
+ <DialogHeader>
70
+ <DialogTitle>{tag ? t('tags.edit') : t('tags.create')}</DialogTitle>
71
+ </DialogHeader>
72
+ <Separator />
73
+
74
+ <form id="tag-form" onSubmit={handleSubmit} className="space-y-6">
75
+ <ModalSection>
76
+ <FormFieldWrapper label={t('tags.name')} required error={error}>
77
+ <Input
78
+ type="text"
79
+ required
80
+ value={formData.name}
81
+ onChange={(e) => setFormData({ name: e.target.value })}
82
+ placeholder={t('tags.name')}
83
+ />
84
+ </FormFieldWrapper>
85
+ </ModalSection>
86
+ </form>
87
+
88
+ <Separator />
89
+ <DialogFooter className="flex-row justify-between sm:justify-end gap-2">
90
+ <ModalFooterActions
91
+ onCancel={onClose}
92
+ submitLabel={t('common.save')}
93
+ loading={loading}
94
+ submitDisabled={!isValid}
95
+ formId="tag-form"
96
+ />
97
+ </DialogFooter>
98
+ </DialogContent>
99
+ </Dialog>
100
+ );
101
+ }