diff --git a/admin/src/App.tsx b/admin/src/App.tsx
index d733d3f..00f4ed2 100644
--- a/admin/src/App.tsx
+++ b/admin/src/App.tsx
@@ -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() {
}>
}>
} />
+ } />
+ } />
+ } />
+ } />
+ } />
} />
diff --git a/admin/src/components/app-shell.tsx b/admin/src/components/app-shell.tsx
index b32edeb..681dcd5 100644
--- a/admin/src/components/app-shell.tsx
+++ b/admin/src/components/app-shell.tsx
@@ -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
- 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.
@@ -104,28 +137,25 @@ export function AppShell({
-
-
+
+
- Migration queue
+ Workspace status
- Legacy Tera screens that move here next.
+ Core admin flows are now available in the standalone app.
-
phase 1
+
live
-
- {nextNav.map((item) => (
-
- {item}
- next
-
- ))}
+
+
+ Public site and admin stay decoupled.
+
+
+ Backend remains the shared auth and data layer.
+
@@ -148,6 +178,26 @@ export function AppShell({
+
+ {primaryNav.map((item) => (
+
+ 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}
+
+ ))}
+
+
diff --git a/admin/src/components/form-field.tsx b/admin/src/components/form-field.tsx
new file mode 100644
index 0000000..8cd2f18
--- /dev/null
+++ b/admin/src/components/form-field.tsx
@@ -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 (
+
+
{label}
+ {children}
+ {hint ?
{hint}
: null}
+
+ )
+}
diff --git a/admin/src/components/ui/select.tsx b/admin/src/components/ui/select.tsx
new file mode 100644
index 0000000..e8dfc33
--- /dev/null
+++ b/admin/src/components/ui/select.tsx
@@ -0,0 +1,19 @@
+import * as React from 'react'
+
+import { cn } from '@/lib/utils'
+
+const Select = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+)
+Select.displayName = 'Select'
+
+export { Select }
diff --git a/admin/src/lib/admin-format.ts b/admin/src/lib/admin-format.ts
new file mode 100644
index 0000000..4fced2d
--- /dev/null
+++ b/admin/src/lib/admin-format.ts
@@ -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 []
+}
diff --git a/admin/src/lib/api.ts b/admin/src/lib/api.ts
index 865b245..f1c3b41 100644
--- a/admin/src/lib/api.ts
+++ b/admin/src/lib/api.ts
@@ -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) {
+ 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(path: string, init?: RequestInit): Promise {
const headers = new Headers(init?.headers)
@@ -84,4 +123,127 @@ export const adminApi = {
request('/api/admin/ai/reindex', {
method: 'POST',
}),
+ listPosts: (query?: PostListQuery) =>
+ request(
+ 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(`/api/posts/slug/${encodeURIComponent(slug)}`),
+ createPost: (payload: CreatePostPayload) =>
+ request('/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(`/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(`/api/posts/slug/${encodeURIComponent(slug)}/markdown`),
+ updatePostMarkdown: (slug: string, markdown: string) =>
+ request(`/api/posts/slug/${encodeURIComponent(slug)}/markdown`, {
+ method: 'PATCH',
+ body: JSON.stringify({ markdown }),
+ }),
+ deletePost: (slug: string) =>
+ request(`/api/posts/slug/${encodeURIComponent(slug)}/markdown`, {
+ method: 'DELETE',
+ }),
+ listComments: (query?: CommentListQuery) =>
+ request(
+ 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(`/api/comments/${id}`, {
+ method: 'PATCH',
+ body: JSON.stringify(payload),
+ }),
+ deleteComment: (id: number) =>
+ request(`/api/comments/${id}`, {
+ method: 'DELETE',
+ }),
+ listFriendLinks: (query?: FriendLinkListQuery) =>
+ request(
+ appendQueryParams('/api/friend_links', {
+ status: query?.status,
+ category: query?.category,
+ }),
+ ),
+ createFriendLink: (payload: FriendLinkPayload) =>
+ request('/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(`/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(`/api/friend_links/${id}`, {
+ method: 'DELETE',
+ }),
+ listReviews: () => request('/api/reviews'),
+ createReview: (payload: CreateReviewPayload) =>
+ request('/api/reviews', {
+ method: 'POST',
+ body: JSON.stringify(payload),
+ }),
+ updateReview: (id: number, payload: UpdateReviewPayload) =>
+ request(`/api/reviews/${id}`, {
+ method: 'PUT',
+ body: JSON.stringify(payload),
+ }),
+ deleteReview: (id: number) =>
+ request(`/api/reviews/${id}`, {
+ method: 'DELETE',
+ }),
}
diff --git a/admin/src/lib/types.ts b/admin/src/lib/types.ts
index 7ca341e..868f735 100644
--- a/admin/src/lib/types.ts
+++ b/admin/src/lib/types.ts
@@ -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
+}
diff --git a/admin/src/pages/comments-page.tsx b/admin/src/pages/comments-page.tsx
new file mode 100644
index 0000000..51d3877
--- /dev/null
+++ b/admin/src/pages/comments-page.tsx
@@ -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([])
+ const [loading, setLoading] = useState(true)
+ const [refreshing, setRefreshing] = useState(false)
+ const [actingId, setActingId] = useState(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 (
+
+
+
+
Comments
+
+
Moderation queue
+
+ Review article comments and paragraph-specific responses from one place, with fast
+ approval controls for the public discussion layer.
+
+
+
+
+
void loadComments(true)} disabled={refreshing}>
+
+ {refreshing ? 'Refreshing...' : 'Refresh'}
+
+
+
+
+
+
+ Pending
+ {pendingCount}
+ Needs moderation attention.
+
+
+
+
+
+ Paragraph replies
+
+ {paragraphCount}
+ Scoped to paragraph anchors.
+
+
+
+
+ Total
+ {comments.length}
+ Everything currently stored.
+
+
+
+
+
+
+ Comment list
+
+ Filter the queue, then approve, hide, or remove entries without leaving the page.
+
+
+
+
+ setSearchTerm(event.target.value)}
+ />
+ setApprovalFilter(event.target.value)}
+ >
+ All approval states
+ Pending only
+ Approved only
+
+ setScopeFilter(event.target.value)}>
+ All scopes
+ Article
+ Paragraph
+
+
+
+ {loading ? (
+
+ ) : (
+
+
+
+ Comment
+ Status
+ Context
+ Actions
+
+
+
+ {filteredComments.map((comment) => (
+
+
+
+
+ {comment.author ?? 'Anonymous'}
+ {comment.scope}
+
+ {formatDateTime(comment.created_at)}
+
+
+
+ {comment.content ?? 'No content provided.'}
+
+ {comment.scope === 'paragraph' ? (
+
+
{comment.paragraph_key ?? 'missing-key'}
+
+ {comment.paragraph_excerpt ?? 'No paragraph excerpt stored.'}
+
+
+ ) : null}
+
+
+
+
+ {comment.approved ? 'Approved' : 'Pending'}
+
+
+
+
+
{comment.post_slug ?? 'unknown-post'}
+ {comment.reply_to_comment_id ? (
+
Replying to #{comment.reply_to_comment_id}
+ ) : (
+
Top-level comment
+ )}
+
+
+
+
+ {
+ 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)
+ }
+ }}
+ >
+
+ Approve
+
+ {
+ 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)
+ }
+ }}
+ >
+
+ Hide
+
+ {
+ 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)
+ }
+ }}
+ >
+
+ Delete
+
+
+
+
+ ))}
+ {!filteredComments.length ? (
+
+
+
+
+
No comments match the current moderation filters.
+
+
+
+ ) : null}
+
+
+ )}
+
+
+
+ )
+}
diff --git a/admin/src/pages/dashboard-page.tsx b/admin/src/pages/dashboard-page.tsx
index 7e22ad6..2828711 100644
--- a/admin/src/pages/dashboard-page.tsx
+++ b/admin/src/pages/dashboard-page.tsx
@@ -135,8 +135,8 @@ export function DashboardPage() {
Operations overview
- 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.
diff --git a/admin/src/pages/friend-links-page.tsx b/admin/src/pages/friend-links-page.tsx
new file mode 100644
index 0000000..fb28a7f
--- /dev/null
+++ b/admin/src/pages/friend-links-page.tsx
@@ -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([])
+ const [selectedId, setSelectedId] = useState(null)
+ const [form, setForm] = useState(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 (
+
+
+
+
Friend links
+
+
Partner site queue
+
+ Review inbound link exchanges, keep metadata accurate, and move requests through
+ pending, approved, or rejected states in one dedicated workspace.
+
+
+
+
+
+ {
+ setSelectedId(null)
+ setForm(defaultFriendLinkForm)
+ }}
+ >
+ New link
+
+ void loadLinks(true)} disabled={refreshing}>
+
+ {refreshing ? 'Refreshing...' : 'Refresh'}
+
+
+
+
+
+
+
+ Link inventory
+
+ Pick an item to edit it, or start a new record from the right-hand form.
+
+
+
+
+ setSearchTerm(event.target.value)}
+ />
+ setStatusFilter(event.target.value)}
+ >
+ All statuses
+ Pending
+ Approved
+ Rejected
+
+
+
+ {loading ? (
+
+ ) : (
+
+ {filteredLinks.map((link) => (
+
{
+ 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'
+ }`}
+ >
+
+
+
+ {link.site_name ?? 'Untitled partner'}
+
+ {link.status ?? 'pending'}
+
+
+
{link.site_url}
+
+ {link.description ?? 'No description yet.'}
+
+
+
+
{link.category ?? 'uncategorized'}
+
{formatDateTime(link.created_at)}
+
+
+
+ ))}
+
+ {!filteredLinks.length ? (
+
+
+
No friend links match the current filters.
+
+ ) : null}
+
+ )}
+
+
+
+
+
+
+ {selectedLink ? 'Edit friend link' : 'Create friend link'}
+
+ Capture the reciprocal URL, classification, and moderation status the public link
+ page depends on.
+
+
+
+ {selectedLink ? (
+
+
+
+ Visit site
+
+
+ ) : null}
+
{
+ 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}
+ >
+
+ {saving ? 'Saving...' : selectedLink ? 'Save changes' : 'Create link'}
+
+ {selectedLink ? (
+
{
+ 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)
+ }
+ }}
+ >
+
+ {deleting ? 'Deleting...' : 'Delete'}
+
+ ) : null}
+
+
+
+ {selectedLink ? (
+
+
+
+
+ Selected record
+
+
+ Created {formatDateTime(selectedLink.created_at)}
+
+
+
+ {selectedLink.status ?? 'pending'}
+
+
+
+ ) : null}
+
+
+
+
+
+
+ )
+}
diff --git a/admin/src/pages/posts-page.tsx b/admin/src/pages/posts-page.tsx
new file mode 100644
index 0000000..a62ba63
--- /dev/null
+++ b/admin/src/pages/posts-page.tsx
@@ -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([])
+ 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(null)
+ const [createForm, setCreateForm] = useState(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 (
+
+
+
+
Posts
+
+
Content library
+
+ Create Markdown-backed articles, keep metadata tidy, and edit the public content
+ store without falling back to the legacy template admin.
+
+
+
+
+
+ navigate('/posts')}>
+
+ New draft
+
+ void loadPosts(true)} disabled={refreshing}>
+
+ {refreshing ? 'Refreshing...' : 'Refresh'}
+
+
+
+
+
+
+
+ All posts
+
+ Filter the content set locally, then jump straight into the selected entry.
+
+
+
+
+ setSearchTerm(event.target.value)}
+ />
+ setTypeFilter(event.target.value)}>
+ All types
+ Article
+ Note
+ Page
+ Snippet
+
+ setPinnedFilter(event.target.value)}
+ >
+ All pins
+ Pinned only
+ Regular only
+
+
+
+ {loading ? (
+
+ ) : (
+
+
+
+ Title
+ Type
+ Updated
+
+
+
+ {filteredPosts.map((post) => (
+
+
+ navigate(`/posts/${post.slug}`)}
+ >
+
+
+ {post.title ?? 'Untitled post'}
+ {post.pinned ? Pinned : null}
+
+
{post.slug}
+
+ {post.description ?? 'No description yet.'}
+
+
+
+
+
+ {post.post_type ?? 'article'}
+
+
+ {formatDateTime(post.updated_at)}
+
+
+ ))}
+ {!filteredPosts.length ? (
+
+
+ No posts match the current filters.
+
+
+ ) : null}
+
+
+ )}
+
+
+
+ {editorLoading ? (
+
+ ) : editor ? (
+
+
+
+ Edit post
+
+ Keep the metadata in sync with the Markdown document already powering the public
+ article page.
+
+
+
+
+
+
+ Open article
+
+
+
{
+ 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}
+ >
+
+ {saving ? 'Saving...' : 'Save post'}
+
+
{
+ 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}
+ >
+
+ {deleting ? 'Deleting...' : 'Delete'}
+
+
+
+
+
+
+
+ Markdown file
+
+
{editor.path}
+
+
+
+ Created
+
+
+ {formatDateTime(editor.createdAt)}
+
+
+
+
+
+
+
+ setEditor((current) =>
+ current ? { ...current, title: event.target.value } : current,
+ )
+ }
+ />
+
+
+
+
+
+
+ setEditor((current) =>
+ current ? { ...current, category: event.target.value } : current,
+ )
+ }
+ />
+
+
+
+ setEditor((current) =>
+ current ? { ...current, postType: event.target.value } : current,
+ )
+ }
+ >
+ Article
+ Note
+ Page
+ Snippet
+
+
+
+
+ setEditor((current) =>
+ current ? { ...current, image: event.target.value } : current,
+ )
+ }
+ />
+
+
+
+ setEditor((current) =>
+ current ? { ...current, tags: event.target.value } : current,
+ )
+ }
+ />
+
+
+
+
+
+
+
+
+
+ setEditor((current) =>
+ current ? { ...current, pinned: event.target.checked } : current,
+ )
+ }
+ className="mt-1 h-4 w-4 rounded border-input text-primary focus:ring-ring"
+ />
+
+
Pin this post
+
+ Pinned content gets elevated placement on the public site.
+
+
+
+
+
+
+
+
+ ) : (
+
+
+
+ Create a new post
+
+ New drafts are written to the Markdown content store first, then surfaced through
+ the same API the public site already uses.
+
+
+ {
+ 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}
+ >
+
+ {creating ? 'Creating...' : 'Create draft'}
+
+
+
+
+
+
+
+ setCreateForm((current) => ({ ...current, pinned: event.target.checked }))
+ }
+ className="mt-1 h-4 w-4 rounded border-input text-primary focus:ring-ring"
+ />
+
+
Start as pinned
+
+ Use this when the draft should surface prominently as soon as it is published.
+
+
+
+
+
+
+
+
+ )}
+
+
+ )
+}
diff --git a/admin/src/pages/reviews-page.tsx b/admin/src/pages/reviews-page.tsx
new file mode 100644
index 0000000..75cec95
--- /dev/null
+++ b/admin/src/pages/reviews-page.tsx
@@ -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([])
+ const [selectedId, setSelectedId] = useState(null)
+ const [form, setForm] = useState(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 (
+
+
+
+
Reviews
+
+
Review library
+
+ Manage the review catalog that powers the public review index, including score,
+ medium, cover art, and publication state.
+
+
+
+
+
+ {
+ setSelectedId(null)
+ setForm(defaultReviewForm)
+ }}
+ >
+ New review
+
+ void loadReviews(true)} disabled={refreshing}>
+
+ {refreshing ? 'Refreshing...' : 'Refresh'}
+
+
+
+
+
+
+
+ Review list
+
+ Select an existing review to edit it, or start a new entry from the editor.
+
+
+
+
+ setSearchTerm(event.target.value)}
+ />
+ setStatusFilter(event.target.value)}
+ >
+ All statuses
+ Published
+ Draft
+ Archived
+
+
+
+ {loading ? (
+
+ ) : (
+
+ {filteredReviews.map((review) => (
+
{
+ 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'
+ }`}
+ >
+
+
+
+ {review.title ?? 'Untitled review'}
+ {review.review_type ?? 'unknown'}
+
+
+ {review.description ?? 'No description yet.'}
+
+
+ {reviewTagsToList(review.tags).join(', ') || 'No tags'}
+
+
+
+
{review.rating ?? 0}/5
+
+ {review.status ?? 'published'}
+
+
+ {formatDateTime(review.created_at)}
+
+
+
+
+ ))}
+
+ {!filteredReviews.length ? (
+
+
+
No reviews match the current filters.
+
+ ) : null}
+
+ )}
+
+
+
+
+
+
+ {selectedReview ? 'Edit review' : 'Create review'}
+
+ Control the presentation fields the public reviews page reads directly from the
+ backend.
+
+
+
+ {
+ 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}
+ >
+
+ {saving ? 'Saving...' : selectedReview ? 'Save changes' : 'Create review'}
+
+ {selectedReview ? (
+ {
+ 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)
+ }
+ }}
+ >
+
+ {deleting ? 'Deleting...' : 'Delete'}
+
+ ) : null}
+
+
+
+ {selectedReview ? (
+
+
+ Selected record
+
+
+ Created {formatDateTime(selectedReview.created_at)}
+
+
+ ) : null}
+
+
+
+
+
+
+ )
+}
diff --git a/admin/src/pages/site-settings-page.tsx b/admin/src/pages/site-settings-page.tsx
index e9d00d0..75e0ce8 100644
--- a/admin/src/pages/site-settings-page.tsx
+++ b/admin/src/pages/site-settings-page.tsx
@@ -122,8 +122,8 @@ export function SiteSettingsPage() {
Brand, profile, and AI controls
- 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.