feat: add shadcn admin workspace
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -5,6 +5,8 @@
|
||||
frontend/.astro/
|
||||
frontend/dist/
|
||||
frontend/node_modules/
|
||||
admin/dist/
|
||||
admin/node_modules/
|
||||
mcp-server/node_modules/
|
||||
|
||||
backend/target/
|
||||
@@ -12,3 +14,5 @@ backend/.loco-start.err.log
|
||||
backend/.loco-start.out.log
|
||||
backend/backend-start.log
|
||||
backend/storage/ai_embedding_models/
|
||||
backend-start.err
|
||||
backend-start.log
|
||||
|
||||
18
README.md
18
README.md
@@ -6,8 +6,9 @@ Monorepo for the Termi blog system.
|
||||
|
||||
```text
|
||||
.
|
||||
├─ admin/ # React + shadcn admin workspace
|
||||
├─ 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
|
||||
├─ .codex/ # Codex workspace config
|
||||
└─ .vscode/ # Editor workspace config
|
||||
@@ -41,6 +42,12 @@ Only backend:
|
||||
.\dev.ps1 -BackendOnly
|
||||
```
|
||||
|
||||
Only admin:
|
||||
|
||||
```powershell
|
||||
.\dev.ps1 -AdminOnly
|
||||
```
|
||||
|
||||
Only MCP:
|
||||
|
||||
```powershell
|
||||
@@ -52,6 +59,7 @@ Direct scripts:
|
||||
```powershell
|
||||
.\start-frontend.ps1
|
||||
.\start-backend.ps1
|
||||
.\start-admin.ps1
|
||||
.\start-mcp.ps1
|
||||
```
|
||||
|
||||
@@ -63,6 +71,14 @@ npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Admin
|
||||
|
||||
```powershell
|
||||
cd admin
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Backend
|
||||
|
||||
```powershell
|
||||
|
||||
24
admin/.gitignore
vendored
Normal file
24
admin/.gitignore
vendored
Normal 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
23
admin/eslint.config.js
Normal 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
23
admin/index.html
Normal 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
3423
admin/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
admin/package.json
Normal file
40
admin/package.json
Normal 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
9
admin/public/favicon.svg
Normal 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
215
admin/src/App.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
171
admin/src/components/app-shell.tsx
Normal file
171
admin/src/components/app-shell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
33
admin/src/components/ui/badge.tsx
Normal file
33
admin/src/components/ui/badge.tsx
Normal 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 }
|
||||
56
admin/src/components/ui/button.tsx
Normal file
56
admin/src/components/ui/button.tsx
Normal 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 }
|
||||
59
admin/src/components/ui/card.tsx
Normal file
59
admin/src/components/ui/card.tsx
Normal 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 }
|
||||
22
admin/src/components/ui/input.tsx
Normal file
22
admin/src/components/ui/input.tsx
Normal 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 }
|
||||
18
admin/src/components/ui/label.tsx
Normal file
18
admin/src/components/ui/label.tsx
Normal 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 }
|
||||
28
admin/src/components/ui/separator.tsx
Normal file
28
admin/src/components/ui/separator.tsx
Normal 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 }
|
||||
9
admin/src/components/ui/skeleton.tsx
Normal file
9
admin/src/components/ui/skeleton.tsx
Normal 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 }
|
||||
67
admin/src/components/ui/table.tsx
Normal file
67
admin/src/components/ui/table.tsx
Normal 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 }
|
||||
21
admin/src/components/ui/textarea.tsx
Normal file
21
admin/src/components/ui/textarea.tsx
Normal 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
121
admin/src/index.css
Normal 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
87
admin/src/lib/api.ts
Normal 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
137
admin/src/lib/types.ts
Normal 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
6
admin/src/lib/utils.ts
Normal 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
11
admin/src/main.tsx
Normal 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>,
|
||||
)
|
||||
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>
|
||||
)
|
||||
}
|
||||
10
admin/src/vite-env.d.ts
vendored
Normal file
10
admin/src/vite-env.d.ts
vendored
Normal 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
32
admin/tsconfig.app.json
Normal 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
7
admin/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
admin/tsconfig.node.json
Normal file
26
admin/tsconfig.node.json
Normal 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
23
admin/vite.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -63,6 +63,7 @@ impl Hooks for App {
|
||||
fn routes(_ctx: &AppContext) -> AppRoutes {
|
||||
AppRoutes::with_default_routes() // controller routes below
|
||||
.add_route(controllers::admin::routes())
|
||||
.add_route(controllers::admin_api::routes())
|
||||
.add_route(controllers::review::routes())
|
||||
.add_route(controllers::category::routes())
|
||||
.add_route(controllers::friend_link::routes())
|
||||
|
||||
@@ -19,6 +19,8 @@ use crate::services::{ai, content};
|
||||
|
||||
static ADMIN_LOGGED_IN: AtomicBool = AtomicBool::new(false);
|
||||
const FRONTEND_BASE_URL: &str = "http://localhost:4321";
|
||||
const DEFAULT_ADMIN_USERNAME: &str = "admin";
|
||||
const DEFAULT_ADMIN_PASSWORD: &str = "admin123";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LoginForm {
|
||||
@@ -397,15 +399,35 @@ fn render_admin(
|
||||
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<()> {
|
||||
if !ADMIN_LOGGED_IN.load(Ordering::SeqCst) {
|
||||
if !is_admin_logged_in() {
|
||||
return Err(Error::Unauthorized("Not logged in".to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn root() -> Result<impl IntoResponse> {
|
||||
if ADMIN_LOGGED_IN.load(Ordering::SeqCst) {
|
||||
if is_admin_logged_in() {
|
||||
Ok(format::redirect("/admin"))
|
||||
} else {
|
||||
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> {
|
||||
if form.username == "admin" && form.password == "admin123" {
|
||||
ADMIN_LOGGED_IN.store(true, Ordering::SeqCst);
|
||||
if validate_admin_credentials(&form.username, &form.password) {
|
||||
set_admin_logged_in(true);
|
||||
return Ok(format::redirect("/admin"));
|
||||
}
|
||||
Ok(format::redirect("/admin/login?error=1"))
|
||||
}
|
||||
|
||||
pub async fn logout() -> Result<impl IntoResponse> {
|
||||
ADMIN_LOGGED_IN.store(false, Ordering::SeqCst);
|
||||
set_admin_logged_in(false);
|
||||
Ok(format::redirect("/admin/login"))
|
||||
}
|
||||
|
||||
|
||||
408
backend/src/controllers/admin_api.rs
Normal file
408
backend/src/controllers/admin_api.rs
Normal 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))
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod admin;
|
||||
pub mod admin_api;
|
||||
pub mod ai;
|
||||
pub mod auth;
|
||||
pub mod category;
|
||||
|
||||
@@ -104,7 +104,7 @@ fn normalize_optional_int(value: Option<i32>, min: i32, max: i32) -> Option<i32>
|
||||
}
|
||||
|
||||
impl SiteSettingsPayload {
|
||||
fn apply(self, item: &mut ActiveModel) {
|
||||
pub(crate) fn apply(self, item: &mut ActiveModel) {
|
||||
if let Some(site_name) = self.site_name {
|
||||
item.site_name = Set(normalize_optional_string(Some(site_name)));
|
||||
}
|
||||
|
||||
21
dev.ps1
21
dev.ps1
@@ -1,6 +1,7 @@
|
||||
param(
|
||||
[switch]$FrontendOnly,
|
||||
[switch]$BackendOnly,
|
||||
[switch]$AdminOnly,
|
||||
[switch]$McpOnly,
|
||||
[switch]$WithMcp,
|
||||
[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
|
||||
$frontendScript = Join-Path $repoRoot "start-frontend.ps1"
|
||||
$backendScript = Join-Path $repoRoot "start-backend.ps1"
|
||||
$adminScript = Join-Path $repoRoot "start-admin.ps1"
|
||||
$mcpScript = Join-Path $repoRoot "start-mcp.ps1"
|
||||
|
||||
if (@($FrontendOnly, $BackendOnly, $McpOnly).Where({ $_ }).Count -gt 1) {
|
||||
throw "Use only one of -FrontendOnly, -BackendOnly, or -McpOnly."
|
||||
if (@($FrontendOnly, $BackendOnly, $AdminOnly, $McpOnly).Where({ $_ }).Count -gt 1) {
|
||||
throw "Use only one of -FrontendOnly, -BackendOnly, -AdminOnly, or -McpOnly."
|
||||
}
|
||||
|
||||
if ($FrontendOnly) {
|
||||
@@ -27,12 +29,17 @@ if ($BackendOnly) {
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
|
||||
if ($AdminOnly) {
|
||||
& $adminScript
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
|
||||
if ($McpOnly) {
|
||||
& $mcpScript
|
||||
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
|
||||
|
||||
Start-Process powershell -ArgumentList @(
|
||||
@@ -48,6 +55,12 @@ Start-Process powershell -ArgumentList @(
|
||||
"-DatabaseUrl", $DatabaseUrl
|
||||
)
|
||||
|
||||
Start-Process powershell -ArgumentList @(
|
||||
"-NoExit",
|
||||
"-ExecutionPolicy", "Bypass",
|
||||
"-File", $adminScript
|
||||
)
|
||||
|
||||
if ($WithMcp) {
|
||||
Start-Process powershell -ArgumentList @(
|
||||
"-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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
param(
|
||||
[switch]$FrontendOnly,
|
||||
[switch]$BackendOnly,
|
||||
[switch]$AdminOnly,
|
||||
[switch]$McpOnly,
|
||||
[switch]$WithMcp,
|
||||
[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"
|
||||
$frontendScript = Join-Path $repoRoot "start-frontend.ps1"
|
||||
$backendScript = Join-Path $repoRoot "start-backend.ps1"
|
||||
$adminScript = Join-Path $repoRoot "start-admin.ps1"
|
||||
$mcpScript = Join-Path $repoRoot "start-mcp.ps1"
|
||||
|
||||
if (@($FrontendOnly, $BackendOnly, $McpOnly).Where({ $_ }).Count -gt 1) {
|
||||
throw "Use only one of -FrontendOnly, -BackendOnly, or -McpOnly."
|
||||
if (@($FrontendOnly, $BackendOnly, $AdminOnly, $McpOnly).Where({ $_ }).Count -gt 1) {
|
||||
throw "Use only one of -FrontendOnly, -BackendOnly, -AdminOnly, or -McpOnly."
|
||||
}
|
||||
|
||||
Write-Host "[restart] Stopping target services first..." -ForegroundColor Cyan
|
||||
@@ -30,6 +32,9 @@ if ($FrontendOnly) {
|
||||
elseif ($BackendOnly) {
|
||||
& $stopScript -BackendOnly
|
||||
}
|
||||
elseif ($AdminOnly) {
|
||||
& $stopScript -AdminOnly
|
||||
}
|
||||
elseif ($McpOnly) {
|
||||
& $stopScript -McpOnly
|
||||
}
|
||||
@@ -60,6 +65,16 @@ if ($BackendOnly) {
|
||||
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) {
|
||||
Start-Process powershell -ArgumentList @(
|
||||
"-NoExit",
|
||||
|
||||
33
start-admin.ps1
Normal file
33
start-admin.ps1
Normal 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
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
param(
|
||||
[switch]$FrontendOnly,
|
||||
[switch]$BackendOnly,
|
||||
[switch]$AdminOnly,
|
||||
[switch]$McpOnly,
|
||||
[switch]$WithMcp
|
||||
)
|
||||
@@ -9,8 +10,8 @@ $ErrorActionPreference = "Stop"
|
||||
|
||||
$repoRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
|
||||
if (@($FrontendOnly, $BackendOnly, $McpOnly).Where({ $_ }).Count -gt 1) {
|
||||
throw "Use only one of -FrontendOnly, -BackendOnly, or -McpOnly."
|
||||
if (@($FrontendOnly, $BackendOnly, $AdminOnly, $McpOnly).Where({ $_ }).Count -gt 1) {
|
||||
throw "Use only one of -FrontendOnly, -BackendOnly, -AdminOnly, or -McpOnly."
|
||||
}
|
||||
|
||||
function Stop-RepoShells {
|
||||
@@ -75,6 +76,11 @@ function Stop-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 {
|
||||
Stop-RepoShells -ScriptName "start-mcp.ps1"
|
||||
Stop-PortOwner -Port 5151 -Label "MCP"
|
||||
@@ -90,13 +96,19 @@ if ($BackendOnly) {
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ($AdminOnly) {
|
||||
Stop-Admin
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ($McpOnly) {
|
||||
Stop-Mcp
|
||||
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-Admin
|
||||
Stop-Backend
|
||||
|
||||
if ($WithMcp) {
|
||||
|
||||
Reference in New Issue
Block a user