'use client'; import { useCallback, useEffect, useState, type ReactNode } from 'react'; import { Loader2 } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Switch } from '@/components/ui/switch'; import { Textarea } from '@/components/ui/textarea'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { apiFetch } from '@/lib/api/client'; export type SettingFieldType = | 'string' | 'password' | 'number' | 'boolean' | 'textarea' | 'html' | 'select' | 'color'; export interface SettingFieldDef { key: string; label: string; description?: string; type: SettingFieldType; placeholder?: string; defaultValue: unknown; options?: Array<{ value: string; label: string }>; } interface SettingsRowResponse { key: string; value: unknown; portId: string | null; } interface ListResponse { data: { portSettings: SettingsRowResponse[]; globalSettings: SettingsRowResponse[] }; } interface SettingsFormCardProps { title: string; description?: string; fields: SettingFieldDef[]; /** Optional extra slot rendered below the form (e.g. test-connection button). */ extra?: ReactNode; } /** * Reusable card that fetches /api/v1/admin/settings, renders editable form * fields for the supplied keys, and PUTs each on save. */ export function SettingsFormCard({ title, description, fields, extra }: SettingsFormCardProps) { const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [values, setValues] = useState>({}); const [originals, setOriginals] = useState>({}); const fetchValues = useCallback(async () => { setLoading(true); try { const res = await apiFetch('/api/v1/admin/settings'); const next: Record = {}; for (const field of fields) { const port = res.data.portSettings.find((s) => s.key === field.key); const global = res.data.globalSettings.find((s) => s.key === field.key); next[field.key] = port?.value ?? global?.value ?? field.defaultValue; } setValues(next); setOriginals(next); } finally { setLoading(false); } }, [fields]); useEffect(() => { void fetchValues(); }, [fetchValues]); function setField(key: string, value: unknown) { setValues((prev) => ({ ...prev, [key]: value })); } async function handleSave() { setSaving(true); try { const changedFields = fields.filter((f) => values[f.key] !== originals[f.key]); if (changedFields.length === 0) { toast.info('No changes to save'); return; } for (const f of changedFields) { await apiFetch('/api/v1/admin/settings', { method: 'PUT', body: { key: f.key, value: values[f.key] }, }); } toast.success(`Saved ${changedFields.length} setting(s)`); setOriginals(values); } catch (err) { toast.error(err instanceof Error ? err.message : 'Save failed'); } finally { setSaving(false); } } if (loading) { return ( {title}
Loading…
); } const dirty = fields.some((f) => values[f.key] !== originals[f.key]); return ( {title} {description && {description}} {fields.map((field) => ( setField(field.key, v)} /> ))}
{extra}
); } function FieldRow({ field, value, onChange, }: { field: SettingFieldDef; value: unknown; onChange: (v: unknown) => void; }) { const id = `setting-${field.key}`; switch (field.type) { case 'boolean': return (
{field.description && (

{field.description}

)}
onChange(checked)} />
); case 'textarea': case 'html': return (
{field.description && (

{field.description}

)}