feat: add shadcn admin workspace

This commit is contained in:
2026-03-28 17:56:36 +08:00
parent ec96d91548
commit 178434d63e
41 changed files with 6153 additions and 16 deletions

4
.gitignore vendored
View File

@@ -5,6 +5,8 @@
frontend/.astro/ frontend/.astro/
frontend/dist/ frontend/dist/
frontend/node_modules/ frontend/node_modules/
admin/dist/
admin/node_modules/
mcp-server/node_modules/ mcp-server/node_modules/
backend/target/ backend/target/
@@ -12,3 +14,5 @@ backend/.loco-start.err.log
backend/.loco-start.out.log backend/.loco-start.out.log
backend/backend-start.log backend/backend-start.log
backend/storage/ai_embedding_models/ backend/storage/ai_embedding_models/
backend-start.err
backend-start.log

View File

@@ -6,8 +6,9 @@ Monorepo for the Termi blog system.
```text ```text
. .
├─ admin/ # React + shadcn admin workspace
├─ frontend/ # Astro blog frontend ├─ frontend/ # Astro blog frontend
├─ backend/ # Loco.rs backend and admin ├─ backend/ # Loco.rs backend APIs and legacy Tera admin
├─ mcp-server/ # Streamable HTTP MCP server for articles/categories/tags ├─ mcp-server/ # Streamable HTTP MCP server for articles/categories/tags
├─ .codex/ # Codex workspace config ├─ .codex/ # Codex workspace config
└─ .vscode/ # Editor workspace config └─ .vscode/ # Editor workspace config
@@ -41,6 +42,12 @@ Only backend:
.\dev.ps1 -BackendOnly .\dev.ps1 -BackendOnly
``` ```
Only admin:
```powershell
.\dev.ps1 -AdminOnly
```
Only MCP: Only MCP:
```powershell ```powershell
@@ -52,6 +59,7 @@ Direct scripts:
```powershell ```powershell
.\start-frontend.ps1 .\start-frontend.ps1
.\start-backend.ps1 .\start-backend.ps1
.\start-admin.ps1
.\start-mcp.ps1 .\start-mcp.ps1
``` ```
@@ -63,6 +71,14 @@ npm install
npm run dev npm run dev
``` ```
### Admin
```powershell
cd admin
npm install
npm run dev
```
### Backend ### Backend
```powershell ```powershell

24
admin/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

23
admin/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

23
admin/index.html Normal file
View File

@@ -0,0 +1,23 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="Termi Admin is the new React and shadcn-based control room for the blog system."
/>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Space+Grotesk:wght@400;500;700&display=swap"
rel="stylesheet"
/>
<title>Termi Admin</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3423
admin/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

40
admin/package.json Normal file
View File

@@ -0,0 +1,40 @@
{
"name": "admin",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0 --port 4322",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@tailwindcss/vite": "^4.2.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.7.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.13.2",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.57.0",
"vite": "^8.0.1"
}
}

