feat: add shadcn admin workspace
This commit is contained in:
379
admin/src/pages/dashboard-page.tsx
Normal file
379
admin/src/pages/dashboard-page.tsx
Normal file
@@ -0,0 +1,379 @@
|
||||
import {
|
||||
ArrowUpRight,
|
||||
BrainCircuit,
|
||||
FolderTree,
|
||||
MessageSquareWarning,
|
||||
RefreshCcw,
|
||||
Rss,
|
||||
Star,
|
||||
Tags,
|
||||
} from 'lucide-react'
|
||||
import { startTransition, useCallback, useEffect, 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 { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { adminApi, ApiError } from '@/lib/api'
|
||||
import type { AdminDashboardResponse } from '@/lib/types'
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
note,
|
||||
icon: Icon,
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
note: string
|
||||
icon: typeof Rss
|
||||
}) {
|
||||
return (
|
||||
<Card className="bg-gradient-to-br from-card via-card to-background/70">
|
||||
<CardContent className="flex items-start justify-between pt-6">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">{label}</p>
|
||||
<div className="mt-3 text-3xl font-semibold tracking-tight">{value}</div>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">{note}</p>
|
||||
</div>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-primary/20 bg-primary/10 text-primary">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const [data, setData] = useState<AdminDashboardResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
const loadDashboard = useCallback(async (showToast = false) => {
|
||||
try {
|
||||
if (showToast) {
|
||||
setRefreshing(true)
|
||||
}
|
||||
|
||||
const next = await adminApi.dashboard()
|
||||
startTransition(() => {
|
||||
setData(next)
|
||||
})
|
||||
|
||||
if (showToast) {
|
||||
toast.success('Dashboard refreshed.')
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
return
|
||||
}
|
||||
toast.error(error instanceof ApiError ? error.message : 'Unable to load dashboard.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadDashboard(false)
|
||||
}, [loadDashboard])
|
||||
|
||||
if (loading || !data) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<Skeleton key={index} className="h-44 rounded-3xl" />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-[420px] rounded-3xl" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const statCards = [
|
||||
{
|
||||
label: 'Posts',
|
||||
value: data.stats.total_posts,
|
||||
note: `${data.stats.total_comments} comments across the content library`,
|
||||
icon: Rss,
|
||||
},
|
||||
{
|
||||
label: 'Pending comments',
|
||||
value: data.stats.pending_comments,
|
||||
note: 'Queued for moderation follow-up',
|
||||
icon: MessageSquareWarning,
|
||||
},
|
||||
{
|
||||
label: 'Categories',
|
||||
value: data.stats.total_categories,
|
||||
note: `${data.stats.total_tags} tags currently in circulation`,
|
||||
icon: FolderTree,
|
||||
},
|
||||
{
|
||||
label: 'AI chunks',
|
||||
value: data.stats.ai_chunks,
|
||||
note: data.stats.ai_enabled ? 'Knowledge base is enabled' : 'AI is currently disabled',
|
||||
icon: BrainCircuit,
|
||||
},
|
||||
]
|
||||
|
||||
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">Dashboard</Badge>
|
||||
<div>
|
||||
<h2 className="text-3xl font-semibold tracking-tight">Operations overview</h2>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
|
||||
This screen pulls the operational signals the old Tera dashboard used to summarize,
|
||||
but now from a standalone React app ready for gradual module migration.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button variant="outline" asChild>
|
||||
<a href="http://localhost:4321/ask" target="_blank" rel="noreferrer">
|
||||
<ArrowUpRight className="h-4 w-4" />
|
||||
Open Ask AI
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => void loadDashboard(true)}
|
||||
disabled={refreshing}
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
{refreshing ? 'Refreshing...' : 'Refresh'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{statCards.map((item) => (
|
||||
<StatCard key={item.label} {...item} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1.25fr_0.95fr]">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>Recent posts</CardTitle>
|
||||
<CardDescription>
|
||||
Freshly imported or updated content flowing into the public site.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline">{data.recent_posts.length} rows</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.recent_posts.map((post) => (
|
||||
<TableRow key={post.id}>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{post.title}</span>
|
||||
{post.pinned ? <Badge variant="success">pinned</Badge> : null}
|
||||
</div>
|
||||
<p className="font-mono text-xs text-muted-foreground">{post.slug}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="uppercase text-muted-foreground">
|
||||
{post.post_type}
|
||||
</TableCell>
|
||||
<TableCell>{post.category}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{post.created_at}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Site heartbeat</CardTitle>
|
||||
<CardDescription>
|
||||
A quick read on the public-facing site and the AI index state.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{data.site.site_name}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{data.site.site_url}</p>
|
||||
</div>
|
||||
<Badge variant={data.site.ai_enabled ? 'success' : 'warning'}>
|
||||
{data.site.ai_enabled ? 'AI on' : 'AI off'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||
Reviews
|
||||
</p>
|
||||
<div className="mt-3 flex items-end gap-2">
|
||||
<span className="text-3xl font-semibold">{data.stats.total_reviews}</span>
|
||||
<Star className="mb-1 h-4 w-4 text-amber-500" />
|
||||
</div>
|
||||
</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">
|
||||
Friend links
|
||||
</p>
|
||||
<div className="mt-3 flex items-end gap-2">
|
||||
<span className="text-3xl font-semibold">{data.stats.total_links}</span>
|
||||
<Tags className="mb-1 h-4 w-4 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
</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 AI index
|
||||
</p>
|
||||
<p className="mt-3 text-sm leading-6 text-muted-foreground">
|
||||
{data.site.ai_last_indexed_at ?? 'The site has not been indexed yet.'}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>Pending comments</CardTitle>
|
||||
<CardDescription>
|
||||
Queue visibility without opening the old moderation page.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="warning">{data.pending_comments.length} queued</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Author</TableHead>
|
||||
<TableHead>Scope</TableHead>
|
||||
<TableHead>Post</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.pending_comments.map((comment) => (
|
||||
<TableRow key={comment.id}>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">{comment.author}</div>
|
||||
<p className="line-clamp-2 text-sm text-muted-foreground">
|
||||
{comment.excerpt}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="uppercase text-muted-foreground">
|
||||
{comment.scope}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{comment.post_slug}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{comment.created_at}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>Pending friend links</CardTitle>
|
||||
<CardDescription>
|
||||
Requests waiting for review and reciprocal checks.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="warning">{data.pending_friend_links.length} pending</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{data.pending_friend_links.map((link) => (
|
||||
<div
|
||||
key={link.id}
|
||||
className="rounded-2xl border border-border/70 bg-background/70 p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">{link.site_name}</p>
|
||||
<p className="mt-1 truncate text-sm text-muted-foreground">
|
||||
{link.site_url}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline">{link.category}</Badge>
|
||||
</div>
|
||||
<p className="mt-3 text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{link.created_at}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent reviews</CardTitle>
|
||||
<CardDescription>
|
||||
The latest review entries flowing into the public reviews page.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{data.recent_reviews.map((review) => (
|
||||
<div
|
||||
key={review.id}
|
||||
className="flex items-center justify-between gap-4 rounded-2xl border border-border/70 bg-background/70 px-4 py-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">{review.title}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{review.review_type} · {review.status}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-semibold">{review.rating}/5</div>
|
||||
<p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{review.review_date}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
108
admin/src/pages/login-page.tsx
Normal file
108
admin/src/pages/login-page.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { LockKeyhole, ShieldCheck } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
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'
|
||||
|
||||
export function LoginPage({
|
||||
submitting,
|
||||
onLogin,
|
||||
}: {
|
||||
submitting: boolean
|
||||
onLogin: (payload: { username: string; password: string }) => Promise<void>
|
||||
}) {
|
||||
const [username, setUsername] = useState('admin')
|
||||
const [password, setPassword] = useState('admin123')
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center px-4 py-10">
|
||||
<div className="grid w-full max-w-5xl gap-6 lg:grid-cols-[1.1fr_0.9fr]">
|
||||
<Card className="overflow-hidden border-primary/12 bg-gradient-to-br from-card via-card to-primary/5">
|
||||
<CardHeader className="space-y-4">
|
||||
<div className="inline-flex w-fit items-center gap-2 rounded-full border border-primary/20 bg-primary/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.28em] text-primary">
|
||||
<ShieldCheck className="h-3.5 w-3.5" />
|
||||
Termi admin
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<CardTitle className="text-4xl leading-tight">
|
||||
Separate the dashboard from the public site without losing momentum.
|
||||
</CardTitle>
|
||||
<CardDescription className="max-w-xl text-base leading-7">
|
||||
This new workspace is where operations, moderation, and AI controls will migrate
|
||||
out of the old server-rendered admin.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 sm:grid-cols-3">
|
||||
{[
|
||||
['React app', 'Independent admin surface'],
|
||||
['shadcn/ui', 'Consistent component foundation'],
|
||||
['Loco API', 'Backend stays focused on data and rules'],
|
||||
].map(([title, description]) => (
|
||||
<div
|
||||
key={title}
|
||||
className="rounded-2xl border border-border/70 bg-background/75 p-4"
|
||||
>
|
||||
<div className="text-sm font-semibold">{title}</div>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-3">
|
||||
<span className="flex h-11 w-11 items-center justify-center rounded-2xl border border-primary/20 bg-primary/10 text-primary">
|
||||
<LockKeyhole className="h-5 w-5" />
|
||||
</span>
|
||||
Sign in to the control room
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
The login bridge still uses the current backend admin credentials so we can migrate
|
||||
screens incrementally without stopping delivery.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form
|
||||
className="space-y-5"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void onLogin({ username, password })
|
||||
}}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Input
|
||||
id="username"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button className="w-full" size="lg" disabled={submitting}>
|
||||
{submitting ? 'Signing in...' : 'Unlock admin'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
424
admin/src/pages/site-settings-page.tsx
Normal file
424
admin/src/pages/site-settings-page.tsx
Normal file
@@ -0,0 +1,424 @@
|
||||
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 is the first fully migrated settings screen. It replaces the old template
|
||||
form with a real app surface while still talking to the same backend data model.
|
||||
</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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user