@mdguggenbichler/slugbase-core 0.0.18 → 0.0.20

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.
@@ -1,10 +1,9 @@
1
- import React, { useState, useEffect, useCallback } from 'react';
1
+ import { useState, useEffect, useCallback } from 'react';
2
2
  import { useTranslation } from 'react-i18next';
3
3
  import api from '../../api/client';
4
4
  import { useToast } from '../ui/Toast';
5
5
  import { useAppConfig } from '../../contexts/AppConfigContext';
6
- import { Save, Sparkles } from 'lucide-react';
7
- import Button from '../ui/Button';
6
+ import { Sparkles } from 'lucide-react';
8
7
  import { PageLoadingSkeleton } from '../ui/PageLoadingSkeleton';
9
8
  import { Switch } from '../ui/switch';
10
9
  import { Label } from '../ui/label';
@@ -20,7 +19,6 @@ export default function AdminAI() {
20
19
  const { showToast } = useToast();
21
20
  const { adminAiOnlyToggle } = useAppConfig();
22
21
  const [loading, setLoading] = useState(true);
23
- const [saving, setSaving] = useState(false);
24
22
  const [settings, setSettings] = useState({
25
23
  ai_enabled: false,
26
24
  ai_provider: 'openai',
@@ -65,9 +63,12 @@ export default function AdminAI() {
65
63
  }
66
64
  }, [showToast, t, adminAiOnlyToggle]);
67
65
 
66
+ // Run initial load once on mount only. Re-running when loadSettings identity changes
67
+ // (e.g. from i18n t) caused repeated API calls and infinite toasts on error.
68
68
  useEffect(() => {
69
69
  loadSettings();
70
- }, [loadSettings]);
70
+ // eslint-disable-next-line react-hooks/exhaustive-deps
71
+ }, []);
71
72
 