9
admin/public/favicon.svg Normal file
View File

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
<style>
path { fill: #000; }
@media (prefers-color-scheme: dark) {
path { fill: #FFF; }
}
</style>
</svg>

After

Width:  |  Height:  |  Size: 749 B

215
admin/src/App.tsx Normal file
View File

@@ -0,0 +1,215 @@
import {
createContext,
startTransition,
useContext,
useEffect,
useCallback,
useMemo,
useState,
} from 'react'
import {
BrowserRouter,
Navigate,
Outlet,
Route,
Routes,
useNavigate,
} from 'react-router-dom'
import { LoaderCircle } from 'lucide-react'
import { Toaster, toast } from 'sonner'
import { AppShell } from '@/components/app-shell'
import { adminApi, ApiError } from '@/lib/api'
import type { AdminSessionResponse } from '@/lib/types'
import { DashboardPage } from '@/pages/dashboard-page'
import { LoginPage } from '@/pages/login-page'
import { SiteSettingsPage } from '@/pages/site-settings-page'
type SessionContextValue = {
session: AdminSessionResponse
setSession: (session: AdminSessionResponse) => void
refreshSession: () => Promise<void>
}
const SessionContext = createContext<SessionContextValue | null>(null)
function useSession() {
const context = useContext(SessionContext)
if (!context) {
throw new Error('useSession must be used inside SessionContext')
}
return context
}
function AppLoadingScreen() {
return (
<div className="flex min-h-screen items-center justify-center bg-background px-6 text-foreground">
<div className="flex max-w-md flex-col items-center gap-4 rounded-3xl border border-border/70 bg-card/80 px-8 py-10 text-center shadow-[0_24px_80px_rgba(15,23,42,0.18)] backdrop-blur">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl border border-primary/20 bg-primary/10 text-primary">
<LoaderCircle className="h-6 w-6 animate-spin" />
</div>
<div className="space-y-2">
<p className="text-xs uppercase tracking-[0.32em] text-muted-foreground">
Termi admin
</p>
<h1 className="text-2xl font-semibold tracking-tight">Booting control room</h1>
<p className="text-sm leading-6 text-muted-foreground">
Checking the current admin session and preparing the new React workspace.
</p>
</div>
</div>
</div>
)
}
function SessionGuard() {
const { session } = useSession()
if (!session.authenticated) {
return <Navigate to="/login" replace />
}
return <Outlet />
}
function PublicOnly() {
const { session, setSession } = useSession()
const navigate = useNavigate()
const [submitting, setSubmitting] = useState(false)
if (session.authenticated) {
return <Navigate to="/" replace />
}
return (
<LoginPage
submitting={submitting}
onLogin={async (payload) => {
try {
setSubmitting(true)
const nextSession = await adminApi.login(payload)
startTransition(() => {
setSession(nextSession)
})
toast.success('Admin session unlocked.')
navigate('/', { replace: true })
} catch (error) {
toast.error(
error instanceof ApiError ? error.message : 'Unable to sign in right now.',
)
} finally {
setSubmitting(false)
}
}}
/>
)
}
function ProtectedLayout() {
const { session, setSession } = useSession()
const navigate = useNavigate()
const [loggingOut, setLoggingOut] = useState(false)
return (
<AppShell
username={session.username}
loggingOut={loggingOut}
onLogout={async () => {
try {
setLoggingOut(true)
const nextSession = await adminApi.logout()
startTransition(() => {
setSession(nextSession)
})
toast.success('Admin session closed.')
navigate('/login', { replace: true })
} catch (error) {
toast.error(
error instanceof ApiError ? error.message : 'Unable to sign out right now.',
)
} finally {
setLoggingOut(false)
}
}}
>
<Outlet />
</AppShell>
)
}
function AppRoutes() {
return (
<Routes>
<Route path="/login" element={<PublicOnly />} />
<Route element={<SessionGuard />}>
<Route element={<ProtectedLayout />}>
<Route index element={<DashboardPage />} />
<Route path="/settings" element={<SiteSettingsPage />} />
</Route>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)
}
export default function App() {
const [session, setSession] = useState<AdminSessionResponse>({
authenticated: false,
username: null,
})
const [loading, setLoading] = useState(true)
const refreshSession = useCallback(async () => {
try {
const nextSession = await adminApi.sessionStatus()
startTransition(() => {
setSession(nextSession)
})
} catch (error) {
toast.error(
error instanceof ApiError ? error.message : 'Unable to reach the backend session API.',
)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void refreshSession()
}, [refreshSession])
const contextValue = useMemo<SessionContextValue>(
() => ({
session,
setSession,
refreshSession,
}),
[session, refreshSession],
)
const basename =
((import.meta.env.VITE_ADMIN_BASENAME as string | undefined)?.trim() || '').replace(
/\/$/,
'',
) || undefined
if (loading) {
return (
<>
<AppLoadingScreen />
<Toaster richColors position="top-right" />
</>
)
}
return (
<SessionContext.Provider value={contextValue}>
<BrowserRouter basename={basename}>
<AppRoutes />
</BrowserRouter>
<Toaster richColors position="top-right" />
</SessionContext.Provider>
)
}

View File

@@ -0,0 +1,171 @@
import { ExternalLink, LayoutDashboard, LogOut, Orbit, Settings, Sparkles } from 'lucide-react'
import type { ReactNode } from 'react'
import { NavLink } from 'react-router-dom'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { cn } from '@/lib/utils'
const primaryNav = [
{
to: '/',
label: 'Overview',
description: 'Live operational dashboard',
icon: LayoutDashboard,
},
{
to: '/settings',
label: 'Site settings',
description: 'Brand, profile, and AI config',
icon: Settings,
},
]
const nextNav = ['Posts editor', 'Comments moderation', 'Friend links queue', 'Review library']
export function AppShell({
children,
username,
loggingOut,
onLogout,
}: {
children: ReactNode
username: string | null
loggingOut: boolean
onLogout: () => Promise<void>
}) {
return (
<div className="min-h-screen bg-background text-foreground">
<div className="mx-auto flex min-h-screen w-full max-w-[1600px] gap-6 px-4 py-4 lg:px-6 lg:py-6">
<aside className="hidden w-[310px] shrink-0 lg:block">
<div className="sticky top-6 overflow-hidden rounded-[2rem] border border-border/70 bg-card/90 shadow-[0_32px_90px_rgba(15,23,42,0.14)] backdrop-blur">
<div className="space-y-5 p-6">
<div className="space-y-3">
<div className="inline-flex 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">
<Orbit className="h-3.5 w-3.5" />
Termi admin
</div>
<div className="space-y-2">
<h1 className="text-2xl font-semibold tracking-tight">
Control room for the blog system
</h1>
<p className="text-sm leading-6 text-muted-foreground">
A separate React workspace for operations, moderation, and AI-related site
controls.
</p>
</div>
</div>
<Separator />
<nav className="space-y-2">
{primaryNav.map((item) => {
const Icon = item.icon
return (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
className={({ isActive }) =>
cn(
'group flex items-start gap-3 rounded-2xl border px-4 py-3 transition-all',
isActive
? 'border-primary/30 bg-primary/10 shadow-[0_12px_30px_rgba(37,99,235,0.14)]'
: 'border-transparent bg-background/50 hover:border-border/80 hover:bg-accent/55',
)
}
>
{({ isActive }) => (
<>
<div
className={cn(
'mt-0.5 flex h-10 w-10 items-center justify-center rounded-xl border',
isActive
? 'border-primary/25 bg-primary/12 text-primary'
: 'border-border/80 bg-secondary text-muted-foreground',
)}
>
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<div className="font-medium">{item.label}</div>
<p className="mt-1 text-sm leading-6 text-muted-foreground">
{item.description}
</p>
</div>
</>
)}
</NavLink>
)
})}
</nav>
<Separator />
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="text-xs uppercase tracking-[0.24em] text-muted-foreground">
Migration queue
</p>
<p className="mt-1 text-sm text-muted-foreground">
Legacy Tera screens that move here next.
</p>
</div>
<Badge variant="outline">phase 1</Badge>
</div>
<div className="space-y-2">
{nextNav.map((item) => (
<div
key={item}
className="flex items-center justify-between rounded-2xl border border-border/60 bg-background/60 px-4 py-3"
>
<span className="text-sm text-muted-foreground">{item}</span>
<Badge variant="secondary">next</Badge>
</div>
))}
</div>
</div>
</div>
</div>
</aside>
<div className="min-w-0 flex-1 space-y-6">
<header className="sticky top-4 z-20 rounded-[1.8rem] border border-border/70 bg-card/80 px-5 py-4 shadow-[0_20px_60px_rgba(15,23,42,0.1)] backdrop-blur">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-2">
<div className="inline-flex items-center gap-2 rounded-full bg-secondary px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-secondary-foreground">
<Sparkles className="h-3.5 w-3.5" />
New admin workspace
</div>
<div>
<p className="text-sm text-muted-foreground">Signed in as {username ?? 'admin'}</p>
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
React + shadcn/ui foundation
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button variant="outline" asChild>
<a href="http://localhost:4321" target="_blank" rel="noreferrer">
<ExternalLink className="h-4 w-4" />
Open site
</a>
</Button>
<Button variant="ghost" onClick={() => void onLogout()} disabled={loggingOut}>
<LogOut className="h-4 w-4" />
{loggingOut ? 'Signing out...' : 'Sign out'}
</Button>
</div>
</div>
</header>
<main>{children}</main>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,33 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] transition-colors',
{
variants: {
variant: {
default: 'border-primary/20 bg-primary/10 text-primary',
secondary: 'border-border bg-secondary text-secondary-foreground',
outline: 'border-border/80 bg-background/60 text-muted-foreground',
success: 'border-emerald-500/20 bg-emerald-500/12 text-emerald-600',
warning: 'border-amber-500/20 bg-amber-500/12 text-amber-700',
danger: 'border-rose-500/20 bg-rose-500/12 text-rose-600',
},
},
defaultVariants: {
variant: 'default',
},
},
)
function Badge({
className,
variant,
...props
}: React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof badgeVariants>) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,56 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 outline-none ring-offset-background focus-visible:ring-2 focus-visible:ring-ring/70 focus-visible:ring-offset-2 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default:
'bg-primary text-primary-foreground shadow-[0_12px_30px_rgb(37_99_235_/_0.22)] hover:bg-primary/90',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
outline:
'border border-border bg-background/80 text-foreground hover:bg-accent hover:text-accent-foreground',
ghost: 'text-foreground hover:bg-accent hover:text-accent-foreground',
danger:
'bg-destructive text-destructive-foreground shadow-[0_12px_30px_rgb(220_38_38_/_0.18)] hover:bg-destructive/90',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-xl px-5',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
},
)
Button.displayName = 'Button'
export { Button, buttonVariants }

View File

@@ -0,0 +1,59 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'rounded-3xl border border-border/70 bg-card/85 text-card-foreground shadow-[0_24px_80px_rgba(15,23,42,0.12)] backdrop-blur',
className,
)}
{...props}
/>
),
)
Card.displayName = 'Card'
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col gap-2 p-6', className)} {...props} />
),
)
CardHeader.displayName = 'CardHeader'
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn('text-lg font-semibold tracking-tight text-balance', className)}
{...props}
/>
),
)
CardTitle.displayName = 'CardTitle'
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p ref={ref} className={cn('text-sm leading-6 text-muted-foreground', className)} {...props} />
))
CardDescription.displayName = 'CardDescription'
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('px-6 pb-6', className)} {...props} />
),
)
CardContent.displayName = 'CardContent'
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center px-6 pb-6', className)} {...props} />
),
)
CardFooter.displayName = 'CardFooter'
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }

