425 lines
15 KiB
TypeScript
425 lines
15 KiB
TypeScript
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 (
|
|
<div className="space-y-2">
|
|
<Label>{label}</Label>
|
|
{children}
|
|
{hint ? <p className="text-xs leading-5 text-muted-foreground">{hint}</p> : null}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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<AdminSiteSettingsResponse | null>(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 = <K extends keyof AdminSiteSettingsResponse>(
|
|
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 (
|
|
<div className="space-y-6">
|
|
<Skeleton className="h-48 rounded-3xl" />
|
|
<Skeleton className="h-[540px] rounded-3xl" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
|
<div className="space-y-3">
|
|
<Badge variant="secondary">Site settings</Badge>
|
|
<div>
|
|
<h2 className="text-3xl font-semibold tracking-tight">
|
|
Brand, profile, and AI controls
|
|
</h2>
|
|
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
|
|
This page keeps the public brand, owner profile, and AI configuration aligned with
|
|
the same backend data model the site already depends on.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<Button variant="outline" onClick={() => void loadSettings(true)}>
|
|
<RefreshCcw className="h-4 w-4" />
|
|
Refresh
|
|
</Button>
|
|
<Button
|
|
variant="secondary"
|
|
disabled={reindexing}
|
|
onClick={async () => {
|
|
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)
|
|
}
|
|
}}
|
|
>
|
|
<Bot className="h-4 w-4" />
|
|
{reindexing ? 'Reindexing...' : 'Rebuild AI index'}
|
|
</Button>
|
|
<Button
|
|
disabled={saving}
|
|
onClick={async () => {
|
|
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)
|
|
}
|
|
}}
|
|
>
|
|
<Save className="h-4 w-4" />
|
|
{saving ? 'Saving...' : 'Save changes'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Public identity</CardTitle>
|
|
<CardDescription>
|
|
Everything the public site reads for brand, hero copy, owner profile, and social
|
|
metadata.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="grid gap-6 lg:grid-cols-2">
|
|
<Field label="Site name">
|
|
<Input
|
|
value={form.site_name ?? ''}
|
|
onChange={(event) => updateField('site_name', event.target.value)}
|
|
/>
|
|
</Field>
|
|
<Field label="Short name">
|
|
<Input
|
|
value={form.site_short_name ?? ''}
|
|
onChange={(event) => updateField('site_short_name', event.target.value)}
|
|
/>
|
|
</Field>
|
|
<Field label="Site URL">
|
|
<Input
|
|
value={form.site_url ?? ''}
|
|
onChange={(event) => updateField('site_url', event.target.value)}
|
|
/>
|
|
</Field>
|
|
<Field label="Location">
|
|
<Input
|
|
value={form.location ?? ''}
|
|
onChange={(event) => updateField('location', event.target.value)}
|
|
/>
|
|
</Field>
|
|
<Field label="Site title" hint="Used in the main document title and SEO surface.">
|
|
<Input
|
|
value={form.site_title ?? ''}
|
|
onChange={(event) => updateField('site_title', event.target.value)}
|
|
/>
|
|
</Field>
|
|
<Field label="Owner title">
|
|
<Input
|
|
value={form.owner_title ?? ''}
|
|
onChange={(event) => updateField('owner_title', event.target.value)}
|
|
/>
|
|
</Field>
|
|
<div className="lg:col-span-2">
|
|
<Field label="Site description">
|
|
<Textarea
|
|
value={form.site_description ?? ''}
|
|
onChange={(event) => updateField('site_description', event.target.value)}
|
|
/>
|
|
</Field>
|
|
</div>
|
|
<Field label="Hero title">
|
|
<Input
|
|
value={form.hero_title ?? ''}
|
|
onChange={(event) => updateField('hero_title', event.target.value)}
|
|
/>
|
|
</Field>
|
|
<Field label="Hero subtitle">
|
|
<Input
|
|
value={form.hero_subtitle ?? ''}
|
|
onChange={(event) => updateField('hero_subtitle', event.target.value)}
|
|
/>
|
|
</Field>
|
|
<Field label="Owner name">
|
|
<Input
|
|
value={form.owner_name ?? ''}
|
|
onChange={(event) => updateField('owner_name', event.target.value)}
|
|
/>
|
|
</Field>
|
|
<Field label="Avatar URL">
|
|
<Input
|
|
value={form.owner_avatar_url ?? ''}
|
|
onChange={(event) => updateField('owner_avatar_url', event.target.value)}
|
|
/>
|
|
</Field>
|
|
<div className="lg:col-span-2">
|
|
<Field label="Owner bio">
|
|
<Textarea
|
|
value={form.owner_bio ?? ''}
|
|
onChange={(event) => updateField('owner_bio', event.target.value)}
|
|
/>
|
|
</Field>
|
|
</div>
|
|
<Field label="GitHub">
|
|
<Input
|
|
value={form.social_github ?? ''}
|
|
onChange={(event) => updateField('social_github', event.target.value)}
|
|
/>
|
|
</Field>
|
|
<Field label="Twitter / X">
|
|
<Input
|
|
value={form.social_twitter ?? ''}
|
|
onChange={(event) => updateField('social_twitter', event.target.value)}
|
|
/>
|
|
</Field>
|
|
<div className="lg:col-span-2">
|
|
<Field label="Email / mailto">
|
|
<Input
|
|
value={form.social_email ?? ''}
|
|
onChange={(event) => updateField('social_email', event.target.value)}
|
|
/>
|
|
</Field>
|
|
</div>
|
|
<div className="lg:col-span-2">
|
|
<Field label="Tech stack" hint="One item per line.">
|
|
<Textarea
|
|
value={techStackValue}
|
|
onChange={(event) =>
|
|
updateField(
|
|
'tech_stack',
|
|
event.target.value
|
|
.split('\n')
|
|
.map((item) => item.trim())
|
|
.filter(Boolean),
|
|
)
|
|
}
|
|
/>
|
|
</Field>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<div className="space-y-6">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>AI module</CardTitle>
|
|
<CardDescription>
|
|
Provider and retrieval controls used by the on-site AI experience.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-5">
|
|
<label className="flex items-start gap-3 rounded-2xl border border-border/70 bg-background/60 p-4">
|
|
<input
|
|
type="checkbox"
|
|
checked={form.ai_enabled}
|
|
onChange={(event) => updateField('ai_enabled', event.target.checked)}
|
|
className="mt-1 h-4 w-4 rounded border-input text-primary focus:ring-ring"
|
|
/>
|
|
<div>
|
|
<div className="font-medium">Enable public AI Q&A</div>
|
|
<p className="mt-1 text-sm leading-6 text-muted-foreground">
|
|
When this is off, the public Ask AI entry stays visible only as a disabled
|
|
state.
|
|
</p>
|
|
</div>
|
|
</label>
|
|
|
|
<Field label="Provider">
|
|
<Input
|
|
value={form.ai_provider ?? ''}
|
|
onChange={(event) => updateField('ai_provider', event.target.value)}
|
|
/>
|
|
</Field>
|
|
<Field label="API base">
|
|
<Input
|
|
value={form.ai_api_base ?? ''}
|
|
onChange={(event) => updateField('ai_api_base', event.target.value)}
|
|
/>
|
|
</Field>
|
|
<Field label="API key">
|
|
<Input
|
|
value={form.ai_api_key ?? ''}
|
|
onChange={(event) => updateField('ai_api_key', event.target.value)}
|
|
/>
|
|
</Field>
|
|
<Field label="Chat model">
|
|
<Input
|
|
value={form.ai_chat_model ?? ''}
|
|
onChange={(event) => updateField('ai_chat_model', event.target.value)}
|
|
/>
|
|
</Field>
|
|
<Field
|
|
label="Embedding model"
|
|
hint={`Local option: ${form.ai_local_embedding}`}
|
|
>
|
|
<Input
|
|
value={form.ai_embedding_model ?? ''}
|
|
onChange={(event) => updateField('ai_embedding_model', event.target.value)}
|
|
/>
|
|
</Field>
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<Field label="Top K">
|
|
<Input
|
|
type="number"
|
|
value={form.ai_top_k ?? ''}
|
|
onChange={(event) =>
|
|
updateField(
|
|
'ai_top_k',
|
|
event.target.value ? Number(event.target.value) : null,
|
|
)
|
|
}
|
|
/>
|
|
</Field>
|
|
<Field label="Chunk size">
|
|
<Input
|
|
type="number"
|
|
value={form.ai_chunk_size ?? ''}
|
|
onChange={(event) =>
|
|
updateField(
|
|
'ai_chunk_size',
|
|
event.target.value ? Number(event.target.value) : null,
|
|
)
|
|
}
|
|
/>
|
|
</Field>
|
|
</div>
|
|
<Field label="System prompt">
|
|
<Textarea
|
|
value={form.ai_system_prompt ?? ''}
|
|
onChange={(event) => updateField('ai_system_prompt', event.target.value)}
|
|
/>
|
|
</Field>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Index status</CardTitle>
|
|
<CardDescription>
|
|
Read-only signals from the current AI knowledge base.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
|
|
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
|
Indexed chunks
|
|
</p>
|
|
<p className="mt-3 text-3xl font-semibold">{form.ai_chunks_count}</p>
|
|
</div>
|
|
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
|
|
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
|
Last indexed at
|
|
</p>
|
|
<p className="mt-3 text-sm leading-6 text-muted-foreground">
|
|
{form.ai_last_indexed_at ?? 'The index has not been built yet.'}
|
|
</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|