feat: migrate admin content and moderation modules

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

View File

@@ -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>
)
}