feat: migrate admin content and moderation modules

This commit is contained in:
2026-03-28 18:24:55 +08:00
parent 178434d63e
commit 84f82c2a7e
13 changed files with 2385 additions and 24 deletions

View File

@@ -21,8 +21,12 @@ import { Toaster, toast } from 'sonner'
import { AppShell } from '@/components/app-shell'
import { adminApi, ApiError } from '@/lib/api'
import type { AdminSessionResponse } from '@/lib/types'
import { CommentsPage } from '@/pages/comments-page'
import { DashboardPage } from '@/pages/dashboard-page'
import { FriendLinksPage } from '@/pages/friend-links-page'
import { LoginPage } from '@/pages/login-page'
import { PostsPage } from '@/pages/posts-page'
import { ReviewsPage } from '@/pages/reviews-page'
import { SiteSettingsPage } from '@/pages/site-settings-page'
type SessionContextValue = {
@@ -146,6 +150,11 @@ function AppRoutes() {
<Route element={<SessionGuard />}>
<Route element={<ProtectedLayout />}>
<Route index element={<DashboardPage />} />
<Route path="/posts" element={<PostsPage />} />
<Route path="/posts/:slug" element={<PostsPage />} />
<Route path="/comments" element={<CommentsPage />} />
<Route path="/friend-links" element={<FriendLinksPage />} />
<Route path="/reviews" element={<ReviewsPage />} />
<Route path="/settings" element={<SiteSettingsPage />} />
</Route>
</Route>

View File

@@ -1,4 +1,15 @@
import { ExternalLink, LayoutDashboard, LogOut, Orbit, Settings, Sparkles } from 'lucide-react'
import {
BookOpenText,
ExternalLink,
LayoutDashboard,
Link2,
LogOut,
MessageSquareText,
Orbit,
ScrollText,
Settings,
Sparkles,
} from 'lucide-react'
import type { ReactNode } from 'react'
import { NavLink } from 'react-router-dom'
@@ -14,6 +25,30 @@ const primaryNav = [
description: 'Live operational dashboard',
icon: LayoutDashboard,
},
{
to: '/posts',
label: 'Posts',
description: 'Markdown content workspace',
icon: ScrollText,
},
{
to: '/comments',
label: 'Comments',
description: 'Moderation and paragraph replies',
icon: MessageSquareText,
},
{
to: '/friend-links',
label: 'Friend links',
description: 'Partner queue and reciprocity',
icon: Link2,
},
{
to: '/reviews',
label: 'Reviews',
description: 'Curated review library',
icon: BookOpenText,
},
{
to: '/settings',
label: 'Site settings',
@@ -22,8 +57,6 @@ const primaryNav = [
},
]
const nextNav = ['Posts editor', 'Comments moderation', 'Friend links queue', 'Review library']
export function AppShell({
children,
username,
@@ -51,8 +84,8 @@ export function AppShell({
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.
A dedicated React workspace for publishing, moderation, operations, and
AI-related site controls.
</p>
</div>
</div>
@@ -104,28 +137,25 @@ export function AppShell({
<Separator />
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="rounded-[1.7rem] border border-border/70 bg-background/65 p-5">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-[0.24em] text-muted-foreground">
Migration queue
Workspace status
</p>
<p className="mt-1 text-sm text-muted-foreground">
Legacy Tera screens that move here next.
Core admin flows are now available in the standalone app.
</p>
</div>
<Badge variant="outline">phase 1</Badge>
<Badge variant="success">live</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 className="mt-4 grid gap-2">
<div className="rounded-2xl border border-border/60 bg-card/70 px-4 py-3 text-sm text-muted-foreground">
Public site and admin stay decoupled.
</div>
<div className="rounded-2xl border border-border/60 bg-card/70 px-4 py-3 text-sm text-muted-foreground">
Backend remains the shared auth and data layer.
</div>
</div>
</div>
</div>
@@ -148,6 +178,26 @@ export function AppShell({
</div>
</div>
<div className="flex gap-2 overflow-x-auto lg:hidden">
{primaryNav.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
className={({ isActive }) =>
cn(
'rounded-full border px-3 py-2 text-sm whitespace-nowrap transition-colors',
isActive
? 'border-primary/30 bg-primary/10 text-primary'
: 'border-border/70 bg-background/60 text-muted-foreground',
)
}
>
{item.label}
</NavLink>
))}
</div>
<div className="flex flex-wrap items-center gap-3">
<Button variant="outline" asChild>
<a href="http://localhost:4321" target="_blank" rel="noreferrer">

View File

@@ -0,0 +1,21 @@
import type { ReactNode } from 'react'
import { Label } from '@/components/ui/label'
export function FormField({
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>
)
}

View File

@@ -0,0 +1,19 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Select = React.forwardRef<HTMLSelectElement, React.ComponentProps<'select'>>(
({ className, ...props }, ref) => (
<select
ref={ref}
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 focus-visible:ring-2 focus-visible:ring-ring/70 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
),
)
Select.displayName = 'Select'
export { Select }

View File

@@ -0,0 +1,64 @@
export function formatDateTime(value: string | null | undefined) {
if (!value) {
return 'Not available'
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return value
}
return new Intl.DateTimeFormat('en-US', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(date)
}
export function emptyToNull(value: string) {
const trimmed = value.trim()
return trimmed ? trimmed : null
}
export function linesToList(value: string) {
return value
.split('\n')
.map((item) => item.trim())
.filter(Boolean)
}
export function csvToList(value: string) {
return value
.split(',')
.map((item) => item.trim())
.filter(Boolean)
}
export function postTagsToList(value: unknown) {
if (!Array.isArray(value)) {
return []
}
return value
.map((item) => (typeof item === 'string' ? item.trim() : ''))
.filter(Boolean)
}
export function reviewTagsToList(value: string | null | undefined) {
if (!value) {
return []
}
try {
const parsed = JSON.parse(value) as unknown
if (Array.isArray(parsed)) {
return parsed
.map((item) => (typeof item === 'string' ? item.trim() : ''))
.filter(Boolean)
}
} catch {
return csvToList(value)
}
return []
}

View File

@@ -3,7 +3,22 @@ import type {
AdminDashboardResponse,
AdminSessionResponse,
AdminSiteSettingsResponse,
CommentListQuery,
CommentRecord,
CreatePostPayload,
CreateReviewPayload,
FriendLinkListQuery,
FriendLinkPayload,
FriendLinkRecord,
MarkdownDeleteResponse,
MarkdownDocumentResponse,
PostListQuery,
PostRecord,
ReviewRecord,
SiteSettingsPayload,
UpdateCommentPayload,
UpdatePostPayload,
UpdateReviewPayload,
} from '@/lib/types'
const API_BASE = import.meta.env.VITE_API_BASE?.trim() || ''
@@ -33,6 +48,30 @@ async function readErrorMessage(response: Response) {
}
}
function appendQueryParams(path: string, params?: Record<string, unknown>) {
if (!params) {
return path
}
const searchParams = new URLSearchParams()
Object.entries(params).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') {
return
}
if (typeof value === 'boolean') {
searchParams.set(key, String(value))
return
}
searchParams.set(key, String(value))
})
const queryString = searchParams.toString()
return queryString ? `${path}?${queryString}` : path
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const headers = new Headers(init?.headers)
@@ -84,4 +123,127 @@ export const adminApi = {
request<AdminAiReindexResponse>('/api/admin/ai/reindex', {
method: 'POST',
}),
listPosts: (query?: PostListQuery) =>
request<PostRecord[]>(
appendQueryParams('/api/posts', {
slug: query?.slug,
category: query?.category,
tag: query?.tag,
search: query?.search,
type: query?.postType,
pinned: query?.pinned,
}),
),
getPostBySlug: (slug: string) => request<PostRecord>(`/api/posts/slug/${encodeURIComponent(slug)}`),
createPost: (payload: CreatePostPayload) =>
request<MarkdownDocumentResponse>('/api/posts/markdown', {
method: 'POST',
body: JSON.stringify({
title: payload.title,
slug: payload.slug,
description: payload.description,
content: payload.content,
category: payload.category,
tags: payload.tags,
post_type: payload.postType,
image: payload.image,
pinned: payload.pinned,
published: payload.published,
}),
}),
updatePost: (id: number, payload: UpdatePostPayload) =>
request<PostRecord>(`/api/posts/${id}`, {
method: 'PATCH',
body: JSON.stringify({
title: payload.title,
slug: payload.slug,
description: payload.description,
content: payload.content,
category: payload.category,
tags: payload.tags,
post_type: payload.postType,
image: payload.image,
pinned: payload.pinned,
}),
}),
getPostMarkdown: (slug: string) =>
request<MarkdownDocumentResponse>(`/api/posts/slug/${encodeURIComponent(slug)}/markdown`),
updatePostMarkdown: (slug: string, markdown: string) =>
request<MarkdownDocumentResponse>(`/api/posts/slug/${encodeURIComponent(slug)}/markdown`, {
method: 'PATCH',
body: JSON.stringify({ markdown }),
}),
deletePost: (slug: string) =>
request<MarkdownDeleteResponse>(`/api/posts/slug/${encodeURIComponent(slug)}/markdown`, {
method: 'DELETE',
}),
listComments: (query?: CommentListQuery) =>
request<CommentRecord[]>(
appendQueryParams('/api/comments', {
post_id: query?.postId,
post_slug: query?.postSlug,
scope: query?.scope,
paragraph_key: query?.paragraphKey,
approved: query?.approved,
}),
),
updateComment: (id: number, payload: UpdateCommentPayload) =>
request<CommentRecord>(`/api/comments/${id}`, {
method: 'PATCH',
body: JSON.stringify(payload),
}),
deleteComment: (id: number) =>
request<void>(`/api/comments/${id}`, {
method: 'DELETE',
}),
listFriendLinks: (query?: FriendLinkListQuery) =>
request<FriendLinkRecord[]>(
appendQueryParams('/api/friend_links', {
status: query?.status,
category: query?.category,
}),
),
createFriendLink: (payload: FriendLinkPayload) =>
request<FriendLinkRecord>('/api/friend_links', {
method: 'POST',
body: JSON.stringify({
siteName: payload.siteName,
siteUrl: payload.siteUrl,
avatarUrl: payload.avatarUrl,
description: payload.description,
category: payload.category,
status: payload.status,
}),
}),
updateFriendLink: (id: number, payload: FriendLinkPayload) =>
request<FriendLinkRecord>(`/api/friend_links/${id}`, {
method: 'PATCH',
body: JSON.stringify({
site_name: payload.siteName,
site_url: payload.siteUrl,
avatar_url: payload.avatarUrl,
description: payload.description,
category: payload.category,
status: payload.status,
}),
}),
deleteFriendLink: (id: number) =>
request<void>(`/api/friend_links/${id}`, {
method: 'DELETE',
}),
listReviews: () => request<ReviewRecord[]>('/api/reviews'),
createReview: (payload: CreateReviewPayload) =>
request<ReviewRecord>('/api/reviews', {
method: 'POST',
body: JSON.stringify(payload),
}),
updateReview: (id: number, payload: UpdateReviewPayload) =>
request<ReviewRecord>(`/api/reviews/${id}`, {
method: 'PUT',
body: JSON.stringify(payload),
}),
deleteReview: (id: number) =>
request<void>(`/api/reviews/${id}`, {
method: 'DELETE',
}),
}

View File

@@ -135,3 +135,166 @@ export interface AdminAiReindexResponse {
indexed_chunks: number
last_indexed_at: string | null
}
export interface PostRecord {
created_at: string
updated_at: string
id: number
title: string | null
slug: string
description: string | null
content: string | null
category: string | null
tags: unknown
post_type: string | null
image: string | null
pinned: boolean | null
}
export interface PostListQuery {
slug?: string
category?: string
tag?: string
search?: string
postType?: string
pinned?: boolean
}
export interface CreatePostPayload {
title: string
slug?: string | null
description?: string | null
content?: string | null
category?: string | null
tags?: string[]
postType?: string | null
image?: string | null
pinned?: boolean
published?: boolean
}
export interface UpdatePostPayload {
title?: string | null
slug: string
description?: string | null
content?: string | null
category?: string | null
tags?: unknown
postType?: string | null
image?: string | null
pinned?: boolean | null
}
export interface MarkdownDocumentResponse {
slug: string
path: string
markdown: string
}
export interface MarkdownDeleteResponse {
slug: string
deleted: boolean
}
export interface CommentRecord {
created_at: string
updated_at: string
id: number
post_id: string | null
post_slug: string | null
author: string | null
email: string | null
avatar: string | null
content: string | null
scope: string
paragraph_key: string | null
paragraph_excerpt: string | null
reply_to: string | null
reply_to_comment_id: number | null
approved: boolean | null
}
export interface CommentListQuery {
postId?: string
postSlug?: string
scope?: string
paragraphKey?: string
approved?: boolean
}
export interface UpdateCommentPayload {
post_id?: string | null
post_slug?: string | null
author?: string | null
email?: string | null
avatar?: string | null
content?: string | null
reply_to?: string | null
reply_to_comment_id?: number | null
scope?: string | null
paragraph_key?: string | null
paragraph_excerpt?: string | null
approved?: boolean
}
export interface FriendLinkRecord {
created_at: string
updated_at: string
id: number
site_name: string | null
site_url: string
avatar_url: string | null
description: string | null
category: string | null
status: string | null
}
export interface FriendLinkListQuery {
status?: string
category?: string
}
export interface FriendLinkPayload {
siteName?: string | null
siteUrl: string
avatarUrl?: string | null
description?: string | null
category?: string | null
status?: string | null
}
export interface ReviewRecord {
id: number
title: string | null
review_type: string | null
rating: number | null
review_date: string | null
status: string | null
description: string | null
tags: string | null
cover: string | null
created_at: string
updated_at: string
}
export interface CreateReviewPayload {
title: string
review_type: string
rating: number
review_date: string
status: string
description: string
tags: string[]
cover: string
}
export interface UpdateReviewPayload {
title?: string
review_type?: string
rating?: number
review_date?: string
status?: string
description?: string
tags?: string[]
cover?: string
}

View File

@@ -0,0 +1,331 @@
import { CheckCheck, MessageSquareText, RefreshCcw, Trash2, XCircle } from 'lucide-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 { Select } from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { adminApi, ApiError } from '@/lib/api'
import { formatDateTime } from '@/lib/admin-format'
import type { CommentRecord } from '@/lib/types'
function moderationBadgeVariant(approved: boolean | null) {
if (approved) {
return 'success' as const
}
return 'warning' as const
}
export function CommentsPage() {
const [comments, setComments] = useState<CommentRecord[]>([])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [actingId, setActingId] = useState<number | null>(null)
const [searchTerm, setSearchTerm] = useState('')
const [approvalFilter, setApprovalFilter] = useState('pending')
const [scopeFilter, setScopeFilter] = useState('all')
const loadComments = useCallback(async (showToast = false) => {
try {
if (showToast) {
setRefreshing(true)
}
const next = await adminApi.listComments()
startTransition(() => {
setComments(next)
})
if (showToast) {
toast.success('Comments refreshed.')
}
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
return
}
toast.error(error instanceof ApiError ? error.message : 'Unable to load comments.')
} finally {
setLoading(false)
setRefreshing(false)
}
}, [])
useEffect(() => {
void loadComments(false)
}, [loadComments])
const filteredComments = useMemo(() => {
return comments.filter((comment) => {
const matchesSearch =
!searchTerm ||
[
comment.author ?? '',
comment.post_slug ?? '',
comment.content ?? '',
comment.paragraph_excerpt ?? '',
comment.paragraph_key ?? '',
]
.join('\n')
.toLowerCase()
.includes(searchTerm.toLowerCase())
const matchesApproval =
approvalFilter === 'all' ||
(approvalFilter === 'approved' && Boolean(comment.approved)) ||
(approvalFilter === 'pending' && !comment.approved)
const matchesScope = scopeFilter === 'all' || comment.scope === scopeFilter
return matchesSearch && matchesApproval && matchesScope
})
}, [approvalFilter, comments, scopeFilter, searchTerm])
const pendingCount = useMemo(
() => comments.filter((comment) => !comment.approved).length,
[comments],
)
const paragraphCount = useMemo(
() => comments.filter((comment) => comment.scope === 'paragraph').length,
[comments],
)
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">Comments</Badge>
<div>
<h2 className="text-3xl font-semibold tracking-tight">Moderation queue</h2>
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
Review article comments and paragraph-specific responses from one place, with fast
approval controls for the public discussion layer.
</p>
</div>
</div>
<Button variant="secondary" onClick={() => void loadComments(true)} disabled={refreshing}>
<RefreshCcw className="h-4 w-4" />
{refreshing ? 'Refreshing...' : 'Refresh'}
</Button>
</div>
<div className="grid gap-4 md:grid-cols-3">
<Card className="bg-gradient-to-br from-card via-card to-background/70">
<CardContent className="pt-6">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">Pending</p>
<div className="mt-3 text-3xl font-semibold">{pendingCount}</div>
<p className="mt-2 text-sm text-muted-foreground">Needs moderation attention.</p>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-card via-card to-background/70">
<CardContent className="pt-6">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
Paragraph replies
</p>
<div className="mt-3 text-3xl font-semibold">{paragraphCount}</div>
<p className="mt-2 text-sm text-muted-foreground">Scoped to paragraph anchors.</p>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-card via-card to-background/70">
<CardContent className="pt-6">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">Total</p>
<div className="mt-3 text-3xl font-semibold">{comments.length}</div>
<p className="mt-2 text-sm text-muted-foreground">Everything currently stored.</p>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Comment list</CardTitle>
<CardDescription>
Filter the queue, then approve, hide, or remove entries without leaving the page.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 lg:grid-cols-[1.2fr_0.6fr_0.6fr]">
<Input
placeholder="Search by author, post slug, content, or paragraph key"
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
/>
<Select
value={approvalFilter}
onChange={(event) => setApprovalFilter(event.target.value)}
>
<option value="all">All approval states</option>
<option value="pending">Pending only</option>
<option value="approved">Approved only</option>
</Select>
<Select value={scopeFilter} onChange={(event) => setScopeFilter(event.target.value)}>
<option value="all">All scopes</option>
<option value="article">Article</option>
<option value="paragraph">Paragraph</option>
</Select>
</div>
{loading ? (
<Skeleton className="h-[680px] rounded-3xl" />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Comment</TableHead>
<TableHead>Status</TableHead>
<TableHead>Context</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredComments.map((comment) => (
<TableRow key={comment.id}>
<TableCell>
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{comment.author ?? 'Anonymous'}</span>
<Badge variant="outline">{comment.scope}</Badge>
<span className="text-xs text-muted-foreground">
{formatDateTime(comment.created_at)}
</span>
</div>
<p className="text-sm leading-6 text-muted-foreground">
{comment.content ?? 'No content provided.'}
</p>
{comment.scope === 'paragraph' ? (
<div className="rounded-2xl border border-border/70 bg-background/60 px-3 py-2 text-xs text-muted-foreground">
<p className="font-mono">{comment.paragraph_key ?? 'missing-key'}</p>
<p className="mt-1">
{comment.paragraph_excerpt ?? 'No paragraph excerpt stored.'}
</p>
</div>
) : null}
</div>
</TableCell>
<TableCell>
<Badge variant={moderationBadgeVariant(comment.approved)}>
{comment.approved ? 'Approved' : 'Pending'}
</Badge>
</TableCell>
<TableCell>
<div className="space-y-1 text-sm text-muted-foreground">
<p className="font-mono text-xs">{comment.post_slug ?? 'unknown-post'}</p>
{comment.reply_to_comment_id ? (
<p>Replying to #{comment.reply_to_comment_id}</p>
) : (
<p>Top-level comment</p>
)}
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
disabled={actingId === comment.id || Boolean(comment.approved)}
onClick={async () => {
try {
setActingId(comment.id)
await adminApi.updateComment(comment.id, { approved: true })
toast.success('Comment approved.')
await loadComments(false)
} catch (error) {
toast.error(
error instanceof ApiError
? error.message
: 'Unable to approve comment.',
)
} finally {
setActingId(null)
}
}}
>
<CheckCheck className="h-4 w-4" />
Approve
</Button>
<Button
size="sm"
variant="ghost"
disabled={actingId === comment.id || !comment.approved}
onClick={async () => {
try {
setActingId(comment.id)
await adminApi.updateComment(comment.id, { approved: false })
toast.success('Comment moved back to pending.')
await loadComments(false)
} catch (error) {
toast.error(
error instanceof ApiError
? error.message
: 'Unable to update comment.',
)
} finally {
setActingId(null)
}
}}
>
<XCircle className="h-4 w-4" />
Hide
</Button>
<Button
size="sm"
variant="danger"
disabled={actingId === comment.id}
onClick={async () => {
if (!window.confirm('Delete this comment permanently?')) {
return
}
try {
setActingId(comment.id)
await adminApi.deleteComment(comment.id)
toast.success('Comment deleted.')
await loadComments(false)
} catch (error) {
toast.error(
error instanceof ApiError
? error.message
: 'Unable to delete comment.',
)
} finally {
setActingId(null)
}
}}
>
<Trash2 className="h-4 w-4" />
Delete
</Button>
</div>
</TableCell>
</TableRow>
))}
{!filteredComments.length ? (
<TableRow>
<TableCell colSpan={4} className="py-12 text-center">
<div className="flex flex-col items-center gap-3 text-muted-foreground">
<MessageSquareText className="h-8 w-8" />
<p>No comments match the current moderation filters.</p>
</div>
</TableCell>
</TableRow>
) : null}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -135,8 +135,8 @@ export function DashboardPage() {
<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.
This screen brings the most important publishing, moderation, and AI signals into the
new standalone admin so the day-to-day control loop stays in one place.
</p>
</div>
</div>

View File

@@ -0,0 +1,411 @@
import { ExternalLink, Link2, RefreshCcw, Save, Trash2 } from 'lucide-react'
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { FormField } from '@/components/form-field'
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 { Select } from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import { adminApi, ApiError } from '@/lib/api'
import { emptyToNull, formatDateTime } from '@/lib/admin-format'
import type { FriendLinkPayload, FriendLinkRecord } from '@/lib/types'
type FriendLinkFormState = {
siteName: string
siteUrl: string
avatarUrl: string
description: string
category: string
status: string
}
const defaultFriendLinkForm: FriendLinkFormState = {
siteName: '',
siteUrl: '',
avatarUrl: '',
description: '',
category: '',
status: 'pending',
}
function toFormState(link: FriendLinkRecord): FriendLinkFormState {
return {
siteName: link.site_name ?? '',
siteUrl: link.site_url,
avatarUrl: link.avatar_url ?? '',
description: link.description ?? '',
category: link.category ?? '',
status: link.status ?? 'pending',
}
}
function toPayload(form: FriendLinkFormState): FriendLinkPayload {
return {
siteName: emptyToNull(form.siteName),
siteUrl: form.siteUrl.trim(),
avatarUrl: emptyToNull(form.avatarUrl),
description: emptyToNull(form.description),
category: emptyToNull(form.category),
status: emptyToNull(form.status) ?? 'pending',
}
}
function statusBadgeVariant(status: string | null) {
switch (status) {
case 'approved':
return 'success' as const
case 'rejected':
return 'danger' as const
default:
return 'warning' as const
}
}
export function FriendLinksPage() {
const [links, setLinks] = useState<FriendLinkRecord[]>([])
const [selectedId, setSelectedId] = useState<number | null>(null)
const [form, setForm] = useState<FriendLinkFormState>(defaultFriendLinkForm)
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [saving, setSaving] = useState(false)
const [deleting, setDeleting] = useState(false)
const [searchTerm, setSearchTerm] = useState('')
const [statusFilter, setStatusFilter] = useState('all')
const loadLinks = useCallback(async (showToast = false) => {
try {
if (showToast) {
setRefreshing(true)
}
const next = await adminApi.listFriendLinks()
startTransition(() => {
setLinks(next)
})
if (showToast) {
toast.success('Friend links refreshed.')
}
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
return
}
toast.error(error instanceof ApiError ? error.message : 'Unable to load friend links.')
} finally {
setLoading(false)
setRefreshing(false)
}
}, [])
useEffect(() => {
void loadLinks(false)
}, [loadLinks])
const filteredLinks = useMemo(() => {
return links.filter((link) => {
const matchesSearch =
!searchTerm ||
[
link.site_name ?? '',
link.site_url,
link.category ?? '',
link.description ?? '',
link.status ?? '',
]
.join('\n')
.toLowerCase()
.includes(searchTerm.toLowerCase())
const matchesStatus = statusFilter === 'all' || (link.status ?? 'pending') === statusFilter
return matchesSearch && matchesStatus
})
}, [links, searchTerm, statusFilter])
const selectedLink = useMemo(
() => links.find((link) => link.id === selectedId) ?? null,
[links, selectedId],
)
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">Friend links</Badge>
<div>
<h2 className="text-3xl font-semibold tracking-tight">Partner site queue</h2>
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
Review inbound link exchanges, keep metadata accurate, and move requests through
pending, approved, or rejected states in one dedicated workspace.
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button
variant="outline"
onClick={() => {
setSelectedId(null)
setForm(defaultFriendLinkForm)
}}
>
New link
</Button>
<Button variant="secondary" onClick={() => void loadLinks(true)} disabled={refreshing}>
<RefreshCcw className="h-4 w-4" />
{refreshing ? 'Refreshing...' : 'Refresh'}
</Button>
</div>
</div>
<div className="grid gap-6 xl:grid-cols-[0.92fr_1.08fr]">
<Card>
<CardHeader>
<CardTitle>Link inventory</CardTitle>
<CardDescription>
Pick an item to edit it, or start a new record from the right-hand form.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 lg:grid-cols-[1.2fr_0.6fr]">
<Input
placeholder="Search by site name, URL, category, or notes"
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
/>
<Select
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value)}
>
<option value="all">All statuses</option>
<option value="pending">Pending</option>
<option value="approved">Approved</option>
<option value="rejected">Rejected</option>
</Select>
</div>
{loading ? (
<Skeleton className="h-[620px] rounded-3xl" />
) : (
<div className="space-y-3">
{filteredLinks.map((link) => (
<button
key={link.id}
type="button"
onClick={() => {
setSelectedId(link.id)
setForm(toFormState(link))
}}
className={`w-full rounded-3xl border px-4 py-4 text-left transition ${
selectedId === link.id
? 'border-primary/30 bg-primary/10 shadow-[0_12px_30px_rgba(37,99,235,0.12)]'
: 'border-border/70 bg-background/60 hover:border-border'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{link.site_name ?? 'Untitled partner'}</span>
<Badge variant={statusBadgeVariant(link.status)}>
{link.status ?? 'pending'}
</Badge>
</div>
<p className="truncate text-sm text-muted-foreground">{link.site_url}</p>
<p className="line-clamp-2 text-sm text-muted-foreground">
{link.description ?? 'No description yet.'}
</p>
</div>
<div className="text-right text-xs text-muted-foreground">
<p>{link.category ?? 'uncategorized'}</p>
<p className="mt-1">{formatDateTime(link.created_at)}</p>
</div>
</div>
</button>
))}
{!filteredLinks.length ? (
<div className="flex flex-col items-center gap-3 rounded-3xl border border-dashed border-border/70 px-6 py-14 text-center text-muted-foreground">
<Link2 className="h-8 w-8" />
<p>No friend links match the current filters.</p>
</div>
) : null}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div>
<CardTitle>{selectedLink ? 'Edit friend link' : 'Create friend link'}</CardTitle>
<CardDescription>
Capture the reciprocal URL, classification, and moderation status the public link
page depends on.
</CardDescription>
</div>
<div className="flex flex-wrap items-center gap-3">
{selectedLink ? (
<Button variant="outline" asChild>
<a href={selectedLink.site_url} target="_blank" rel="noreferrer">
<ExternalLink className="h-4 w-4" />
Visit site
</a>
</Button>
) : null}
<Button
onClick={async () => {
if (!form.siteUrl.trim()) {
toast.error('Site URL is required.')
return
}
try {
setSaving(true)
const payload = toPayload(form)
if (selectedLink) {
const updated = await adminApi.updateFriendLink(selectedLink.id, payload)
startTransition(() => {
setSelectedId(updated.id)
setForm(toFormState(updated))
})
toast.success('Friend link updated.')
} else {
const created = await adminApi.createFriendLink(payload)
startTransition(() => {
setSelectedId(created.id)
setForm(toFormState(created))
})
toast.success('Friend link created.')
}
await loadLinks(false)
} catch (error) {
toast.error(
error instanceof ApiError ? error.message : 'Unable to save friend link.',
)
} finally {
setSaving(false)
}
}}
disabled={saving}
>
<Save className="h-4 w-4" />
{saving ? 'Saving...' : selectedLink ? 'Save changes' : 'Create link'}
</Button>
{selectedLink ? (
<Button
variant="danger"
disabled={deleting}
onClick={async () => {
if (!window.confirm('Delete this friend link?')) {
return
}
try {
setDeleting(true)
await adminApi.deleteFriendLink(selectedLink.id)
toast.success('Friend link deleted.')
setSelectedId(null)
setForm(defaultFriendLinkForm)
await loadLinks(false)
} catch (error) {
toast.error(
error instanceof ApiError ? error.message : 'Unable to delete friend link.',
)
} finally {
setDeleting(false)
}
}}
>
<Trash2 className="h-4 w-4" />
{deleting ? 'Deleting...' : 'Delete'}
</Button>
) : null}
</div>
</CardHeader>
<CardContent className="space-y-5">
{selectedLink ? (
<div className="rounded-3xl border border-border/70 bg-background/60 p-5">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
Selected record
</p>
<p className="mt-2 text-sm text-muted-foreground">
Created {formatDateTime(selectedLink.created_at)}
</p>
</div>
<Badge variant={statusBadgeVariant(selectedLink.status)}>
{selectedLink.status ?? 'pending'}
</Badge>
</div>
</div>
) : null}
<div className="grid gap-5 lg:grid-cols-2">
<FormField label="Site name">
<Input
value={form.siteName}
onChange={(event) =>
setForm((current) => ({ ...current, siteName: event.target.value }))
}
/>
</FormField>
<FormField label="Site URL">
<Input
value={form.siteUrl}
onChange={(event) =>
setForm((current) => ({ ...current, siteUrl: event.target.value }))
}
/>
</FormField>
<FormField label="Avatar URL">
<Input
value={form.avatarUrl}
onChange={(event) =>
setForm((current) => ({ ...current, avatarUrl: event.target.value }))
}
/>
</FormField>
<FormField label="Category">
<Input
value={form.category}
onChange={(event) =>
setForm((current) => ({ ...current, category: event.target.value }))
}
/>
</FormField>
<div className="lg:col-span-2">
<FormField label="Status">
<Select
value={form.status}
onChange={(event) =>
setForm((current) => ({ ...current, status: event.target.value }))
}
>
<option value="pending">Pending</option>
<option value="approved">Approved</option>
<option value="rejected">Rejected</option>
</Select>
</FormField>
</div>
<div className="lg:col-span-2">
<FormField label="Description">
<Textarea
value={form.description}
onChange={(event) =>
setForm((current) => ({ ...current, description: event.target.value }))
}
/>
</FormField>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,687 @@
import {
ExternalLink,
FilePlus2,
PencilLine,
RefreshCcw,
Save,
Trash2,
} from 'lucide-react'
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { toast } from 'sonner'
import { FormField } from '@/components/form-field'
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 { Select } from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Textarea } from '@/components/ui/textarea'
import { adminApi, ApiError } from '@/lib/api'
import { emptyToNull, formatDateTime, postTagsToList } from '@/lib/admin-format'
import type { CreatePostPayload, PostRecord } from '@/lib/types'
type PostFormState = {
id: number
title: string
slug: string
description: string
category: string
postType: string
image: string
pinned: boolean
tags: string
markdown: string
path: string
createdAt: string
updatedAt: string
}
type CreatePostFormState = {
title: string
slug: string
description: string
category: string
postType: string
image: string
pinned: boolean
tags: string
markdown: string
}
const defaultCreateForm: CreatePostFormState = {
title: '',
slug: '',
description: '',
category: '',
postType: 'article',
image: '',
pinned: false,
tags: '',
markdown: '# Untitled post\n',
}
function buildEditorState(post: PostRecord, markdown: string, path: string): PostFormState {
return {
id: post.id,
title: post.title ?? '',
slug: post.slug,
description: post.description ?? '',
category: post.category ?? '',
postType: post.post_type ?? 'article',
image: post.image ?? '',
pinned: Boolean(post.pinned),
tags: postTagsToList(post.tags).join(', '),
markdown,
path,
createdAt: post.created_at,
updatedAt: post.updated_at,
}
}
function buildCreatePayload(form: CreatePostFormState): CreatePostPayload {
return {
title: form.title.trim(),
slug: emptyToNull(form.slug),
description: emptyToNull(form.description),
content: form.markdown,
category: emptyToNull(form.category),
tags: form.tags
.split(',')
.map((item) => item.trim())
.filter(Boolean),
postType: emptyToNull(form.postType) ?? 'article',
image: emptyToNull(form.image),
pinned: form.pinned,
published: true,
}
}
export function PostsPage() {
const navigate = useNavigate()
const { slug } = useParams()
const [posts, setPosts] = useState<PostRecord[]>([])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [editorLoading, setEditorLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [creating, setCreating] = useState(false)
const [deleting, setDeleting] = useState(false)
const [editor, setEditor] = useState<PostFormState | null>(null)
const [createForm, setCreateForm] = useState<CreatePostFormState>(defaultCreateForm)
const [searchTerm, setSearchTerm] = useState('')
const [typeFilter, setTypeFilter] = useState('all')
const [pinnedFilter, setPinnedFilter] = useState('all')
const loadPosts = useCallback(async (showToast = false) => {
try {
if (showToast) {
setRefreshing(true)
}
const next = await adminApi.listPosts()
startTransition(() => {
setPosts(next)
})
if (showToast) {
toast.success('Posts refreshed.')
}
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
return
}
toast.error(error instanceof ApiError ? error.message : 'Unable to load posts.')
} finally {
setLoading(false)
setRefreshing(false)
}
}, [])
const loadEditor = useCallback(
async (nextSlug: string) => {
try {
setEditorLoading(true)
const [post, markdown] = await Promise.all([
adminApi.getPostBySlug(nextSlug),
adminApi.getPostMarkdown(nextSlug),
])
startTransition(() => {
setEditor(buildEditorState(post, markdown.markdown, markdown.path))
})
} catch (error) {
toast.error(error instanceof ApiError ? error.message : 'Unable to open this post.')
navigate('/posts', { replace: true })
} finally {
setEditorLoading(false)
}
},
[navigate],
)
useEffect(() => {
void loadPosts(false)
}, [loadPosts])
useEffect(() => {
if (!slug) {
setEditor(null)
return
}
void loadEditor(slug)
}, [loadEditor, slug])
const filteredPosts = useMemo(() => {
return posts.filter((post) => {
const matchesSearch =
!searchTerm ||
[
post.title ?? '',
post.slug,
post.category ?? '',
post.description ?? '',
post.post_type ?? '',
]
.join('\n')
.toLowerCase()
.includes(searchTerm.toLowerCase())
const matchesType = typeFilter === 'all' || (post.post_type ?? 'article') === typeFilter
const pinnedValue = Boolean(post.pinned)
const matchesPinned =
pinnedFilter === 'all' ||
(pinnedFilter === 'pinned' && pinnedValue) ||
(pinnedFilter === 'regular' && !pinnedValue)
return matchesSearch && matchesType && matchesPinned
})
}, [pinnedFilter, posts, searchTerm, typeFilter])
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">Posts</Badge>
<div>
<h2 className="text-3xl font-semibold tracking-tight">Content library</h2>
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
Create Markdown-backed articles, keep metadata tidy, and edit the public content
store without falling back to the legacy template admin.
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button variant="outline" onClick={() => navigate('/posts')}>
<FilePlus2 className="h-4 w-4" />
New draft
</Button>
<Button variant="secondary" onClick={() => void loadPosts(true)} disabled={refreshing}>
<RefreshCcw className="h-4 w-4" />
{refreshing ? 'Refreshing...' : 'Refresh'}
</Button>
</div>
</div>
<div className="grid gap-6 xl:grid-cols-[0.96fr_1.04fr]">
<Card>
<CardHeader>
<CardTitle>All posts</CardTitle>
<CardDescription>
Filter the content set locally, then jump straight into the selected entry.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 lg:grid-cols-[1.2fr_0.6fr_0.6fr]">
<Input
placeholder="Search by title, slug, category, or copy"
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
/>
<Select value={typeFilter} onChange={(event) => setTypeFilter(event.target.value)}>
<option value="all">All types</option>
<option value="article">Article</option>
<option value="note">Note</option>
<option value="page">Page</option>
<option value="snippet">Snippet</option>
</Select>
<Select
value={pinnedFilter}
onChange={(event) => setPinnedFilter(event.target.value)}
>
<option value="all">All pins</option>
<option value="pinned">Pinned only</option>
<option value="regular">Regular only</option>
</Select>
</div>
{loading ? (
<Skeleton className="h-[620px] rounded-3xl" />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Type</TableHead>
<TableHead>Updated</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredPosts.map((post) => (
<TableRow
key={post.id}
className={post.slug === slug ? 'bg-accent/50' : undefined}
>
<TableCell>
<button
type="button"
className="w-full text-left"
onClick={() => navigate(`/posts/${post.slug}`)}
>
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{post.title ?? 'Untitled post'}</span>
{post.pinned ? <Badge variant="success">Pinned</Badge> : null}
</div>
<p className="font-mono text-xs text-muted-foreground">{post.slug}</p>
<p className="line-clamp-2 text-sm text-muted-foreground">
{post.description ?? 'No description yet.'}
</p>
</div>
</button>
</TableCell>
<TableCell className="uppercase text-muted-foreground">
{post.post_type ?? 'article'}
</TableCell>
<TableCell className="text-muted-foreground">
{formatDateTime(post.updated_at)}
</TableCell>
</TableRow>
))}
{!filteredPosts.length ? (
<TableRow>
<TableCell colSpan={3} className="py-12 text-center text-muted-foreground">
No posts match the current filters.
</TableCell>
</TableRow>
) : null}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{editorLoading ? (
<Skeleton className="h-[720px] rounded-3xl" />
) : editor ? (
<Card>
<CardHeader className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div>
<CardTitle>Edit post</CardTitle>
<CardDescription>
Keep the metadata in sync with the Markdown document already powering the public
article page.
</CardDescription>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button variant="outline" asChild>
<a
href={`http://localhost:4321/articles/${editor.slug}`}
target="_blank"
rel="noreferrer"
>
<ExternalLink className="h-4 w-4" />
Open article
</a>
</Button>
<Button
onClick={async () => {
if (!editor.title.trim()) {
toast.error('Title is required before saving.')
return
}
try {
setSaving(true)
const updatedPost = await adminApi.updatePost(editor.id, {
title: editor.title.trim(),
slug: editor.slug,
description: emptyToNull(editor.description),
category: emptyToNull(editor.category),
tags: editor.tags
.split(',')
.map((item) => item.trim())
.filter(Boolean),
postType: emptyToNull(editor.postType) ?? 'article',
image: emptyToNull(editor.image),
pinned: editor.pinned,
})
const updatedMarkdown = await adminApi.updatePostMarkdown(
editor.slug,
editor.markdown,
)
startTransition(() => {
setEditor(
buildEditorState(
updatedPost,
updatedMarkdown.markdown,
updatedMarkdown.path,
),
)
})
await loadPosts(false)
toast.success('Post saved.')
} catch (error) {
toast.error(error instanceof ApiError ? error.message : 'Unable to save post.')
} finally {
setSaving(false)
}
}}
disabled={saving}
>
<Save className="h-4 w-4" />
{saving ? 'Saving...' : 'Save post'}
</Button>
<Button
variant="danger"
onClick={async () => {
if (!window.confirm(`Delete "${editor.title || editor.slug}"?`)) {
return
}
try {
setDeleting(true)
await adminApi.deletePost(editor.slug)
toast.success('Post deleted.')
await loadPosts(false)
navigate('/posts', { replace: true })
} catch (error) {
toast.error(
error instanceof ApiError ? error.message : 'Unable to delete post.',
)
} finally {
setDeleting(false)
}
}}
disabled={deleting}
>
<Trash2 className="h-4 w-4" />
{deleting ? 'Deleting...' : 'Delete'}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 rounded-3xl border border-border/70 bg-background/60 p-5 sm:grid-cols-2">
<div>
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
Markdown file
</p>
<p className="mt-2 font-mono text-sm text-muted-foreground">{editor.path}</p>
</div>
<div>
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
Created
</p>
<p className="mt-2 text-sm text-muted-foreground">
{formatDateTime(editor.createdAt)}
</p>
</div>
</div>
<div className="grid gap-5 lg:grid-cols-2">
<FormField label="Title">
<Input
value={editor.title}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, title: event.target.value } : current,
)
}
/>
</FormField>
<FormField label="Slug" hint="Slug changes stay disabled to avoid Markdown path drift.">
<Input value={editor.slug} disabled />
</FormField>
<FormField label="Category">
<Input
value={editor.category}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, category: event.target.value } : current,
)
}
/>
</FormField>
<FormField label="Post type">
<Select
value={editor.postType}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, postType: event.target.value } : current,
)
}
>
<option value="article">Article</option>
<option value="note">Note</option>
<option value="page">Page</option>
<option value="snippet">Snippet</option>
</Select>
</FormField>
<FormField label="Cover image URL">
<Input
value={editor.image}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, image: event.target.value } : current,
)
}
/>
</FormField>
<FormField label="Tags" hint="Comma-separated tag names.">
<Input
value={editor.tags}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, tags: event.target.value } : current,
)
}
/>
</FormField>
<div className="lg:col-span-2">
<FormField label="Description">
<Textarea
value={editor.description}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, description: event.target.value } : current,
)
}
/>
</FormField>
</div>
</div>
<label className="flex items-start gap-3 rounded-2xl border border-border/70 bg-background/60 p-4">
<input
type="checkbox"
checked={editor.pinned}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, pinned: event.target.checked } : current,
)
}
className="mt-1 h-4 w-4 rounded border-input text-primary focus:ring-ring"
/>
<div>
<div className="font-medium">Pin this post</div>
<p className="mt-1 text-sm leading-6 text-muted-foreground">
Pinned content gets elevated placement on the public site.
</p>
</div>
</label>
<FormField label="Markdown body">
<Textarea
className="min-h-[420px] font-mono text-[13px] leading-6"
value={editor.markdown}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, markdown: event.target.value } : current,
)
}
/>
</FormField>
</CardContent>
</Card>
) : (
<Card>
<CardHeader className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div>
<CardTitle>Create a new post</CardTitle>
<CardDescription>
New drafts are written to the Markdown content store first, then surfaced through
the same API the public site already uses.
</CardDescription>
</div>
<Button
onClick={async () => {
if (!createForm.title.trim()) {
toast.error('Title is required to create a post.')
return
}
try {
setCreating(true)
const created = await adminApi.createPost(buildCreatePayload(createForm))
toast.success('Draft created.')
setCreateForm(defaultCreateForm)
await loadPosts(false)
navigate(`/posts/${created.slug}`)
} catch (error) {
toast.error(
error instanceof ApiError ? error.message : 'Unable to create draft.',
)
} finally {
setCreating(false)
}
}}
disabled={creating}
>
<PencilLine className="h-4 w-4" />
{creating ? 'Creating...' : 'Create draft'}
</Button>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-5 lg:grid-cols-2">
<FormField label="Title">
<Input
value={createForm.title}
onChange={(event) =>
setCreateForm((current) => ({ ...current, title: event.target.value }))
}
/>
</FormField>
<FormField label="Slug" hint="Leave empty to auto-generate from the title.">
<Input
value={createForm.slug}
onChange={(event) =>
setCreateForm((current) => ({ ...current, slug: event.target.value }))
}
/>
</FormField>
<FormField label="Category">
<Input
value={createForm.category}
onChange={(event) =>
setCreateForm((current) => ({ ...current, category: event.target.value }))
}
/>
</FormField>
<FormField label="Post type">
<Select
value={createForm.postType}
onChange={(event) =>
setCreateForm((current) => ({ ...current, postType: event.target.value }))
}
>
<option value="article">Article</option>
<option value="note">Note</option>
<option value="page">Page</option>
<option value="snippet">Snippet</option>
</Select>
</FormField>
<FormField label="Cover image URL">
<Input
value={createForm.image}
onChange={(event) =>
setCreateForm((current) => ({ ...current, image: event.target.value }))
}
/>
</FormField>
<FormField label="Tags" hint="Comma-separated tag names.">
<Input
value={createForm.tags}
onChange={(event) =>
setCreateForm((current) => ({ ...current, tags: event.target.value }))
}
/>
</FormField>
<div className="lg:col-span-2">
<FormField label="Description">
<Textarea
value={createForm.description}
onChange={(event) =>
setCreateForm((current) => ({
...current,
description: event.target.value,
}))
}
/>
</FormField>
</div>
</div>
<label className="flex items-start gap-3 rounded-2xl border border-border/70 bg-background/60 p-4">
<input
type="checkbox"
checked={createForm.pinned}
onChange={(event) =>
setCreateForm((current) => ({ ...current, pinned: event.target.checked }))
}
className="mt-1 h-4 w-4 rounded border-input text-primary focus:ring-ring"
/>
<div>
<div className="font-medium">Start as pinned</div>
<p className="mt-1 text-sm leading-6 text-muted-foreground">
Use this when the draft should surface prominently as soon as it is published.
</p>
</div>
</label>
<FormField label="Initial Markdown">
<Textarea
className="min-h-[420px] font-mono text-[13px] leading-6"
value={createForm.markdown}
onChange={(event) =>
setCreateForm((current) => ({ ...current, markdown: event.target.value }))
}
/>
</FormField>
</CardContent>
</Card>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,444 @@
import { BookOpenText, RefreshCcw, Save, Trash2 } from 'lucide-react'
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { FormField } from '@/components/form-field'
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 { Select } from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import { adminApi, ApiError } from '@/lib/api'
import { csvToList, formatDateTime, reviewTagsToList } from '@/lib/admin-format'
import type { CreateReviewPayload, ReviewRecord, UpdateReviewPayload } from '@/lib/types'
type ReviewFormState = {
title: string
reviewType: string
rating: string
reviewDate: string
status: string
description: string
tags: string
cover: string
}
const defaultReviewForm: ReviewFormState = {
title: '',
reviewType: 'book',
rating: '4',
reviewDate: '',
status: 'published',
description: '',
tags: '',
cover: '',
}
function toFormState(review: ReviewRecord): ReviewFormState {
return {
title: review.title ?? '',
reviewType: review.review_type ?? 'book',
rating: String(review.rating ?? 4),
reviewDate: review.review_date ?? '',
status: review.status ?? 'published',
description: review.description ?? '',
tags: reviewTagsToList(review.tags).join(', '),
cover: review.cover ?? '',
}
}
function toCreatePayload(form: ReviewFormState): CreateReviewPayload {
return {
title: form.title.trim(),
review_type: form.reviewType,
rating: Number(form.rating),
review_date: form.reviewDate,
status: form.status,
description: form.description.trim(),
tags: csvToList(form.tags),
cover: form.cover.trim(),
}
}
function toUpdatePayload(form: ReviewFormState): UpdateReviewPayload {
return {
title: form.title.trim(),
review_type: form.reviewType,
rating: Number(form.rating),
review_date: form.reviewDate,
status: form.status,
description: form.description.trim(),
tags: csvToList(form.tags),
cover: form.cover.trim(),
}
}
export function ReviewsPage() {
const [reviews, setReviews] = useState<ReviewRecord[]>([])
const [selectedId, setSelectedId] = useState<number | null>(null)
const [form, setForm] = useState<ReviewFormState>(defaultReviewForm)
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [saving, setSaving] = useState(false)
const [deleting, setDeleting] = useState(false)
const [searchTerm, setSearchTerm] = useState('')
const [statusFilter, setStatusFilter] = useState('all')
const loadReviews = useCallback(async (showToast = false) => {
try {
if (showToast) {
setRefreshing(true)
}
const next = await adminApi.listReviews()
startTransition(() => {
setReviews(next)
})
if (showToast) {
toast.success('Reviews refreshed.')
}
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
return
}
toast.error(error instanceof ApiError ? error.message : 'Unable to load reviews.')
} finally {
setLoading(false)
setRefreshing(false)
}
}, [])
useEffect(() => {
void loadReviews(false)
}, [loadReviews])
const filteredReviews = useMemo(() => {
return reviews.filter((review) => {
const matchesSearch =
!searchTerm ||
[
review.title ?? '',
review.review_type ?? '',
review.description ?? '',
review.tags ?? '',
review.status ?? '',
]
.join('\n')
.toLowerCase()
.includes(searchTerm.toLowerCase())
const matchesStatus =
statusFilter === 'all' || (review.status ?? 'published') === statusFilter
return matchesSearch && matchesStatus
})
}, [reviews, searchTerm, statusFilter])
const selectedReview = useMemo(
() => reviews.find((review) => review.id === selectedId) ?? null,
[reviews, selectedId],
)
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">Reviews</Badge>
<div>
<h2 className="text-3xl font-semibold tracking-tight">Review library</h2>
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
Manage the review catalog that powers the public review index, including score,
medium, cover art, and publication state.
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button
variant="outline"
onClick={() => {
setSelectedId(null)
setForm(defaultReviewForm)
}}
>
New review
</Button>
<Button variant="secondary" onClick={() => void loadReviews(true)} disabled={refreshing}>
<RefreshCcw className="h-4 w-4" />
{refreshing ? 'Refreshing...' : 'Refresh'}
</Button>
</div>
</div>
<div className="grid gap-6 xl:grid-cols-[0.92fr_1.08fr]">
<Card>
<CardHeader>
<CardTitle>Review list</CardTitle>
<CardDescription>
Select an existing review to edit it, or start a new entry from the editor.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 lg:grid-cols-[1.2fr_0.6fr]">
<Input
placeholder="Search by title, medium, description, tags, or status"
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
/>
<Select
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value)}
>
<option value="all">All statuses</option>
<option value="published">Published</option>
<option value="draft">Draft</option>
<option value="archived">Archived</option>
</Select>
</div>
{loading ? (
<Skeleton className="h-[620px] rounded-3xl" />
) : (
<div className="space-y-3">
{filteredReviews.map((review) => (
<button
key={review.id}
type="button"
onClick={() => {
setSelectedId(review.id)
setForm(toFormState(review))
}}
className={`w-full rounded-3xl border px-4 py-4 text-left transition ${
selectedId === review.id
? 'border-primary/30 bg-primary/10 shadow-[0_12px_30px_rgba(37,99,235,0.12)]'
: 'border-border/70 bg-background/60 hover:border-border'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{review.title ?? 'Untitled review'}</span>
<Badge variant="outline">{review.review_type ?? 'unknown'}</Badge>
</div>
<p className="line-clamp-2 text-sm text-muted-foreground">
{review.description ?? 'No description yet.'}
</p>
<p className="text-xs text-muted-foreground">
{reviewTagsToList(review.tags).join(', ') || 'No tags'}
</p>
</div>
<div className="text-right">
<div className="text-xl font-semibold">{review.rating ?? 0}/5</div>
<p className="mt-1 text-xs uppercase tracking-[0.18em] text-muted-foreground">
{review.status ?? 'published'}
</p>
<p className="mt-2 text-xs text-muted-foreground">
{formatDateTime(review.created_at)}
</p>
</div>
</div>
</button>
))}
{!filteredReviews.length ? (
<div className="flex flex-col items-center gap-3 rounded-3xl border border-dashed border-border/70 px-6 py-14 text-center text-muted-foreground">
<BookOpenText className="h-8 w-8" />
<p>No reviews match the current filters.</p>
</div>
) : null}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div>
<CardTitle>{selectedReview ? 'Edit review' : 'Create review'}</CardTitle>
<CardDescription>
Control the presentation fields the public reviews page reads directly from the
backend.
</CardDescription>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button
onClick={async () => {
if (!form.title.trim()) {
toast.error('Title is required.')
return
}
if (!form.reviewDate) {
toast.error('Review date is required.')
return
}
try {
setSaving(true)
if (selectedReview) {
const updated = await adminApi.updateReview(
selectedReview.id,
toUpdatePayload(form),
)
startTransition(() => {
setSelectedId(updated.id)
setForm(toFormState(updated))
})
toast.success('Review updated.')
} else {
const created = await adminApi.createReview(toCreatePayload(form))
startTransition(() => {
setSelectedId(created.id)
setForm(toFormState(created))
})
toast.success('Review created.')
}
await loadReviews(false)
} catch (error) {
toast.error(error instanceof ApiError ? error.message : 'Unable to save review.')
} finally {
setSaving(false)
}
}}
disabled={saving}
>
<Save className="h-4 w-4" />
{saving ? 'Saving...' : selectedReview ? 'Save changes' : 'Create review'}
</Button>
{selectedReview ? (
<Button
variant="danger"
disabled={deleting}
onClick={async () => {
if (!window.confirm('Delete this review?')) {
return
}
try {
setDeleting(true)
await adminApi.deleteReview(selectedReview.id)
toast.success('Review deleted.')
setSelectedId(null)
setForm(defaultReviewForm)
await loadReviews(false)
} catch (error) {
toast.error(
error instanceof ApiError ? error.message : 'Unable to delete review.',
)
} finally {
setDeleting(false)
}
}}
>
<Trash2 className="h-4 w-4" />
{deleting ? 'Deleting...' : 'Delete'}
</Button>
) : null}
</div>
</CardHeader>
<CardContent className="space-y-5">
{selectedReview ? (
<div className="rounded-3xl border border-border/70 bg-background/60 p-5">
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
Selected record
</p>
<p className="mt-2 text-sm text-muted-foreground">
Created {formatDateTime(selectedReview.created_at)}
</p>
</div>
) : null}
<div className="grid gap-5 lg:grid-cols-2">
<FormField label="Title">
<Input
value={form.title}
onChange={(event) =>
setForm((current) => ({ ...current, title: event.target.value }))
}
/>
</FormField>
<FormField label="Review type">
<Select
value={form.reviewType}
onChange={(event) =>
setForm((current) => ({ ...current, reviewType: event.target.value }))
}
>
<option value="book">Book</option>
<option value="movie">Movie</option>
<option value="game">Game</option>
<option value="anime">Anime</option>
<option value="music">Music</option>
</Select>
</FormField>
<FormField label="Rating">
<Input
type="number"
min="1"
max="5"
step="1"
value={form.rating}
onChange={(event) =>
setForm((current) => ({ ...current, rating: event.target.value }))
}
/>
</FormField>
<FormField label="Review date">
<Input
type="date"
value={form.reviewDate}
onChange={(event) =>
setForm((current) => ({ ...current, reviewDate: event.target.value }))
}
/>
</FormField>
<FormField label="Status">
<Select
value={form.status}
onChange={(event) =>
setForm((current) => ({ ...current, status: event.target.value }))
}
>
<option value="published">Published</option>
<option value="draft">Draft</option>
<option value="archived">Archived</option>
</Select>
</FormField>
<FormField label="Cover URL">
<Input
value={form.cover}
onChange={(event) =>
setForm((current) => ({ ...current, cover: event.target.value }))
}
/>
</FormField>
<div className="lg:col-span-2">
<FormField label="Tags" hint="Comma-separated tag names.">
<Input
value={form.tags}
onChange={(event) =>
setForm((current) => ({ ...current, tags: event.target.value }))
}
/>
</FormField>
</div>
<div className="lg:col-span-2">
<FormField label="Description">
<Textarea
value={form.description}
onChange={(event) =>
setForm((current) => ({ ...current, description: event.target.value }))
}
/>
</FormField>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -122,8 +122,8 @@ export function SiteSettingsPage() {
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.
This page keeps the public brand, owner profile, and AI configuration aligned with
the same backend data model the site already depends on.
</p>
</div>
</div>