feat: migrate admin content and moderation modules
This commit is contained in:
@@ -21,8 +21,12 @@ import { Toaster, toast } from 'sonner'
|
|||||||
import { AppShell } from '@/components/app-shell'
|
import { AppShell } from '@/components/app-shell'
|
||||||
import { adminApi, ApiError } from '@/lib/api'
|
import { adminApi, ApiError } from '@/lib/api'
|
||||||
import type { AdminSessionResponse } from '@/lib/types'
|
import type { AdminSessionResponse } from '@/lib/types'
|
||||||
|
import { CommentsPage } from '@/pages/comments-page'
|
||||||
import { DashboardPage } from '@/pages/dashboard-page'
|
import { DashboardPage } from '@/pages/dashboard-page'
|
||||||
|
import { FriendLinksPage } from '@/pages/friend-links-page'
|
||||||
import { LoginPage } from '@/pages/login-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'
|
import { SiteSettingsPage } from '@/pages/site-settings-page'
|
||||||
|
|
||||||
type SessionContextValue = {
|
type SessionContextValue = {
|
||||||
@@ -146,6 +150,11 @@ function AppRoutes() {
|
|||||||
<Route element={<SessionGuard />}>
|
<Route element={<SessionGuard />}>
|
||||||
<Route element={<ProtectedLayout />}>
|
<Route element={<ProtectedLayout />}>
|
||||||
<Route index element={<DashboardPage />} />
|
<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 path="/settings" element={<SiteSettingsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -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 type { ReactNode } from 'react'
|
||||||
import { NavLink } from 'react-router-dom'
|
import { NavLink } from 'react-router-dom'
|
||||||
|
|
||||||
@@ -14,6 +25,30 @@ const primaryNav = [
|
|||||||
description: 'Live operational dashboard',
|
description: 'Live operational dashboard',
|
||||||
icon: LayoutDashboard,
|
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',
|
to: '/settings',
|
||||||
label: 'Site settings',
|
label: 'Site settings',
|
||||||
@@ -22,8 +57,6 @@ const primaryNav = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const nextNav = ['Posts editor', 'Comments moderation', 'Friend links queue', 'Review library']
|
|
||||||
|
|
||||||
export function AppShell({
|
export function AppShell({
|
||||||
children,
|
children,
|
||||||
username,
|
username,
|
||||||
@@ -51,8 +84,8 @@ export function AppShell({
|
|||||||
Control room for the blog system
|
Control room for the blog system
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm leading-6 text-muted-foreground">
|
<p className="text-sm leading-6 text-muted-foreground">
|
||||||
A separate React workspace for operations, moderation, and AI-related site
|
A dedicated React workspace for publishing, moderation, operations, and
|
||||||
controls.
|
AI-related site controls.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -104,28 +137,25 @@ export function AppShell({
|
|||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="rounded-[1.7rem] border border-border/70 bg-background/65 p-5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs uppercase tracking-[0.24em] text-muted-foreground">
|
<p className="text-xs uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
Migration queue
|
Workspace status
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline">phase 1</Badge>
|
<Badge variant="success">live</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="mt-4 grid gap-2">
|
||||||
{nextNav.map((item) => (
|
<div className="rounded-2xl border border-border/60 bg-card/70 px-4 py-3 text-sm text-muted-foreground">
|
||||||
<div
|
Public site and admin stay decoupled.
|
||||||
key={item}
|
</div>
|
||||||
className="flex items-center justify-between rounded-2xl border border-border/60 bg-background/60 px-4 py-3"
|
<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.
|
||||||
<span className="text-sm text-muted-foreground">{item}</span>
|
</div>
|
||||||
<Badge variant="secondary">next</Badge>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -148,6 +178,26 @@ export function AppShell({
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<Button variant="outline" asChild>
|
<Button variant="outline" asChild>
|
||||||
<a href="http://localhost:4321" target="_blank" rel="noreferrer">
|
<a href="http://localhost:4321" target="_blank" rel="noreferrer">
|
||||||
|
|||||||
21
admin/src/components/form-field.tsx
Normal file
21
admin/src/components/form-field.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
19
admin/src/components/ui/select.tsx
Normal file
19
admin/src/components/ui/select.tsx
Normal 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 }
|
||||||
64
admin/src/lib/admin-format.ts
Normal file
64
admin/src/lib/admin-format.ts
Normal 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 []
|
||||||
|
}
|
||||||
@@ -3,7 +3,22 @@ import type {
|
|||||||
AdminDashboardResponse,
|
AdminDashboardResponse,
|
||||||
AdminSessionResponse,
|
AdminSessionResponse,
|
||||||
AdminSiteSettingsResponse,
|
AdminSiteSettingsResponse,
|
||||||
|
CommentListQuery,
|
||||||
|
CommentRecord,
|
||||||
|
CreatePostPayload,
|
||||||
|
CreateReviewPayload,
|
||||||
|
FriendLinkListQuery,
|
||||||
|
FriendLinkPayload,
|
||||||
|
FriendLinkRecord,
|
||||||
|
MarkdownDeleteResponse,
|
||||||
|
MarkdownDocumentResponse,
|
||||||
|
PostListQuery,
|
||||||
|
PostRecord,
|
||||||
|
ReviewRecord,
|
||||||
SiteSettingsPayload,
|
SiteSettingsPayload,
|
||||||
|
UpdateCommentPayload,
|
||||||
|
UpdatePostPayload,
|
||||||
|
UpdateReviewPayload,
|
||||||
} from '@/lib/types'
|
} from '@/lib/types'
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE?.trim() || ''
|
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> {
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const headers = new Headers(init?.headers)
|
const headers = new Headers(init?.headers)
|
||||||
|
|
||||||
@@ -84,4 +123,127 @@ export const adminApi = {
|
|||||||
request<AdminAiReindexResponse>('/api/admin/ai/reindex', {
|
request<AdminAiReindexResponse>('/api/admin/ai/reindex', {
|
||||||
method: 'POST',
|
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',
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,3 +135,166 @@ export interface AdminAiReindexResponse {
|
|||||||
indexed_chunks: number
|
indexed_chunks: number
|
||||||
last_indexed_at: string | null
|
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
|
||||||
|
}
|
||||||
|
|||||||
331
admin/src/pages/comments-page.tsx
Normal file
331
admin/src/pages/comments-page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -135,8 +135,8 @@ export function DashboardPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h2 className="text-3xl font-semibold tracking-tight">Operations overview</h2>
|
<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">
|
<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,
|
This screen brings the most important publishing, moderation, and AI signals into the
|
||||||
but now from a standalone React app ready for gradual module migration.
|
new standalone admin so the day-to-day control loop stays in one place.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
411
admin/src/pages/friend-links-page.tsx
Normal file
411
admin/src/pages/friend-links-page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
687
admin/src/pages/posts-page.tsx
Normal file
687
admin/src/pages/posts-page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
444
admin/src/pages/reviews-page.tsx
Normal file
444
admin/src/pages/reviews-page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -122,8 +122,8 @@ export function SiteSettingsPage() {
|
|||||||
Brand, profile, and AI controls
|
Brand, profile, and AI controls
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
|
<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
|
This page keeps the public brand, owner profile, and AI configuration aligned with
|
||||||
form with a real app surface while still talking to the same backend data model.
|
the same backend data model the site already depends on.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user