View File

@@ -0,0 +1,22 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-11 w-full rounded-xl border border-input bg-background/80 px-3 py-2 text-sm shadow-sm outline-none transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring/70 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
)
},
)
Input.displayName = 'Input'
export { Input }

View File

@@ -0,0 +1,18 @@
import * as React from 'react'
import * as LabelPrimitive from '@radix-ui/react-label'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const labelVariants = cva('text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70')
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -0,0 +1,28 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
function Separator({
className,
orientation = 'horizontal',
decorative = true,
...props
}: React.HTMLAttributes<HTMLDivElement> & {
orientation?: 'horizontal' | 'vertical'
decorative?: boolean
}) {
return (
<div
aria-hidden={decorative}
data-orientation={orientation}
className={cn(
'shrink-0 bg-border/80',
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
className,
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -0,0 +1,9 @@
import type { HTMLAttributes } from 'react'
import { cn } from '@/lib/utils'
function Skeleton({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn('animate-pulse rounded-md bg-muted', className)} {...props} />
}
export { Skeleton }

View File

@@ -0,0 +1,67 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
</div>
),
)
Table.displayName = 'Table'
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn('[&_tr]:border-b [&_tr]:border-border/70', className)} {...props} />
))
TableHeader.displayName = 'TableHeader'
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
))
TableBody.displayName = 'TableBody'
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
'border-b border-border/60 transition-colors hover:bg-accent/40 data-[state=selected]:bg-accent/60',
className,
)}
{...props}
/>
),
)
TableRow.displayName = 'TableRow'
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
'h-11 px-4 text-left align-middle text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground',
className,
)}
{...props}
/>
))
TableHead.displayName = 'TableHead'
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td ref={ref} className={cn('p-4 align-middle', className)} {...props} />
))
TableCell.displayName = 'TableCell'
export { Table, TableBody, TableCell, TableHead, TableHeader, TableRow }

View File

@@ -0,0 +1,21 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<'textarea'>>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
'flex min-h-[132px] w-full rounded-2xl border border-input bg-background/80 px-3 py-3 text-sm shadow-sm outline-none transition-colors placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring/70 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
)
},
)
Textarea.displayName = 'Textarea'
export { Textarea }

121
admin/src/index.css Normal file
View File

@@ -0,0 +1,121 @@
@import "tailwindcss";
:root {
--background: oklch(0.98 0.008 240);
--foreground: oklch(0.18 0.02 255);
--card: oklch(1 0 0 / 0.82);
--card-foreground: oklch(0.18 0.02 255);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.18 0.02 255);
--primary: oklch(0.57 0.17 255);
--primary-foreground: oklch(0.98 0.01 255);
--secondary: oklch(0.94 0.02 220);
--secondary-foreground: oklch(0.28 0.03 250);
--muted: oklch(0.95 0.01 250);
--muted-foreground: oklch(0.48 0.02 250);
--accent: oklch(0.88 0.04 205);
--accent-foreground: oklch(0.24 0.03 255);
--destructive: oklch(0.62 0.22 28);
--destructive-foreground: oklch(0.98 0.01 28);
--border: oklch(0.9 0.01 250);
--input: oklch(0.91 0.01 250);
--ring: oklch(0.57 0.17 255);
--success: oklch(0.72 0.16 160);
--warning: oklch(0.81 0.16 78);
--radius: 1.15rem;
}
.dark {
--background: oklch(0.16 0.02 258);
--foreground: oklch(0.95 0.01 255);
--card: oklch(0.19 0.02 258 / 0.9);
--card-foreground: oklch(0.95 0.01 255);
--popover: oklch(0.2 0.02 258);
--popover-foreground: oklch(0.95 0.01 255);
--primary: oklch(0.71 0.15 246);
--primary-foreground: oklch(0.2 0.02 258);
--secondary: oklch(0.25 0.02 258);
--secondary-foreground: oklch(0.94 0.01 255);
--muted: oklch(0.24 0.02 258);
--muted-foreground: oklch(0.72 0.02 255);
--accent: oklch(0.31 0.04 215);
--accent-foreground: oklch(0.94 0.01 255);
--destructive: oklch(0.69 0.19 26);
--destructive-foreground: oklch(0.96 0.01 26);
--border: oklch(0.3 0.02 258);
--input: oklch(0.29 0.02 258);
--ring: oklch(0.71 0.15 246);
--success: oklch(0.75 0.15 160);
--warning: oklch(0.84 0.15 84);
}
@theme inline {
--font-sans: "Space Grotesk", ui-sans-serif, system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, monospace;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-sm: calc(var(--radius) - 0.35rem);
--radius-md: calc(var(--radius) - 0.15rem);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 0.4rem);
}
* {
@apply border-border;
}
html {
color-scheme: light;
}
body {
@apply min-h-screen bg-background font-sans text-foreground antialiased;
background-image:
radial-gradient(circle at top left, rgb(77 132 255 / 0.12), transparent 24rem),
radial-gradient(circle at top right, rgb(16 185 129 / 0.08), transparent 22rem),
linear-gradient(180deg, rgb(255 255 255 / 0.66), transparent 26rem);
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
opacity: 0.5;
background-image:
linear-gradient(rgb(119 140 173 / 0.08) 1px, transparent 1px),
linear-gradient(90deg, rgb(119 140 173 / 0.06) 1px, transparent 1px);
background-size: 100% 1.35rem, 1.35rem 100%;
mask-image: linear-gradient(180deg, rgb(0 0 0 / 0.75), transparent 86%);
}
#root {
min-height: 100vh;
}
a {
@apply transition-colors;
}
button,
input,
textarea {
font: inherit;
}