72
73
  const loadModels = useCallback(async () => {
73
74
  setModelsLoading(true);
@@ -104,28 +105,29 @@ export default function AdminAI() {
104
105
  ? modelOptions
105
106
  : [{ value: settings.ai_model, label: settings.ai_model }, ...modelOptions];
106
107
 
107
- const handleSave = async (e: React.FormEvent) => {
108
- e.preventDefault();
109
- setSaving(true);
110
- try {
111
- const payload = adminAiOnlyToggle
112
- ? { ai_enabled: settings.ai_enabled }
113
- : {
114
- ai_enabled: settings.ai_enabled,
115
- ai_provider: settings.ai_provider,
116
- ai_model: settings.ai_model,
117
- ai_api_key: settings.ai_api_key || undefined,
118
- };
119
- await api.post('/admin/settings/ai', payload);
120
- showToast(t('common.success'), 'success');
121
- await loadSettings();
122
- } catch (err: unknown) {
123
- const e = err as { response?: { data?: { error?: string } } };
124
- showToast(e?.response?.data?.error || t('common.error'), 'error');
125
- } finally {
126
- setSaving(false);
127
- }
128
- };
108
+ const saveSettings = useCallback(
109
+ async (payload: { ai_enabled?: boolean; ai_provider?: string; ai_model?: string; ai_api_key?: string }) => {
110
+ try {
111
+ const body = adminAiOnlyToggle
112
+ ? { ai_enabled: payload.ai_enabled ?? settings.ai_enabled }
113
+ : {
114
+ ...(payload.ai_enabled !== undefined && { ai_enabled: payload.ai_enabled }),
115
+ ...(payload.ai_provider !== undefined && { ai_provider: payload.ai_provider }),
116
+ ...(payload.ai_model !== undefined && { ai_model: payload.ai_model }),
117
+ ...(payload.ai_api_key !== undefined && payload.ai_api_key !== '' && { ai_api_key: payload.ai_api_key }),
118
+ };
119
+ if (adminAiOnlyToggle && payload.ai_enabled === undefined) return;
120
+ if (!adminAiOnlyToggle && Object.keys(body).length === 0) return;
121
+ await api.post('/admin/settings/ai', body);
122
+ showToast(t('common.success'), 'success');
123
+ await loadSettings();
124
+ } catch (err: unknown) {
125
+ const e = err as { response?: { data?: { error?: string } } };
126
+ showToast(e?.response?.data?.error || t('common.error'), 'error');
127
+ }
128
+ },
129
+ [adminAiOnlyToggle, settings.ai_enabled, showToast, t, loadSettings]
130
+ );
129
131
 
130
132
  if (loading) {
131
133
  return <PageLoadingSkeleton lines={8} />;
@@ -146,14 +148,15 @@ export default function AdminAI() {
146
148
  </div>
147
149
  </div>
148
150
 
149
- <form onSubmit={handleSave} className="space-y-4">
151
+ <div className="space-y-4">
150
152
  <div className="flex items-center gap-3">
151
153
  <Switch
152
154
  id="ai-enabled"
153
155
  checked={settings.ai_enabled}
154
- onCheckedChange={(checked) =>
155
- setSettings({ ...settings, ai_enabled: checked })
156
- }
156
+ onCheckedChange={(checked) => {
157
+ setSettings((s) => ({ ...s, ai_enabled: checked }));
158
+ saveSettings({ ai_enabled: checked });
159
+ }}
157
160
  />
158
161
  <Label htmlFor="ai-enabled" className="text-sm font-medium text-gray-900 dark:text-white cursor-pointer">
159
162
  {t('admin.ai.enabled')}
@@ -168,7 +171,10 @@ export default function AdminAI() {
168
171
  </Label>
169
172
  <Select
170
173
  value={settings.ai_provider}
171
- onChange={(value) => setSettings({ ...settings, ai_provider: value })}
174
+ onChange={(value) => {
175
+ setSettings((s) => ({ ...s, ai_provider: value }));
176
+ saveSettings({ ai_provider: value });
177
+ }}
172
178
  options={providerOptions}
173
179
  className="max-w-xs"
174
180
  />
@@ -181,7 +187,12 @@ export default function AdminAI() {
181
187
  <Input
182
188
  type="password"
183
189
  value={settings.ai_api_key}
184
- onChange={(e) => setSettings({ ...settings, ai_api_key: e.target.value })}
190
+ onChange={(e) => setSettings((s) => ({ ...s, ai_api_key: e.target.value }))}
191
+ onBlur={() => {
192
+ if (settings.ai_api_key.trim() !== '') {
193
+ saveSettings({ ai_api_key: settings.ai_api_key });
194
+ }
195
+ }}
185
196
  placeholder={settings.ai_api_key_set ? t('admin.ai.apiKeyPlaceholder') : 'sk-...'}
186
197
  className="max-w-md font-mono text-sm"
187
198
  />
@@ -196,7 +207,10 @@ export default function AdminAI() {
196
207
  </Label>
197
208
  <Select
198
209
  value={settings.ai_model}
199
- onChange={(value) => setSettings({ ...settings, ai_model: value })}
210
+ onChange={(value) => {
211
+ setSettings((s) => ({ ...s, ai_model: value }));
212
+ saveSettings({ ai_model: value });
213
+ }}
200
214
  options={modelOptionsWithCurrent}
201
215
  placeholder={
202
216
  !settings.ai_api_key_set
@@ -211,18 +225,7 @@ export default function AdminAI() {
211
225
  </div>
212
226
  </>
213
227
  )}
214
-
215
- <div className="pt-2">
216
- <Button type="submit" variant="primary" disabled={saving}>
217
- {saving ? t('common.loading') : (
218
- <>
219
- <Save className="h-4 w-4 mr-2 inline" />
220
- {t('common.save')}
221
- </>
222
- )}
223
- </Button>
224
- </div>
225
- </form>
228
+ </div>
226
229
  </div>
227
230
  </div>
228
231
  );
@@ -1,3 +1,4 @@
1
+ import { useCallback } from 'react';
1
2
  import { toast as sonnerToast } from 'sonner';
2
3
  import { Toaster } from './sonner';
3
4
 
@@ -11,28 +12,27 @@ export interface Toast {
11
12
  }
12
13
 
13
14
  export function useToast() {
14
- const showToast = (
15
- message: string,
16
- variant: ToastVariant = 'info',
17
- duration = 3000
18
- ) => {
19
- const options = duration > 0 ? { duration } : { duration: Infinity };
20
- switch (variant) {
21
- case 'success':
22
- sonnerToast.success(message, options);
23
- break;
24
- case 'error':
25
- sonnerToast.error(message, options);
26
- break;
27
- case 'warning':
28
- sonnerToast.warning(message, options);
29
- break;
30
- case 'info':
31
- default:
32
- sonnerToast.info(message, options);
33
- break;
34
- }
35
- };
15
+ const showToast = useCallback(
16
+ (message: string, variant: ToastVariant = 'info', duration = 3000) => {
17
+ const options = duration > 0 ? { duration } : { duration: Infinity };
18
+ switch (variant) {
19
+ case 'success':
20
+ sonnerToast.success(message, options);
21
+ break;
22
+ case 'error':
23
+ sonnerToast.error(message, options);
24
+ break;
25
+ case 'warning':
26
+ sonnerToast.warning(message, options);
27
+ break;
28
+ case 'info':
29
+ default:
30
+ sonnerToast.info(message, options);
31
+ break;
32
+ }
33
+ },
34
+ []
35
+ );
36
36
 
37
37
  return { showToast };
38
38
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mdguggenbichler/slugbase-core",
3
- "version": "0.0.18",
3
+ "version": "0.0.20",
4
4
  "description": "SlugBase core: backend and frontend entrypoints for self-hosted and cloud apps",
5
5
  "type": "module",
6
6
  "exports": {