import { Bot, RefreshCcw, Save } from 'lucide-react'
import type { ReactNode } from 'react'
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { Badge } from '@/components/ui/badge'
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 { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import { adminApi, ApiError } from '@/lib/api'
import type { AdminSiteSettingsResponse, SiteSettingsPayload } from '@/lib/types'
function Field({
label,
hint,
children,
}: {
label: string
hint?: string
children: ReactNode
}) {
return (
{label}
{children}
{hint ?
{hint}
: null}
)
}
function toPayload(form: AdminSiteSettingsResponse): SiteSettingsPayload {
return {
siteName: form.site_name,
siteShortName: form.site_short_name,
siteUrl: form.site_url,
siteTitle: form.site_title,
siteDescription: form.site_description,
heroTitle: form.hero_title,
heroSubtitle: form.hero_subtitle,
ownerName: form.owner_name,
ownerTitle: form.owner_title,
ownerBio: form.owner_bio,
ownerAvatarUrl: form.owner_avatar_url,
socialGithub: form.social_github,
socialTwitter: form.social_twitter,
socialEmail: form.social_email,
location: form.location,
techStack: form.tech_stack,
aiEnabled: form.ai_enabled,
aiProvider: form.ai_provider,
aiApiBase: form.ai_api_base,
aiApiKey: form.ai_api_key,
aiChatModel: form.ai_chat_model,
aiEmbeddingModel: form.ai_embedding_model,
aiSystemPrompt: form.ai_system_prompt,
aiTopK: form.ai_top_k,
aiChunkSize: form.ai_chunk_size,
}
}
export function SiteSettingsPage() {
const [form, setForm] = useState(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [reindexing, setReindexing] = useState(false)
const loadSettings = useCallback(async (showToast = false) => {
try {
const next = await adminApi.getSiteSettings()
startTransition(() => {
setForm(next)
})
if (showToast) {
toast.success('Site settings refreshed.')
}
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
return
}
toast.error(error instanceof ApiError ? error.message : 'Unable to load site settings.')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void loadSettings(false)
}, [loadSettings])
const updateField = (
key: K,
value: AdminSiteSettingsResponse[K],
) => {
setForm((current) => (current ? { ...current, [key]: value } : current))
}
const techStackValue = useMemo(
() => (form?.tech_stack.length ? form.tech_stack.join('\n') : ''),
[form?.tech_stack],
)
if (loading || !form) {
return (
)
}
return (
Site settings
Brand, profile, and AI controls
This page keeps the public brand, owner profile, and AI configuration aligned with
the same backend data model the site already depends on.
void loadSettings(true)}>
Refresh
{
try {
setReindexing(true)
const result = await adminApi.reindexAi()
toast.success(`AI index rebuilt with ${result.indexed_chunks} chunks.`)
await loadSettings(false)
} catch (error) {
toast.error(error instanceof ApiError ? error.message : 'AI reindex failed.')
} finally {
setReindexing(false)
}
}}
>
{reindexing ? 'Reindexing...' : 'Rebuild AI index'}
{
try {
setSaving(true)
const updated = await adminApi.updateSiteSettings(toPayload(form))
startTransition(() => {
setForm(updated)
})
toast.success('Site settings saved.')
} catch (error) {
toast.error(error instanceof ApiError ? error.message : 'Save failed.')
} finally {
setSaving(false)
}
}}
>
{saving ? 'Saving...' : 'Save changes'}
Public identity
Everything the public site reads for brand, hero copy, owner profile, and social
metadata.
updateField('site_name', event.target.value)}
/>
updateField('site_short_name', event.target.value)}
/>
updateField('site_url', event.target.value)}
/>
updateField('location', event.target.value)}
/>
updateField('site_title', event.target.value)}
/>
updateField('owner_title', event.target.value)}
/>
updateField('hero_title', event.target.value)}
/>
updateField('hero_subtitle', event.target.value)}
/>
updateField('owner_name', event.target.value)}
/>
updateField('owner_avatar_url', event.target.value)}
/>
updateField('social_github', event.target.value)}
/>
updateField('social_twitter', event.target.value)}
/>
updateField('social_email', event.target.value)}
/>
AI module
Provider and retrieval controls used by the on-site AI experience.
updateField('ai_enabled', event.target.checked)}
className="mt-1 h-4 w-4 rounded border-input text-primary focus:ring-ring"
/>
Enable public AI Q&A
When this is off, the public Ask AI entry stays visible only as a disabled
state.
updateField('ai_provider', event.target.value)}
/>
updateField('ai_api_base', event.target.value)}
/>
updateField('ai_api_key', event.target.value)}
/>
updateField('ai_chat_model', event.target.value)}
/>
updateField('ai_embedding_model', event.target.value)}
/>
updateField(
'ai_top_k',
event.target.value ? Number(event.target.value) : null,
)
}
/>
updateField(
'ai_chunk_size',
event.target.value ? Number(event.target.value) : null,
)
}
/>
Index status
Read-only signals from the current AI knowledge base.
Indexed chunks
{form.ai_chunks_count}
Last indexed at
{form.ai_last_indexed_at ?? 'The index has not been built yet.'}
)
}