87
admin/src/lib/api.ts Normal file
View File

@@ -0,0 +1,87 @@
import type {
AdminAiReindexResponse,
AdminDashboardResponse,
AdminSessionResponse,
AdminSiteSettingsResponse,
SiteSettingsPayload,
} from '@/lib/types'
const API_BASE = import.meta.env.VITE_API_BASE?.trim() || ''
export class ApiError extends Error {
status: number
constructor(message: string, status: number) {
super(message)
this.name = 'ApiError'
this.status = status
}
}
async function readErrorMessage(response: Response) {
const text = await response.text().catch(() => '')
if (!text) {
return `Request failed with status ${response.status}.`
}
try {
const parsed = JSON.parse(text) as { description?: string; error?: string; message?: string }
return parsed.description || parsed.error || parsed.message || text
} catch {
return text
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const headers = new Headers(init?.headers)
if (init?.body && !(init.body instanceof FormData) && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json')
}
const response = await fetch(`${API_BASE}${path}`, {
...init,
headers,
})
if (!response.ok) {
throw new ApiError(await readErrorMessage(response), response.status)
}
if (response.status === 204) {
return undefined as T
}
const contentType = response.headers.get('content-type') || ''
if (contentType.includes('application/json')) {
return (await response.json()) as T
}
return (await response.text()) as T
}
export const adminApi = {
sessionStatus: () => request<AdminSessionResponse>('/api/admin/session'),
login: (payload: { username: string; password: string }) =>
request<AdminSessionResponse>('/api/admin/session/login', {
method: 'POST',
body: JSON.stringify(payload),
}),
logout: () =>
request<AdminSessionResponse>('/api/admin/session', {
method: 'DELETE',
}),
dashboard: () => request<AdminDashboardResponse>('/api/admin/dashboard'),
getSiteSettings: () => request<AdminSiteSettingsResponse>('/api/admin/site-settings'),
updateSiteSettings: (payload: SiteSettingsPayload) =>
request<AdminSiteSettingsResponse>('/api/admin/site-settings', {
method: 'PATCH',
body: JSON.stringify(payload),
}),
reindexAi: () =>
request<AdminAiReindexResponse>('/api/admin/ai/reindex', {
method: 'POST',
}),
}

137
admin/src/lib/types.ts Normal file
View File

@@ -0,0 +1,137 @@
export interface AdminSessionResponse {
authenticated: boolean
username: string | null
}
export interface DashboardStats {
total_posts: number
total_comments: number
pending_comments: number
total_categories: number
total_tags: number
total_reviews: number
total_links: number
pending_links: number
ai_chunks: number
ai_enabled: boolean
}
export interface DashboardPostItem {
id: number
title: string
slug: string
category: string
post_type: string
pinned: boolean
created_at: string
}
export interface DashboardCommentItem {
id: number
author: string
post_slug: string
scope: string
excerpt: string
approved: boolean
created_at: string
}
export interface DashboardFriendLinkItem {
id: number
site_name: string
site_url: string
category: string
status: string
created_at: string
}
export interface DashboardReviewItem {
id: number
title: string
review_type: string
rating: number
status: string
review_date: string
}
export interface DashboardSiteSummary {
site_name: string
site_url: string
ai_enabled: boolean
ai_chunks: number
ai_last_indexed_at: string | null
}
export interface AdminDashboardResponse {
stats: DashboardStats
site: DashboardSiteSummary
recent_posts: DashboardPostItem[]
pending_comments: DashboardCommentItem[]
pending_friend_links: DashboardFriendLinkItem[]
recent_reviews: DashboardReviewItem[]
}
export interface AdminSiteSettingsResponse {
id: number
site_name: string | null
site_short_name: string | null
site_url: string | null
site_title: string | null
site_description: string | null
hero_title: string | null
hero_subtitle: string | null
owner_name: string | null
owner_title: string | null
owner_bio: string | null
owner_avatar_url: string | null
social_github: string | null
social_twitter: string | null
social_email: string | null
location: string | null
tech_stack: string[]
ai_enabled: boolean
ai_provider: string | null
ai_api_base: string | null
ai_api_key: string | null
ai_chat_model: string | null
ai_embedding_model: string | null
ai_system_prompt: string | null
ai_top_k: number | null
ai_chunk_size: number | null
ai_last_indexed_at: string | null
ai_chunks_count: number
ai_local_embedding: string
}
export interface SiteSettingsPayload {
siteName?: string | null
siteShortName?: string | null
siteUrl?: string | null
siteTitle?: string | null
siteDescription?: string | null
heroTitle?: string | null
heroSubtitle?: string | null
ownerName?: string | null
ownerTitle?: string | null
ownerBio?: string | null
ownerAvatarUrl?: string | null
socialGithub?: string | null
socialTwitter?: string | null
socialEmail?: string | null
location?: string | null
techStack?: string[]
aiEnabled?: boolean
aiProvider?: string | null
aiApiBase?: string | null
aiApiKey?: string | null
aiChatModel?: string | null
aiEmbeddingModel?: string | null
aiSystemPrompt?: string | null
aiTopK?: number | null
aiChunkSize?: number | null
}
export interface AdminAiReindexResponse {
indexed_chunks: number
last_indexed_at: string | null
}

6
admin/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

11
admin/src/main.tsx Normal file
View File

@@ -0,0 +1,11 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.tsx'
import './index.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View 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>
)
}

View 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>
)
}

View 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>
)
}

10
admin/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE?: string
readonly VITE_ADMIN_BASENAME?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

32
admin/tsconfig.app.json Normal file
View File

@@ -0,0 +1,32 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2023",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
admin/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
admin/tsconfig.node.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

23
admin/vite.config.ts Normal file
View File

@@ -0,0 +1,23 @@
import path from 'node:path'
import tailwindcss from '@tailwindcss/vite'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 4322,
proxy: {
'/api': {
target: 'http://127.0.0.1:5150',
changeOrigin: true,
},
},
},
})

View File

