feat: add shadcn admin workspace
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -5,6 +5,8 @@
|
|||||||
frontend/.astro/
|
frontend/.astro/
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
|
admin/dist/
|
||||||
|
admin/node_modules/
|
||||||
mcp-server/node_modules/
|
mcp-server/node_modules/
|
||||||
|
|
||||||
backend/target/
|
backend/target/
|
||||||
@@ -12,3 +14,5 @@ backend/.loco-start.err.log
|
|||||||
backend/.loco-start.out.log
|
backend/.loco-start.out.log
|
||||||
backend/backend-start.log
|
backend/backend-start.log
|
||||||
backend/storage/ai_embedding_models/
|
backend/storage/ai_embedding_models/
|
||||||
|
backend-start.err
|
||||||
|
backend-start.log
|
||||||
|
|||||||
18
README.md
18
README.md
@@ -6,8 +6,9 @@ Monorepo for the Termi blog system.
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
.
|
.
|
||||||
|
├─ admin/ # React + shadcn admin workspace
|
||||||
├─ frontend/ # Astro blog frontend
|
├─ frontend/ # Astro blog frontend
|
||||||
├─ backend/ # Loco.rs backend and admin
|
├─ backend/ # Loco.rs backend APIs and legacy Tera admin
|
||||||
├─ mcp-server/ # Streamable HTTP MCP server for articles/categories/tags
|
├─ mcp-server/ # Streamable HTTP MCP server for articles/categories/tags
|
||||||
├─ .codex/ # Codex workspace config
|
├─ .codex/ # Codex workspace config
|
||||||
└─ .vscode/ # Editor workspace config
|
└─ .vscode/ # Editor workspace config
|
||||||
@@ -41,6 +42,12 @@ Only backend:
|
|||||||
.\dev.ps1 -BackendOnly
|
.\dev.ps1 -BackendOnly
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Only admin:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\dev.ps1 -AdminOnly
|
||||||
|
```
|
||||||
|
|
||||||
Only MCP:
|
Only MCP:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@@ -52,6 +59,7 @@ Direct scripts:
|
|||||||
```powershell
|
```powershell
|
||||||
.\start-frontend.ps1
|
.\start-frontend.ps1
|
||||||
.\start-backend.ps1
|
.\start-backend.ps1
|
||||||
|
.\start-admin.ps1
|
||||||
.\start-mcp.ps1
|
.\start-mcp.ps1
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -63,6 +71,14 @@ npm install
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Admin
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd admin
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
### Backend
|
### Backend
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
|||||||
24
admin/.gitignore
vendored
Normal file
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 {
|
fn routes(_ctx: &AppContext) -> AppRoutes {
|
||||||
AppRoutes::with_default_routes() // controller routes below
|
AppRoutes::with_default_routes() // controller routes below
|
||||||
.add_route(controllers::admin::routes())
|
.add_route(controllers::admin::routes())
|
||||||
|
.add_route(controllers::admin_api::routes())
|
||||||
.add_route(controllers::review::routes())
|
.add_route(controllers::review::routes())
|
||||||
.add_route(controllers::category::routes())
|
.add_route(controllers::category::routes())
|
||||||
.add_route(controllers::friend_link::routes())
|
.add_route(controllers::friend_link::routes())
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ use crate::services::{ai, content};
|
|||||||
|
|
||||||
static ADMIN_LOGGED_IN: AtomicBool = AtomicBool::new(false);
|
static ADMIN_LOGGED_IN: AtomicBool = AtomicBool::new(false);
|
||||||
const FRONTEND_BASE_URL: &str = "http://localhost:4321";
|
const FRONTEND_BASE_URL: &str = "http://localhost:4321";
|
||||||
|
const DEFAULT_ADMIN_USERNAME: &str = "admin";
|
||||||
|
const DEFAULT_ADMIN_PASSWORD: &str = "admin123";
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct LoginForm {
|
pub struct LoginForm {
|
||||||
@@ -397,15 +399,35 @@ fn render_admin(
|
|||||||
format::view(&view_engine.0, template, Value::Object(context))
|
format::view(&view_engine.0, template, Value::Object(context))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn admin_username() -> String {
|
||||||
|
std::env::var("TERMI_ADMIN_USERNAME").unwrap_or_else(|_| DEFAULT_ADMIN_USERNAME.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn admin_password() -> String {
|
||||||
|
std::env::var("TERMI_ADMIN_PASSWORD").unwrap_or_else(|_| DEFAULT_ADMIN_PASSWORD.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_admin_logged_in() -> bool {
|
||||||
|
ADMIN_LOGGED_IN.load(Ordering::SeqCst)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn set_admin_logged_in(value: bool) {
|
||||||
|
ADMIN_LOGGED_IN.store(value, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn validate_admin_credentials(username: &str, password: &str) -> bool {
|
||||||
|
username == admin_username() && password == admin_password()
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn check_auth() -> Result<()> {
|
pub(crate) fn check_auth() -> Result<()> {
|
||||||
if !ADMIN_LOGGED_IN.load(Ordering::SeqCst) {
|
if !is_admin_logged_in() {
|
||||||
return Err(Error::Unauthorized("Not logged in".to_string()));
|
return Err(Error::Unauthorized("Not logged in".to_string()));
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn root() -> Result<impl IntoResponse> {
|
pub async fn root() -> Result<impl IntoResponse> {
|
||||||
if ADMIN_LOGGED_IN.load(Ordering::SeqCst) {
|
if is_admin_logged_in() {
|
||||||
Ok(format::redirect("/admin"))
|
Ok(format::redirect("/admin"))
|
||||||
} else {
|
} else {
|
||||||
Ok(format::redirect("/admin/login"))
|
Ok(format::redirect("/admin/login"))
|
||||||
@@ -428,15 +450,15 @@ pub async fn login_page(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn login_submit(Form(form): Form<LoginForm>) -> Result<impl IntoResponse> {
|
pub async fn login_submit(Form(form): Form<LoginForm>) -> Result<impl IntoResponse> {
|
||||||
if form.username == "admin" && form.password == "admin123" {
|
if validate_admin_credentials(&form.username, &form.password) {
|
||||||
ADMIN_LOGGED_IN.store(true, Ordering::SeqCst);
|
set_admin_logged_in(true);
|
||||||
return Ok(format::redirect("/admin"));
|
return Ok(format::redirect("/admin"));
|
||||||
}
|
}
|
||||||
Ok(format::redirect("/admin/login?error=1"))
|
Ok(format::redirect("/admin/login?error=1"))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn logout() -> Result<impl IntoResponse> {
|
pub async fn logout() -> Result<impl IntoResponse> {
|
||||||
ADMIN_LOGGED_IN.store(false, Ordering::SeqCst);
|
set_admin_logged_in(false);
|
||||||
Ok(format::redirect("/admin/login"))
|
Ok(format::redirect("/admin/login"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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;
|
||||||
|
pub mod admin_api;
|
||||||
pub mod ai;
|
pub mod ai;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod category;
|
pub mod category;
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ fn normalize_optional_int(value: Option<i32>, min: i32, max: i32) -> Option<i32>
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl SiteSettingsPayload {
|
impl SiteSettingsPayload {
|
||||||
fn apply(self, item: &mut ActiveModel) {
|
pub(crate) fn apply(self, item: &mut ActiveModel) {
|
||||||
if let Some(site_name) = self.site_name {
|
if let Some(site_name) = self.site_name {
|
||||||
item.site_name = Set(normalize_optional_string(Some(site_name)));
|
item.site_name = Set(normalize_optional_string(Some(site_name)));
|
||||||
}
|
}
|
||||||
|
|||||||
21
dev.ps1
21
dev.ps1
@@ -1,6 +1,7 @@
|
|||||||
param(
|
param(
|
||||||
[switch]$FrontendOnly,
|
[switch]$FrontendOnly,
|
||||||
[switch]$BackendOnly,
|
[switch]$BackendOnly,
|
||||||
|
[switch]$AdminOnly,
|
||||||
[switch]$McpOnly,
|
[switch]$McpOnly,
|
||||||
[switch]$WithMcp,
|
[switch]$WithMcp,
|
||||||
[string]$DatabaseUrl = "postgres://postgres:postgres%402025%21@10.0.0.2:5432/termi-api_development"
|
[string]$DatabaseUrl = "postgres://postgres:postgres%402025%21@10.0.0.2:5432/termi-api_development"
|
||||||
@@ -11,10 +12,11 @@ $ErrorActionPreference = "Stop"
|
|||||||
$repoRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
|
$repoRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
$frontendScript = Join-Path $repoRoot "start-frontend.ps1"
|
$frontendScript = Join-Path $repoRoot "start-frontend.ps1"
|
||||||
$backendScript = Join-Path $repoRoot "start-backend.ps1"
|
$backendScript = Join-Path $repoRoot "start-backend.ps1"
|
||||||
|
$adminScript = Join-Path $repoRoot "start-admin.ps1"
|
||||||
$mcpScript = Join-Path $repoRoot "start-mcp.ps1"
|
$mcpScript = Join-Path $repoRoot "start-mcp.ps1"
|
||||||
|
|
||||||
if (@($FrontendOnly, $BackendOnly, $McpOnly).Where({ $_ }).Count -gt 1) {
|
if (@($FrontendOnly, $BackendOnly, $AdminOnly, $McpOnly).Where({ $_ }).Count -gt 1) {
|
||||||
throw "Use only one of -FrontendOnly, -BackendOnly, or -McpOnly."
|
throw "Use only one of -FrontendOnly, -BackendOnly, -AdminOnly, or -McpOnly."
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($FrontendOnly) {
|
if ($FrontendOnly) {
|
||||||
@@ -27,12 +29,17 @@ if ($BackendOnly) {
|
|||||||
exit $LASTEXITCODE
|
exit $LASTEXITCODE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($AdminOnly) {
|
||||||
|
& $adminScript
|
||||||
|
exit $LASTEXITCODE
|
||||||
|
}
|
||||||
|
|
||||||
if ($McpOnly) {
|
if ($McpOnly) {
|
||||||
& $mcpScript
|
& $mcpScript
|
||||||
exit $LASTEXITCODE
|
exit $LASTEXITCODE
|
||||||
}
|
}
|
||||||
|
|
||||||
$services = if ($WithMcp) { "frontend, backend, and MCP" } else { "frontend and backend" }
|
$services = if ($WithMcp) { "frontend, admin, backend, and MCP" } else { "frontend, admin, and backend" }
|
||||||
Write-Host "[monorepo] Starting $services in separate PowerShell windows..." -ForegroundColor Cyan
|
Write-Host "[monorepo] Starting $services in separate PowerShell windows..." -ForegroundColor Cyan
|
||||||
|
|
||||||
Start-Process powershell -ArgumentList @(
|
Start-Process powershell -ArgumentList @(
|
||||||
@@ -48,6 +55,12 @@ Start-Process powershell -ArgumentList @(
|
|||||||
"-DatabaseUrl", $DatabaseUrl
|
"-DatabaseUrl", $DatabaseUrl
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Start-Process powershell -ArgumentList @(
|
||||||
|
"-NoExit",
|
||||||
|
"-ExecutionPolicy", "Bypass",
|
||||||
|
"-File", $adminScript
|
||||||
|
)
|
||||||
|
|
||||||
if ($WithMcp) {
|
if ($WithMcp) {
|
||||||
Start-Process powershell -ArgumentList @(
|
Start-Process powershell -ArgumentList @(
|
||||||
"-NoExit",
|
"-NoExit",
|
||||||
@@ -56,5 +69,5 @@ if ($WithMcp) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
$servicesStarted = if ($WithMcp) { "Frontend, backend, and MCP windows started." } else { "Frontend window and backend window started." }
|
$servicesStarted = if ($WithMcp) { "Frontend, admin, backend, and MCP windows started." } else { "Frontend, admin, and backend windows started." }
|
||||||
Write-Host "[monorepo] $servicesStarted" -ForegroundColor Green
|
Write-Host "[monorepo] $servicesStarted" -ForegroundColor Green
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
param(
|
param(
|
||||||
[switch]$FrontendOnly,
|
[switch]$FrontendOnly,
|
||||||
[switch]$BackendOnly,
|
[switch]$BackendOnly,
|
||||||
|
[switch]$AdminOnly,
|
||||||
[switch]$McpOnly,
|
[switch]$McpOnly,
|
||||||
[switch]$WithMcp,
|
[switch]$WithMcp,
|
||||||
[string]$DatabaseUrl = "postgres://postgres:postgres%402025%21@10.0.0.2:5432/termi-api_development",
|
[string]$DatabaseUrl = "postgres://postgres:postgres%402025%21@10.0.0.2:5432/termi-api_development",
|
||||||
@@ -16,10 +17,11 @@ $stopScript = Join-Path $repoRoot "stop-services.ps1"
|
|||||||
$devScript = Join-Path $repoRoot "dev.ps1"
|
$devScript = Join-Path $repoRoot "dev.ps1"
|
||||||
$frontendScript = Join-Path $repoRoot "start-frontend.ps1"
|
$frontendScript = Join-Path $repoRoot "start-frontend.ps1"
|
||||||
$backendScript = Join-Path $repoRoot "start-backend.ps1"
|
$backendScript = Join-Path $repoRoot "start-backend.ps1"
|
||||||
|
$adminScript = Join-Path $repoRoot "start-admin.ps1"
|
||||||
$mcpScript = Join-Path $repoRoot "start-mcp.ps1"
|
$mcpScript = Join-Path $repoRoot "start-mcp.ps1"
|
||||||
|
|
||||||
if (@($FrontendOnly, $BackendOnly, $McpOnly).Where({ $_ }).Count -gt 1) {
|
if (@($FrontendOnly, $BackendOnly, $AdminOnly, $McpOnly).Where({ $_ }).Count -gt 1) {
|
||||||
throw "Use only one of -FrontendOnly, -BackendOnly, or -McpOnly."
|
throw "Use only one of -FrontendOnly, -BackendOnly, -AdminOnly, or -McpOnly."
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "[restart] Stopping target services first..." -ForegroundColor Cyan
|
Write-Host "[restart] Stopping target services first..." -ForegroundColor Cyan
|
||||||
@@ -30,6 +32,9 @@ if ($FrontendOnly) {
|
|||||||
elseif ($BackendOnly) {
|
elseif ($BackendOnly) {
|
||||||
& $stopScript -BackendOnly
|
& $stopScript -BackendOnly
|
||||||
}
|
}
|
||||||
|
elseif ($AdminOnly) {
|
||||||
|
& $stopScript -AdminOnly
|
||||||
|
}
|
||||||
elseif ($McpOnly) {
|
elseif ($McpOnly) {
|
||||||
& $stopScript -McpOnly
|
& $stopScript -McpOnly
|
||||||
}
|
}
|
||||||
@@ -60,6 +65,16 @@ if ($BackendOnly) {
|
|||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($AdminOnly) {
|
||||||
|
Start-Process powershell -ArgumentList @(
|
||||||
|
"-NoExit",
|
||||||
|
"-ExecutionPolicy", "Bypass",
|
||||||
|
"-File", $adminScript
|
||||||
|
)
|
||||||
|
Write-Host "[restart] Admin window restarted." -ForegroundColor Green
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
if ($McpOnly) {
|
if ($McpOnly) {
|
||||||
Start-Process powershell -ArgumentList @(
|
Start-Process powershell -ArgumentList @(
|
||||||
"-NoExit",
|
"-NoExit",
|
||||||
|
|||||||
33
start-admin.ps1
Normal file
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(
|
param(
|
||||||
[switch]$FrontendOnly,
|
[switch]$FrontendOnly,
|
||||||
[switch]$BackendOnly,
|
[switch]$BackendOnly,
|
||||||
|
[switch]$AdminOnly,
|
||||||
[switch]$McpOnly,
|
[switch]$McpOnly,
|
||||||
[switch]$WithMcp
|
[switch]$WithMcp
|
||||||
)
|
)
|
||||||
@@ -9,8 +10,8 @@ $ErrorActionPreference = "Stop"
|
|||||||
|
|
||||||
$repoRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
|
$repoRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
|
||||||
if (@($FrontendOnly, $BackendOnly, $McpOnly).Where({ $_ }).Count -gt 1) {
|
if (@($FrontendOnly, $BackendOnly, $AdminOnly, $McpOnly).Where({ $_ }).Count -gt 1) {
|
||||||
throw "Use only one of -FrontendOnly, -BackendOnly, or -McpOnly."
|
throw "Use only one of -FrontendOnly, -BackendOnly, -AdminOnly, or -McpOnly."
|
||||||
}
|
}
|
||||||
|
|
||||||
function Stop-RepoShells {
|
function Stop-RepoShells {
|
||||||
@@ -75,6 +76,11 @@ function Stop-Backend {
|
|||||||
Stop-PortOwner -Port 5150 -Label "backend"
|
Stop-PortOwner -Port 5150 -Label "backend"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Stop-Admin {
|
||||||
|
Stop-RepoShells -ScriptName "start-admin.ps1"
|
||||||
|
Stop-PortOwner -Port 4322 -Label "admin"
|
||||||
|
}
|
||||||
|
|
||||||
function Stop-Mcp {
|
function Stop-Mcp {
|
||||||
Stop-RepoShells -ScriptName "start-mcp.ps1"
|
Stop-RepoShells -ScriptName "start-mcp.ps1"
|
||||||
Stop-PortOwner -Port 5151 -Label "MCP"
|
Stop-PortOwner -Port 5151 -Label "MCP"
|
||||||
@@ -90,13 +96,19 @@ if ($BackendOnly) {
|
|||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($AdminOnly) {
|
||||||
|
Stop-Admin
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
if ($McpOnly) {
|
if ($McpOnly) {
|
||||||
Stop-Mcp
|
Stop-Mcp
|
||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "[stop] Stopping frontend and backend services..." -ForegroundColor Cyan
|
Write-Host "[stop] Stopping frontend, admin, and backend services..." -ForegroundColor Cyan
|
||||||
Stop-Frontend
|
Stop-Frontend
|
||||||
|
Stop-Admin
|
||||||
Stop-Backend
|
Stop-Backend
|
||||||
|
|
||||||
if ($WithMcp) {
|
if ($WithMcp) {
|
||||||
|
|||||||
Reference in New Issue
Block a user