@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,593 @@
1
+ import React, { useState, useEffect, useCallback } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { Link } from 'react-router-dom';
4
+ import { useAuth } from '../contexts/AuthContext';
5
+ import { useAppConfig } from '../contexts/AppConfigContext';
6
+ import { AlertCircle, Key } from 'lucide-react';
7
+ import Select from '../components/ui/Select';
8
+ import Button from '../components/ui/Button';
9
+ import { Switch } from '../components/ui/switch';
10
+ import { useToast } from '../components/ui/Toast';
11
+ import ConfirmDialog from '../components/ui/ConfirmDialog';
12
+ import CreateTokenModal from '../components/profile/CreateTokenModal';
13
+ import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '../components/ui/card';
14
+ import { Badge } from '../components/ui/badge';
15
+ import { Input } from '../components/ui/input';
16
+ import api from '../api/client';
17
+
18
+ interface ApiToken {
19
+ id: string;
20
+ name: string;
21
+ created_at: string;
22
+ last_used_at: string | null;
23
+ }
24
+
25
+ function SettingsRow({
26
+ label,
27
+ value,
28
+ action,
29
+ helper,
30
+ children,
31
+ ariaLabel,
32
+ }: {
33
+ label: string;
34
+ value?: React.ReactNode;
35
+ action?: React.ReactNode;
36
+ helper?: React.ReactNode;
37
+ children?: React.ReactNode;
38
+ ariaLabel?: string;
39
+ }) {
40
+ return (
41
+ <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 py-3 border-b border-border last:border-0">
42
+ <div className="min-w-0 flex-1">
43
+ <dt className="text-sm font-medium text-foreground">{label}</dt>
44
+ {value !== undefined && (
45
+ <dd className="mt-0.5 text-sm text-muted-foreground">{value}</dd>
46
+ )}
47
+ {helper && <dd className="mt-0.5 text-xs text-muted-foreground">{helper}</dd>}
48
+ {children}
49
+ </div>
50
+ {action && (
51
+ <div className="flex-shrink-0 sm:ml-4" aria-label={ariaLabel}>
52
+ {action}
53
+ </div>
54
+ )}
55
+ </div>
56
+ );
57
+ }
58
+
59
+ export default function Profile() {
60
+ const { t } = useTranslation();
61
+ const { appBasePath, apiBaseUrl } = useAppConfig();
62
+ const { user, updateUser, checkAuth } = useAuth();
63
+ const { showToast } = useToast();
64
+ const [formData, setFormData] = useState({
65
+ email: '',
66
+ name: '',
67
+ language: 'en',
68
+ theme: 'auto',
69
+ ai_suggestions_enabled: true,
70
+ });
71
+ const [aiAvailable, setAiAvailable] = useState(false);
72
+ const [saving, setSaving] = useState(false);
73
+ const [errors, setErrors] = useState<{ email?: string; name?: string }>({});
74
+ const [editingEmail, setEditingEmail] = useState(false);
75
+ const [editingName, setEditingName] = useState(false);
76
+ const [tokens, setTokens] = useState<ApiToken[]>([]);
77
+ const [tokensLoading, setTokensLoading] = useState(true);
78
+ const [createTokenOpen, setCreateTokenOpen] = useState(false);
79
+ const [revokeTokenId, setRevokeTokenId] = useState<string | null>(null);
80
+
81
+ useEffect(() => {
82
+ if (user) {
83
+ setFormData({
84
+ email: user.email || '',
85
+ name: user.name || '',
86
+ language: user.language || 'en',
87
+ theme: user.theme || 'auto',
88
+ ai_suggestions_enabled: Boolean((user as { ai_suggestions_enabled?: boolean | number }).ai_suggestions_enabled ?? true),
89
+ });
90
+ }
91
+ }, [user]);
92
+
93
+ useEffect(() => {
94
+ if (user) {
95
+ api.get('/config/ai-suggestions')
96
+ .then((res) => setAiAvailable(res.data?.available === true))
97
+ .catch(() => setAiAvailable(false));
98
+ }
99
+ }, [user]);
100
+
101
+ const fetchTokens = useCallback(() => {
102
+ setTokensLoading(true);
103
+ api.get('/tokens').then((res) => {
104
+ setTokens(Array.isArray(res.data) ? res.data : []);
105
+ }).catch(() => setTokens([])).finally(() => setTokensLoading(false));
106
+ }, []);
107
+
108
+ useEffect(() => {
109
+ if (user) fetchTokens();
110
+ }, [user, fetchTokens]);
111
+
112
+ const preferencesDirty = user && (
113
+ formData.language !== (user.language || 'en') ||
114
+ formData.theme !== (user.theme || 'auto') ||
115
+ formData.ai_suggestions_enabled !== Boolean((user as { ai_suggestions_enabled?: boolean | number }).ai_suggestions_enabled ?? true)
116
+ );
117
+
118
+ async function handleSubmit(e: React.FormEvent) {
119
+ e.preventDefault();
120
+ setSaving(true);
121
+ setErrors({});
122
+ try {
123
+ const response = (await updateUser(formData)) as unknown;
124
+ const data = response && typeof response === 'object' && 'emailVerificationRequired' in response
125
+ ? (response as { emailVerificationRequired?: boolean })
126
+ : null;
127
+ if (data?.emailVerificationRequired) {
128
+ showToast(t('emailVerification.emailSent'), 'success');
129
+ setEditingEmail(false);
130
+ await checkAuth();
131
+ } else {
132
+ setEditingEmail(false);
133
+ setEditingName(false);
134
+ showToast(t('common.success'), 'success');
135
+ }
136
+ } catch (error: unknown) {
137
+ const err = error as { response?: { data?: { error?: string } } };
138
+ console.error('Failed to update profile:', err);
139
+ if (err.response?.data?.error) {
140
+ const errorMessage = err.response.data.error;
141
+ if (errorMessage.includes('email')) {
142
+ setErrors({ email: errorMessage });
143
+ } else if (errorMessage.includes('name') || errorMessage.includes('Name')) {
144
+ setErrors({ name: errorMessage });
145
+ } else {
146
+ setErrors({ email: errorMessage, name: errorMessage });
147
+ }
148
+ showToast(errorMessage, 'error');
149
+ } else {
150
+ showToast(t('common.error'), 'error');
151
+ }
152
+ } finally {
153
+ setSaving(false);
154
+ }
155
+ }
156
+
157
+ async function handleRevokeToken(tokenId: string) {
158
+ try {
159
+ await api.delete(`/tokens/${tokenId}`);
160
+ setTokens((prev) => prev.filter((tok) => tok.id !== tokenId));
161
+ showToast(t('common.success'), 'success');
162
+ } catch (err: unknown) {
163
+ const e = err as { response?: { data?: { error?: string } } };
164
+ showToast(e.response?.data?.error || t('common.error'), 'error');
165
+ } finally {
166
+ setRevokeTokenId(null);
167
+ }
168
+ }
169
+
170
+ function formatDate(iso: string) {
171
+ try {
172
+ return new Date(iso).toLocaleDateString(undefined, {
173
+ year: 'numeric',
174
+ month: 'short',
175
+ day: 'numeric',
176
+ hour: '2-digit',
177
+ minute: '2-digit',
178
+ });
179
+ } catch {
180
+ return iso;
181
+ }
182
+ }
183
+
184
+ if (!user) {
185
+ return (
186
+ <div className="flex items-center justify-center min-h-[400px]">
187
+ <div className="text-muted-foreground">{t('common.loading')}</div>
188
+ </div>
189
+ );
190
+ }
191
+
192
+ const languageOptions = [
193
+ { value: 'en', label: t('profile.languageEnglish') },
194
+ { value: 'de', label: t('profile.languageGerman') },
195
+ { value: 'fr', label: t('profile.languageFrench') },
196
+ { value: 'es', label: t('profile.languageSpanish') },
197
+ { value: 'it', label: t('profile.languageItalian') },
198
+ { value: 'pt', label: t('profile.languagePortuguese') },
199
+ { value: 'nl', label: t('profile.languageDutch') },
200
+ { value: 'ru', label: t('profile.languageRussian') },
201
+ { value: 'ja', label: t('profile.languageJapanese') },
202
+ { value: 'zh', label: t('profile.languageChinese') },
203
+ { value: 'pl', label: t('profile.languagePolish') },
204
+ ];
205
+
206
+ const themeOptions = [
207
+ { value: 'auto', label: t('profile.themeAuto') },
208
+ { value: 'light', label: t('profile.themeLight') },
209
+ { value: 'dark', label: t('profile.themeDark') },
210
+ ];
211
+
212
+ return (
213
+ <div className="space-y-6">
214
+ {/* Page header: title + subtitle left; signed-in summary + Admin badge right */}
215
+ <div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-2">
216
+ <div>
217
+ <h1 className="text-2xl font-semibold text-foreground">
218
+ {t('profile.title')}
219
+ </h1>
220
+ <p className="mt-1 text-sm text-muted-foreground">
221
+ {t('profile.description')}
222
+ </p>
223
+ </div>
224
+ <div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
225
+ <span>
226
+ {t('profile.signedInAs')}: <span className="text-foreground">{user.email}</span>
227
+ </span>
228
+ {user.is_admin && (
229
+ <Badge variant="secondary" className="text-xs font-normal">
230
+ {t('profile.admin')}
231
+ </Badge>
232
+ )}
233
+ </div>
234
+ </div>
235
+
236
+ <div className="space-y-6">
237
+ {/* Section A: Account */}
238
+ <Card className="border border-border bg-card shadow-sm">
239
+ <CardHeader>
240
+ <CardTitle>{t('profile.account')}</CardTitle>
241
+ <CardDescription>{t('profile.accountDescription')}</CardDescription>
242
+ </CardHeader>
243
+ <CardContent className="space-y-0">
244
+ <dl className="divide-y-0">
245
+ {/* Email row */}
246
+ <SettingsRow
247
+ label={t('profile.email')}
248
+ value={
249
+ editingEmail ? undefined : (
250
+ <>
251
+ {user.email}
252
+ {user.email_pending && (
253
+ <span className="ml-2 text-primary text-xs font-medium">
254
+ ({t('emailVerification.pendingTitle')})
255
+ </span>
256
+ )}
257
+ </>
258
+ )
259
+ }
260
+ helper={
261
+ !editingEmail && user.oidc_provider
262
+ ? t('profile.emailManagedByOIDC')
263
+ : undefined
264
+ }
265
+ action={
266
+ editingEmail ? (
267
+ <div className="flex gap-2 mt-2 sm:mt-0">
268
+ <Button
269
+ type="button"
270
+ variant="primary"
271
+ size="sm"
272
+ onClick={handleSubmit}
273
+ disabled={saving}
274
+ aria-label={t('common.save')}
275
+ >
276
+ {saving ? t('common.loading') : t('common.save')}
277
+ </Button>
278
+ <Button
279
+ type="button"
280
+ variant="ghost"
281
+ size="sm"
282
+ onClick={() => {
283
+ setEditingEmail(false);
284
+ setFormData((prev) => ({ ...prev, email: user.email || '' }));
285
+ setErrors((prev) => ({ ...prev, email: undefined }));
286
+ }}
287
+ aria-label={t('common.cancel')}
288
+ >
289
+ {t('common.cancel')}
290
+ </Button>
291
+ </div>
292
+ ) : !user.oidc_provider ? (
293
+ <Button
294
+ type="button"
295
+ variant="ghost"
296
+ size="sm"
297
+ onClick={() => setEditingEmail(true)}
298
+ aria-label={t('profile.email')}
299
+ >
300
+ {t('common.edit')}
301
+ </Button>
302
+ ) : null
303
+ }
304
+ >
305
+ {editingEmail && (
306
+ <div className="space-y-1 mt-2">
307
+ <Input
308
+ type="email"
309
+ value={formData.email}
310
+ onChange={(e) => {
311
+ setFormData((prev) => ({ ...prev, email: e.target.value }));
312
+ setErrors((prev) => ({ ...prev, email: undefined }));
313
+ }}
314
+ placeholder={t('profile.emailPlaceholder')}
315
+ className="max-w-md"
316
+ aria-label={t('profile.email')}
317
+ />
318
+ {errors.email && (
319
+ <p className="text-xs text-destructive">{errors.email}</p>
320
+ )}
321
+ </div>
322
+ )}
323
+ {!editingEmail && user.email_pending && (
324
+ <div className="flex items-start gap-2 px-3 py-2 bg-primary/10 border border-primary/30 rounded-lg mt-2">
325
+ <AlertCircle className="h-4 w-4 text-primary flex-shrink-0 mt-0.5" />
326
+ <div className="flex-1">
327
+ <p className="text-xs text-foreground font-medium">
328
+ {t('emailVerification.pendingTitle')}
329
+ </p>
330
+ <p className="text-xs text-muted-foreground mt-1">
331
+ {t('emailVerification.pendingDescription', { email: user.email_pending })}
332
+ </p>
333
+ </div>
334
+ </div>
335
+ )}
336
+ </SettingsRow>
337
+
338
+ {/* Name row */}
339
+ <SettingsRow
340
+ label={t('profile.name')}
341
+ value={editingName ? undefined : user.name}
342
+ action={
343
+ editingName ? (
344
+ <div className="flex gap-2 mt-2 sm:mt-0">
345
+ <Button
346
+ type="button"
347
+ variant="primary"
348
+ size="sm"
349
+ onClick={handleSubmit}
350
+ disabled={saving}
351
+ aria-label={t('common.save')}
352
+ >
353
+ {saving ? t('common.loading') : t('common.save')}
354
+ </Button>
355
+ <Button
356
+ type="button"
357
+ variant="ghost"
358
+ size="sm"
359
+ onClick={() => {
360
+ setEditingName(false);
361
+ setFormData((prev) => ({ ...prev, name: user.name || '' }));
362
+ setErrors((prev) => ({ ...prev, name: undefined }));
363
+ }}
364
+ aria-label={t('common.cancel')}
365
+ >
366
+ {t('common.cancel')}
367
+ </Button>
368
+ </div>
369
+ ) : (
370
+ <Button
371
+ type="button"
372
+ variant="ghost"
373
+ size="sm"
374
+ onClick={() => setEditingName(true)}
375
+ aria-label={t('profile.name')}
376
+ >
377
+ {t('common.edit')}
378
+ </Button>
379
+ )
380
+ }
381
+ >
382
+ {editingName && (
383
+ <div className="space-y-1 mt-2">
384
+ <Input
385
+ type="text"
386
+ value={formData.name}
387
+ onChange={(e) => {
388
+ setFormData((prev) => ({ ...prev, name: e.target.value }));
389
+ setErrors((prev) => ({ ...prev, name: undefined }));
390
+ }}
391
+ placeholder={t('profile.namePlaceholder')}
392
+ className="max-w-md"
393
+ aria-label={t('profile.name')}
394
+ />
395
+ {errors.name && (
396
+ <p className="text-xs text-destructive">{errors.name}</p>
397
+ )}
398
+ </div>
399
+ )}
400
+ </SettingsRow>
401
+
402
+ {/* Quick Access row — muted, no primary action */}
403
+ <SettingsRow
404
+ label={t('profile.quickAccess')}
405
+ helper={
406
+ <>
407
+ {t('profile.quickAccessDescription')}{' '}
408
+ <Link
409
+ to={`${appBasePath}/go-preferences`}
410
+ className="text-primary hover:text-primary/90 font-medium"
411
+ >
412
+ {t('profile.manageQuickAccess')} →
413
+ </Link>
414
+ </>
415
+ }
416
+ />
417
+ </dl>
418
+ </CardContent>
419
+ </Card>
420
+
421
+ {/* Section B: Preferences */}
422
+ <Card className="border border-border bg-card shadow-sm">
423
+ <CardHeader>
424
+ <CardTitle>{t('profile.preferences')}</CardTitle>
425
+ <CardDescription>{t('profile.preferencesDescription')}</CardDescription>
426
+ </CardHeader>
427
+ <form onSubmit={handleSubmit} id="profile-preferences-form">
428
+ <CardContent className="space-y-4">
429
+ <div className="space-y-2">
430
+ <label className="text-sm font-medium text-foreground" id="profile-language-label">
431
+ {t('profile.language')}
432
+ </label>
433
+ <Select
434
+ value={formData.language}
435
+ onChange={(value) => setFormData((prev) => ({ ...prev, language: value }))}
436
+ options={languageOptions}
437
+ />
438
+ </div>
439
+ <div className="space-y-2">
440
+ <label className="text-sm font-medium text-foreground" id="profile-theme-label">
441
+ {t('profile.theme')}
442
+ </label>
443
+ <Select
444
+ value={formData.theme}
445
+ onChange={(value) => setFormData((prev) => ({ ...prev, theme: value }))}
446
+ options={themeOptions}
447
+ />
448
+ </div>
449
+ {aiAvailable && (
450
+ <div className="flex items-center justify-between gap-3 py-2">
451
+ <div>
452
+ <label htmlFor="ai-suggestions" className="text-sm font-medium text-foreground">
453
+ {t('profile.aiSuggestions')}
454
+ </label>
455
+ <p className="text-xs text-muted-foreground mt-0.5">
456
+ {t('profile.aiSuggestionsDescription')}
457
+ </p>
458
+ </div>
459
+ <Switch
460
+ id="ai-suggestions"
461
+ checked={formData.ai_suggestions_enabled}
462
+ onCheckedChange={(checked) =>
463
+ setFormData((prev) => ({ ...prev, ai_suggestions_enabled: checked }))
464
+ }
465
+ aria-label={t('profile.aiSuggestions')}
466
+ />
467
+ </div>
468
+ )}
469
+ </CardContent>
470
+ <CardFooter className="flex flex-row items-center justify-between gap-4">
471
+ <span className="text-xs text-muted-foreground">
472
+ {preferencesDirty ? t('profile.unsavedChanges') : null}
473
+ </span>
474
+ <Button
475
+ type="submit"
476
+ form="profile-preferences-form"
477
+ variant="primary"
478
+ disabled={saving || !preferencesDirty}
479
+ aria-label={t('common.save')}
480
+ >
481
+ {saving ? t('common.loading') : t('common.save')}
482
+ </Button>
483
+ </CardFooter>
484
+ </form>
485
+ </Card>
486
+
487
+ {/* Section C: Developer / API Access */}
488
+ <Card className="border border-border bg-card shadow-sm" aria-labelledby="developer-section-title">
489
+ <CardHeader>
490
+ <div className="flex flex-wrap items-center gap-2">
491
+ <CardTitle id="developer-section-title" className="flex items-center gap-2">
492
+ <Key className="h-5 w-5 text-amber-600 dark:text-amber-400" />
493
+ {t('profile.developerApiTitle')}
494
+ </CardTitle>
495
+ <Badge
496
+ variant="secondary"
497
+ className="text-xs font-normal bg-amber-500/10 text-amber-700 dark:text-amber-300 border-amber-500/20"
498
+ >
499
+ {t('profile.advanced')}
500
+ </Badge>
501
+ </div>
502
+ <CardDescription>{t('profile.developerApiDescription')}</CardDescription>
503
+ </CardHeader>
504
+ <CardContent className="space-y-4">
505
+ <div className="flex items-start gap-2 px-2.5 py-1.5 rounded-md bg-amber-500/10 border border-amber-500/20">
506
+ <AlertCircle className="h-3.5 w-3.5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
507
+ <p className="text-xs text-amber-800 dark:text-amber-200">
508
+ {t('profile.apiTokenWarning')}
509
+ </p>
510
+ </div>
511
+ <a
512
+ href={apiBaseUrl ? `${apiBaseUrl}/api-docs` : '/api-docs'}
513
+ target="_blank"
514
+ rel="noopener noreferrer"
515
+ className="text-sm font-medium text-primary hover:text-primary/90 inline-block"
516
+ >
517
+ {t('profile.viewApiDocs')} →
518
+ </a>
519
+ <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
520
+ <span className="text-sm font-medium text-foreground">
521
+ {t('profile.yourTokens')}
522
+ </span>
523
+ <Button
524
+ type="button"
525
+ variant="primary"
526
+ size="sm"
527
+ onClick={() => setCreateTokenOpen(true)}
528
+ aria-label={t('profile.createToken')}
529
+ >
530
+ {t('profile.createToken')}
531
+ </Button>
532
+ </div>
533
+ {tokensLoading ? (
534
+ <p className="text-sm text-muted-foreground">{t('common.loading')}</p>
535
+ ) : tokens.length === 0 ? (
536
+ <p className="text-sm text-muted-foreground">
537
+ {t('profile.noTokensEmpty')}
538
+ </p>
539
+ ) : (
540
+ <ul className="space-y-2" role="list">
541
+ {tokens.map((tok) => (
542
+ <li
543
+ key={tok.id}
544
+ className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 py-2 px-3 rounded-lg bg-muted/50"
545
+ >
546
+ <div className="min-w-0 flex-1">
547
+ <span className="text-sm font-medium text-foreground block truncate">
548
+ {tok.name}
549
+ </span>
550
+ <span className="text-xs text-muted-foreground font-mono">
551
+ sb_********************************
552
+ </span>
553
+ <span className="text-xs text-muted-foreground block mt-0.5">
554
+ {t('profile.createdAt')}: {formatDate(tok.created_at)}
555
+ {' · '}
556
+ {t('profile.lastUsed')}: {tok.last_used_at ? formatDate(tok.last_used_at) : t('profile.neverUsed')}
557
+ </span>
558
+ </div>
559
+ <Button
560
+ type="button"
561
+ variant="ghost"
562
+ size="sm"
563
+ onClick={() => setRevokeTokenId(tok.id)}
564
+ className="text-destructive hover:text-destructive/90 sm:ml-auto"
565
+ aria-label={`${t('profile.revokeToken')} ${tok.name}`}
566
+ >
567
+ {t('profile.revokeToken')}
568
+ </Button>
569
+ </li>
570
+ ))}
571
+ </ul>
572
+ )}
573
+ </CardContent>
574
+ </Card>
575
+ </div>
576
+
577
+ <CreateTokenModal
578
+ isOpen={createTokenOpen}
579
+ onClose={() => setCreateTokenOpen(false)}
580
+ onCreated={fetchTokens}
581
+ />
582
+ <ConfirmDialog
583
+ isOpen={revokeTokenId !== null}
584
+ title={t('profile.revokeToken')}
585
+ message={t('profile.revokeTokenConfirm')}
586
+ variant="danger"
587
+ confirmText={t('profile.revokeToken')}
588
+ onConfirm={() => revokeTokenId && handleRevokeToken(revokeTokenId)}
589
+ onCancel={() => setRevokeTokenId(null)}
590
+ />
591
+ </div>
592
+ );
593
+ }