@@ -63,6 +63,7 @@ impl Hooks for App {
fn routes(_ctx: &AppContext) -> AppRoutes { fn routes(_ctx: &AppContext) -> AppRoutes {
AppRoutes::with_default_routes() // controller routes below AppRoutes::with_default_routes() // controller routes below
.add_route(controllers::admin::routes()) .add_route(controllers::admin::routes())
.add_route(controllers::admin_api::routes())
.add_route(controllers::review::routes()) .add_route(controllers::review::routes())
.add_route(controllers::category::routes()) .add_route(controllers::category::routes())
.add_route(controllers::friend_link::routes()) .add_route(controllers::friend_link::routes())

View File

@@ -19,6 +19,8 @@ use crate::services::{ai, content};
static ADMIN_LOGGED_IN: AtomicBool = AtomicBool::new(false); static ADMIN_LOGGED_IN: AtomicBool = AtomicBool::new(false);
const FRONTEND_BASE_URL: &str = "http://localhost:4321"; const FRONTEND_BASE_URL: &str = "http://localhost:4321";
const DEFAULT_ADMIN_USERNAME: &str = "admin";
const DEFAULT_ADMIN_PASSWORD: &str = "admin123";
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct LoginForm { pub struct LoginForm {
@@ -397,15 +399,35 @@ fn render_admin(
format::view(&view_engine.0, template, Value::Object(context)) format::view(&view_engine.0, template, Value::Object(context))
} }
pub(crate) fn admin_username() -> String {
std::env::var("TERMI_ADMIN_USERNAME").unwrap_or_else(|_| DEFAULT_ADMIN_USERNAME.to_string())
}
pub(crate) fn admin_password() -> String {
std::env::var("TERMI_ADMIN_PASSWORD").unwrap_or_else(|_| DEFAULT_ADMIN_PASSWORD.to_string())
}
pub(crate) fn is_admin_logged_in() -> bool {
ADMIN_LOGGED_IN.load(Ordering::SeqCst)
}
pub(crate) fn set_admin_logged_in(value: bool) {
ADMIN_LOGGED_IN.store(value, Ordering::SeqCst);
}
pub(crate) fn validate_admin_credentials(username: &str, password: &str) -> bool {
username == admin_username() && password == admin_password()
}
pub(crate) fn check_auth() -> Result<()> { pub(crate) fn check_auth() -> Result<()> {
if !ADMIN_LOGGED_IN.load(Ordering::SeqCst) { if !is_admin_logged_in() {
return Err(Error::Unauthorized("Not logged in".to_string())); return Err(Error::Unauthorized("Not logged in".to_string()));
} }
Ok(()) Ok(())
} }
pub async fn root() -> Result<impl IntoResponse> { pub async fn root() -> Result<impl IntoResponse> {
if ADMIN_LOGGED_IN.load(Ordering::SeqCst) { if is_admin_logged_in() {
Ok(format::redirect("/admin")) Ok(format::redirect("/admin"))
} else { } else {
Ok(format::redirect("/admin/login")) Ok(format::redirect("/admin/login"))
@@ -428,15 +450,15 @@ pub async fn login_page(
} }
pub async fn login_submit(Form(form): Form<LoginForm>) -> Result<impl IntoResponse> { pub async fn login_submit(Form(form): Form<LoginForm>) -> Result<impl IntoResponse> {
if form.username == "admin" && form.password == "admin123" { if validate_admin_credentials(&form.username, &form.password) {
ADMIN_LOGGED_IN.store(true, Ordering::SeqCst); set_admin_logged_in(true);
return Ok(format::redirect("/admin")); return Ok(format::redirect("/admin"));
} }
Ok(format::redirect("/admin/login?error=1")) Ok(format::redirect("/admin/login?error=1"))
} }
pub async fn logout() -> Result<impl IntoResponse> { pub async fn logout() -> Result<impl IntoResponse> {
ADMIN_LOGGED_IN.store(false, Ordering::SeqCst); set_admin_logged_in(false);
Ok(format::redirect("/admin/login")) Ok(format::redirect("/admin/login"))
} }

View File

@@ -0,0 +1,408 @@
use loco_rs::prelude::*;
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, PaginatorTrait, QueryFilter,
QueryOrder, QuerySelect,
};
use serde::{Deserialize, Serialize};
use crate::{
controllers::{
admin::{admin_username, check_auth, is_admin_logged_in, set_admin_logged_in, validate_admin_credentials},
site_settings::{self, SiteSettingsPayload},
},
models::_entities::{ai_chunks, comments, friend_links, posts, reviews},
services::{ai, content},
};
#[derive(Clone, Debug, Deserialize)]
pub struct AdminLoginPayload {
pub username: String,
pub password: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct AdminSessionResponse {
pub authenticated: bool,
pub username: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct DashboardStats {
pub total_posts: u64,
pub total_comments: u64,
pub pending_comments: u64,
pub total_categories: u64,
pub total_tags: u64,
pub total_reviews: u64,
pub total_links: u64,
pub pending_links: u64,
pub ai_chunks: u64,
pub ai_enabled: bool,
}
#[derive(Clone, Debug, Serialize)]
pub struct DashboardPostItem {
pub id: i32,
pub title: String,
pub slug: String,
pub category: String,
pub post_type: String,
pub pinned: bool,
pub created_at: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct DashboardCommentItem {
pub id: i32,
pub author: String,
pub post_slug: String,
pub scope: String,
pub excerpt: String,
pub approved: bool,
pub created_at: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct DashboardFriendLinkItem {
pub id: i32,
pub site_name: String,
pub site_url: String,
pub category: String,
pub status: String,
pub created_at: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct DashboardReviewItem {
pub id: i32,
pub title: String,
pub review_type: String,
pub rating: i32,
pub status: String,
pub review_date: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct DashboardSiteSummary {
pub site_name: String,
pub site_url: String,
pub ai_enabled: bool,
pub ai_chunks: u64,
pub ai_last_indexed_at: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct AdminDashboardResponse {
pub stats: DashboardStats,
pub site: DashboardSiteSummary,
pub recent_posts: Vec<DashboardPostItem>,
pub pending_comments: Vec<DashboardCommentItem>,
pub pending_friend_links: Vec<DashboardFriendLinkItem>,
pub recent_reviews: Vec<DashboardReviewItem>,
}
#[derive(Clone, Debug, Serialize)]
pub struct AdminSiteSettingsResponse {
pub id: i32,
pub site_name: Option<String>,
pub site_short_name: Option<String>,
pub site_url: Option<String>,
pub site_title: Option<String>,
pub site_description: Option<String>,
pub hero_title: Option<String>,
pub hero_subtitle: Option<String>,
pub owner_name: Option<String>,
pub owner_title: Option<String>,
pub owner_bio: Option<String>,
pub owner_avatar_url: Option<String>,
pub social_github: Option<String>,
pub social_twitter: Option<String>,
pub social_email: Option<String>,
pub location: Option<String>,
pub tech_stack: Vec<String>,
pub ai_enabled: bool,
pub ai_provider: Option<String>,
pub ai_api_base: Option<String>,
pub ai_api_key: Option<String>,
pub ai_chat_model: Option<String>,
pub ai_embedding_model: Option<String>,
pub ai_system_prompt: Option<String>,
pub ai_top_k: Option<i32>,
pub ai_chunk_size: Option<i32>,
pub ai_last_indexed_at: Option<String>,
pub ai_chunks_count: u64,
pub ai_local_embedding: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct AdminAiReindexResponse {
pub indexed_chunks: usize,
pub last_indexed_at: Option<String>,
}
fn format_timestamp(
value: Option<sea_orm::prelude::DateTimeWithTimeZone>,
pattern: &str,
) -> Option<String> {
value.map(|item| item.format(pattern).to_string())
}
fn required_text(value: Option<&str>, fallback: &str) -> String {
value
.map(str::trim)
.filter(|item| !item.is_empty())
.unwrap_or(fallback)
.to_string()
}
fn tech_stack_values(value: &Option<serde_json::Value>) -> Vec<String> {
value
.as_ref()
.and_then(serde_json::Value::as_array)
.cloned()
.unwrap_or_default()
.into_iter()
.filter_map(|item| item.as_str().map(ToString::to_string))
.collect()
}
fn build_settings_response(
item: crate::models::_entities::site_settings::Model,
ai_chunks_count: u64,
) -> AdminSiteSettingsResponse {
AdminSiteSettingsResponse {
id: item.id,
site_name: item.site_name,
site_short_name: item.site_short_name,
site_url: item.site_url,
site_title: item.site_title,
site_description: item.site_description,
hero_title: item.hero_title,
hero_subtitle: item.hero_subtitle,
owner_name: item.owner_name,
owner_title: item.owner_title,
owner_bio: item.owner_bio,
owner_avatar_url: item.owner_avatar_url,
social_github: item.social_github,
social_twitter: item.social_twitter,
social_email: item.social_email,
location: item.location,
tech_stack: tech_stack_values(&item.tech_stack),
ai_enabled: item.ai_enabled.unwrap_or(false),
ai_provider: item.ai_provider,
ai_api_base: item.ai_api_base,
ai_api_key: item.ai_api_key,
ai_chat_model: item.ai_chat_model,
ai_embedding_model: item.ai_embedding_model,
ai_system_prompt: item.ai_system_prompt,
ai_top_k: item.ai_top_k,
ai_chunk_size: item.ai_chunk_size,
ai_last_indexed_at: format_timestamp(item.ai_last_indexed_at, "%Y-%m-%d %H:%M:%S UTC"),
ai_chunks_count,
ai_local_embedding: ai::local_embedding_label().to_string(),
}
}
#[debug_handler]
pub async fn session_status() -> Result<Response> {
format::json(AdminSessionResponse {
authenticated: is_admin_logged_in(),
username: is_admin_logged_in().then(admin_username),
})
}
#[debug_handler]
pub async fn session_login(Json(payload): Json<AdminLoginPayload>) -> Result<Response> {
if !validate_admin_credentials(payload.username.trim(), payload.password.trim()) {
return unauthorized("Invalid credentials");
}
set_admin_logged_in(true);
format::json(AdminSessionResponse {
authenticated: true,
username: Some(admin_username()),
})
}
#[debug_handler]
pub async fn session_logout() -> Result<Response> {
set_admin_logged_in(false);
format::json(AdminSessionResponse {
authenticated: false,
username: None,
})
}
#[debug_handler]
pub async fn dashboard(State(ctx): State<AppContext>) -> Result<Response> {
check_auth()?;
content::sync_markdown_posts(&ctx).await?;
let total_posts = posts::Entity::find().count(&ctx.db).await?;
let total_comments = comments::Entity::find().count(&ctx.db).await?;
let pending_comments = comments::Entity::find()
.filter(comments::Column::Approved.eq(false))
.count(&ctx.db)
.await?;
let total_categories = crate::models::_entities::categories::Entity::find()
.count(&ctx.db)
.await?;
let total_tags = crate::models::_entities::tags::Entity::find()
.count(&ctx.db)
.await?;
let total_reviews = reviews::Entity::find().count(&ctx.db).await?;
let total_links = friend_links::Entity::find().count(&ctx.db).await?;
let pending_links = friend_links::Entity::find()
.filter(friend_links::Column::Status.eq("pending"))
.count(&ctx.db)
.await?;
let ai_chunks_count = ai_chunks::Entity::find().count(&ctx.db).await?;
let site_settings = site_settings::load_current(&ctx).await?;
let recent_posts = posts::Entity::find()
.order_by_desc(posts::Column::CreatedAt)
.limit(6)
.all(&ctx.db)
.await?
.into_iter()
.map(|post| DashboardPostItem {
id: post.id,
title: required_text(post.title.as_deref(), "Untitled post"),
slug: post.slug,
category: required_text(post.category.as_deref(), "Uncategorized"),
post_type: required_text(post.post_type.as_deref(), "article"),
pinned: post.pinned.unwrap_or(false),
created_at: post.created_at.format("%Y-%m-%d %H:%M").to_string(),
})
.collect::<Vec<_>>();
let pending_comment_rows = comments::Entity::find()
.filter(comments::Column::Approved.eq(false))
.order_by_desc(comments::Column::CreatedAt)
.limit(8)
.all(&ctx.db)
.await?
.into_iter()
.map(|comment| DashboardCommentItem {
id: comment.id,
author: required_text(comment.author.as_deref(), "Anonymous"),
post_slug: required_text(comment.post_slug.as_deref(), "unknown-post"),
scope: required_text(Some(comment.scope.as_str()), "global"),
excerpt: required_text(comment.content.as_deref(), ""),
approved: comment.approved.unwrap_or(false),
created_at: comment.created_at.format("%Y-%m-%d %H:%M").to_string(),
})
.collect::<Vec<_>>();
let pending_friend_links = friend_links::Entity::find()
.filter(friend_links::Column::Status.eq("pending"))
.order_by_desc(friend_links::Column::CreatedAt)
.limit(6)
.all(&ctx.db)
.await?
.into_iter()
.map(|link| DashboardFriendLinkItem {
id: link.id,
site_name: required_text(link.site_name.as_deref(), "Unnamed site"),
site_url: link.site_url,
category: required_text(link.category.as_deref(), "Other"),
status: required_text(link.status.as_deref(), "pending"),
created_at: link.created_at.format("%Y-%m-%d %H:%M").to_string(),
})
.collect::<Vec<_>>();
let recent_reviews = reviews::Entity::find()
.order_by_desc(reviews::Column::CreatedAt)
.limit(6)
.all(&ctx.db)
.await?
.into_iter()
.map(|review| DashboardReviewItem {
id: review.id,
title: required_text(review.title.as_deref(), "Untitled review"),
review_type: required_text(review.review_type.as_deref(), "game"),
rating: review.rating.unwrap_or(0),
status: required_text(review.status.as_deref(), "completed"),
review_date: required_text(review.review_date.as_deref(), ""),
})
.collect::<Vec<_>>();
format::json(AdminDashboardResponse {
stats: DashboardStats {
total_posts,
total_comments,
pending_comments,
total_categories,
total_tags,
total_reviews,
total_links,
pending_links,
ai_chunks: ai_chunks_count,
ai_enabled: site_settings.ai_enabled.unwrap_or(false),
},
site: DashboardSiteSummary {
site_name: required_text(site_settings.site_name.as_deref(), "Unnamed site"),
site_url: required_text(site_settings.site_url.as_deref(), ""),
ai_enabled: site_settings.ai_enabled.unwrap_or(false),
ai_chunks: ai_chunks_count,
ai_last_indexed_at: format_timestamp(
site_settings.ai_last_indexed_at,
"%Y-%m-%d %H:%M:%S UTC",
),
},
recent_posts,
pending_comments: pending_comment_rows,
pending_friend_links,
recent_reviews,
})
}
#[debug_handler]
pub async fn get_site_settings(State(ctx): State<AppContext>) -> Result<Response> {
check_auth()?;
let current = site_settings::load_current(&ctx).await?;
let ai_chunks_count = ai_chunks::Entity::find().count(&ctx.db).await?;
format::json(build_settings_response(current, ai_chunks_count))
}
#[debug_handler]
pub async fn update_site_settings(
State(ctx): State<AppContext>,
Json(params): Json<SiteSettingsPayload>,
) -> Result<Response> {
check_auth()?;
let current = site_settings::load_current(&ctx).await?;
let mut item = current.into_active_model();
params.apply(&mut item);
let updated = item.update(&ctx.db).await?;
let ai_chunks_count = ai_chunks::Entity::find().count(&ctx.db).await?;
format::json(build_settings_response(updated, ai_chunks_count))
}
#[debug_handler]
pub async fn reindex_ai(State(ctx): State<AppContext>) -> Result<Response> {
check_auth()?;
let summary = ai::rebuild_index(&ctx).await?;
format::json(AdminAiReindexResponse {
indexed_chunks: summary.indexed_chunks,
last_indexed_at: format_timestamp(summary.last_indexed_at.map(Into::into), "%Y-%m-%d %H:%M:%S UTC"),
})
}
pub fn routes() -> Routes {
Routes::new()
.prefix("/api/admin")
.add("/session", get(session_status))
.add("/session", delete(session_logout))
.add("/session/login", post(session_login))
.add("/dashboard", get(dashboard))
.add("/site-settings", get(get_site_settings))
.add("/site-settings", patch(update_site_settings))
.add("/site-settings", put(update_site_settings))
.add("/ai/reindex", post(reindex_ai))
}

View File

@@ -1,4 +1,5 @@
pub mod admin; pub mod admin;
pub mod admin_api;
pub mod ai; pub mod ai;
pub mod auth; pub mod auth;
pub mod category; pub mod category;

View File

@@ -104,7 +104,7 @@ fn normalize_optional_int(value: Option<i32>, min: i32, max: i32) -> Option<i32>
} }
impl SiteSettingsPayload { impl SiteSettingsPayload {
fn apply(self, item: &mut ActiveModel) { pub(crate) fn apply(self, item: &mut ActiveModel) {
if let Some(site_name) = self.site_name { if let Some(site_name) = self.site_name {
item.site_name = Set(normalize_optional_string(Some(site_name))); item.site_name = Set(normalize_optional_string(Some(site_name)));
} }

21
dev.ps1
View File

@@ -1,6 +1,7 @@
param( param(
[switch]$FrontendOnly, [switch]$FrontendOnly,
[switch]$BackendOnly, [switch]$BackendOnly,
[switch]$AdminOnly,
[switch]$McpOnly, [switch]$McpOnly,
[switch]$WithMcp, [switch]$WithMcp,
[string]$DatabaseUrl = "postgres://postgres:postgres%402025%21@10.0.0.2:5432/termi-api_development" [string]$DatabaseUrl = "postgres://postgres:postgres%402025%21@10.0.0.2:5432/termi-api_development"
@@ -11,10 +12,11 @@ $ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $MyInvocation.MyCommand.Path $repoRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
$frontendScript = Join-Path $repoRoot "start-frontend.ps1" $frontendScript = Join-Path $repoRoot "start-frontend.ps1"
$backendScript = Join-Path $repoRoot "start-backend.ps1" $backendScript = Join-Path $repoRoot "start-backend.ps1"
$adminScript = Join-Path $repoRoot "start-admin.ps1"
$mcpScript = Join-Path $repoRoot "start-mcp.ps1" $mcpScript = Join-Path $repoRoot "start-mcp.ps1"
if (@($FrontendOnly, $BackendOnly, $McpOnly).Where({ $_ }).Count -gt 1) { if (@($FrontendOnly, $BackendOnly, $AdminOnly, $McpOnly).Where({ $_ }).Count -gt 1) {
throw "Use only one of -FrontendOnly, -BackendOnly, or -McpOnly." throw "Use only one of -FrontendOnly, -BackendOnly, -AdminOnly, or -McpOnly."
} }
if ($FrontendOnly) { if ($FrontendOnly) {
@@ -27,12 +29,17 @@ if ($BackendOnly) {
exit $LASTEXITCODE exit $LASTEXITCODE
} }
if ($AdminOnly) {
& $adminScript
exit $LASTEXITCODE
}
if ($McpOnly) { if ($McpOnly) {
& $mcpScript & $mcpScript
exit $LASTEXITCODE exit $LASTEXITCODE
} }
$services = if ($WithMcp) { "frontend, backend, and MCP" } else { "frontend and backend" } $services = if ($WithMcp) { "frontend, admin, backend, and MCP" } else { "frontend, admin, and backend" }
Write-Host "[monorepo] Starting $services in separate PowerShell windows..." -ForegroundColor Cyan Write-Host "[monorepo] Starting $services in separate PowerShell windows..." -ForegroundColor Cyan
Start-Process powershell -ArgumentList @( Start-Process powershell -ArgumentList @(
@@ -48,6 +55,12 @@ Start-Process powershell -ArgumentList @(
"-DatabaseUrl", $DatabaseUrl "-DatabaseUrl", $DatabaseUrl
) )
Start-Process powershell -ArgumentList @(
"-NoExit",
"-ExecutionPolicy", "Bypass",
"-File", $adminScript
)
if ($WithMcp) { if ($WithMcp) {
Start-Process powershell -ArgumentList @( Start-Process powershell -ArgumentList @(
"-NoExit", "-NoExit",
@@ -56,5 +69,5 @@ if ($WithMcp) {
) )
} }
$servicesStarted = if ($WithMcp) { "Frontend, backend, and MCP windows started." } else { "Frontend window and backend window started." } $servicesStarted = if ($WithMcp) { "Frontend, admin, backend, and MCP windows started." } else { "Frontend, admin, and backend windows started." }
Write-Host "[monorepo] $servicesStarted" -ForegroundColor Green Write-Host "[monorepo] $servicesStarted" -ForegroundColor Green

View File

@@ -1,6 +1,7 @@
param( param(
[switch]$FrontendOnly, [switch]$FrontendOnly,
[switch]$BackendOnly, [switch]$BackendOnly,
[switch]$AdminOnly,
[switch]$McpOnly, [switch]$McpOnly,
[switch]$WithMcp, [switch]$WithMcp,
[string]$DatabaseUrl = "postgres://postgres:postgres%402025%21@10.0.0.2:5432/termi-api_development", [string]$DatabaseUrl = "postgres://postgres:postgres%402025%21@10.0.0.2:5432/termi-api_development",
@@ -16,10 +17,11 @@ $stopScript = Join-Path $repoRoot "stop-services.ps1"
$devScript = Join-Path $repoRoot "dev.ps1" $devScript = Join-Path $repoRoot "dev.ps1"
$frontendScript = Join-Path $repoRoot "start-frontend.ps1" $frontendScript = Join-Path $repoRoot "start-frontend.ps1"
$backendScript = Join-Path $repoRoot "start-backend.ps1" $backendScript = Join-Path $repoRoot "start-backend.ps1"
$adminScript = Join-Path $repoRoot "start-admin.ps1"
$mcpScript = Join-Path $repoRoot "start-mcp.ps1" $mcpScript = Join-Path $repoRoot "start-mcp.ps1"
if (@($FrontendOnly, $BackendOnly, $McpOnly).Where({ $_ }).Count -gt 1) { if (@($FrontendOnly, $BackendOnly, $AdminOnly, $McpOnly).Where({ $_ }).Count -gt 1) {
throw "Use only one of -FrontendOnly, -BackendOnly, or -McpOnly." throw "Use only one of -FrontendOnly, -BackendOnly, -AdminOnly, or -McpOnly."
} }
Write-Host "[restart] Stopping target services first..." -ForegroundColor Cyan Write-Host "[restart] Stopping target services first..." -ForegroundColor Cyan
@@ -30,6 +32,9 @@ if ($FrontendOnly) {
elseif ($BackendOnly) { elseif ($BackendOnly) {
& $stopScript -BackendOnly & $stopScript -BackendOnly
} }
elseif ($AdminOnly) {
& $stopScript -AdminOnly
}
elseif ($McpOnly) { elseif ($McpOnly) {
& $stopScript -McpOnly & $stopScript -McpOnly
} }
@@ -60,6 +65,16 @@ if ($BackendOnly) {
exit 0 exit 0
} }
if ($AdminOnly) {
Start-Process powershell -ArgumentList @(
"-NoExit",
"-ExecutionPolicy", "Bypass",
"-File", $adminScript
)
Write-Host "[restart] Admin window restarted." -ForegroundColor Green
exit 0
}
if ($McpOnly) { if ($McpOnly) {
Start-Process powershell -ArgumentList @( Start-Process powershell -ArgumentList @(
"-NoExit", "-NoExit",

33
start-admin.ps1 Normal file
View File

@@ -0,0 +1,33 @@
param(
[switch]$Install
)
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
$adminDir = Join-Path $repoRoot "admin"
if (-not (Test-Path $adminDir)) {
throw "Admin directory not found: $adminDir"
}
Push-Location $adminDir
try {
if ($Install -or -not (Test-Path (Join-Path $adminDir "node_modules"))) {
Write-Host "[admin] Installing dependencies..." -ForegroundColor Cyan
npm install
if ($LASTEXITCODE -ne 0) {
throw "npm install failed"
}
}
Write-Host "[admin] Starting Vite admin workspace..." -ForegroundColor Green
npm run dev
if ($LASTEXITCODE -ne 0) {
throw "npm run dev failed"
}
}
finally {
Pop-Location
}

View File

@@ -1,6 +1,7 @@
param( param(
[switch]$FrontendOnly, [switch]$FrontendOnly,
[switch]$BackendOnly, [switch]$BackendOnly,
[switch]$AdminOnly,
[switch]$McpOnly, [switch]$McpOnly,
[switch]$WithMcp [switch]$WithMcp
) )
@@ -9,8 +10,8 @@ $ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $MyInvocation.MyCommand.Path $repoRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
if (@($FrontendOnly, $BackendOnly, $McpOnly).Where({ $_ }).Count -gt 1) { if (@($FrontendOnly, $BackendOnly, $AdminOnly, $McpOnly).Where({ $_ }).Count -gt 1) {
throw "Use only one of -FrontendOnly, -BackendOnly, or -McpOnly." throw "Use only one of -FrontendOnly, -BackendOnly, -AdminOnly, or -McpOnly."
} }
function Stop-RepoShells { function Stop-RepoShells {
@@ -75,6 +76,11 @@ function Stop-Backend {
Stop-PortOwner -Port 5150 -Label "backend" Stop-PortOwner -Port 5150 -Label "backend"
} }
function Stop-Admin {
Stop-RepoShells -ScriptName "start-admin.ps1"
Stop-PortOwner -Port 4322 -Label "admin"
}
function Stop-Mcp { function Stop-Mcp {
Stop-RepoShells -ScriptName "start-mcp.ps1" Stop-RepoShells -ScriptName "start-mcp.ps1"
Stop-PortOwner -Port 5151 -Label "MCP" Stop-PortOwner -Port 5151 -Label "MCP"
@@ -90,13 +96,19 @@ if ($BackendOnly) {
exit 0 exit 0
} }
if ($AdminOnly) {
Stop-Admin
exit 0
}
if ($McpOnly) { if ($McpOnly) {
Stop-Mcp Stop-Mcp
exit 0 exit 0
} }
Write-Host "[stop] Stopping frontend and backend services..." -ForegroundColor Cyan Write-Host "[stop] Stopping frontend, admin, and backend services..." -ForegroundColor Cyan
Stop-Frontend Stop-Frontend
Stop-Admin
Stop-Backend Stop-Backend
if ($WithMcp) { if ($WithMcp) {