421 lines
15 KiB
TypeScript
421 lines
15 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
import { BookText, Plus, Save, Trash2 } from 'lucide-react'
|
|
|
|
import {
|
|
createAdminBlogPost,
|
|
deleteAdminBlogPost,
|
|
fetchAdminBlogAuthors,
|
|
fetchAdminBlogCategories,
|
|
fetchAdminBlogPosts,
|
|
fetchAdminBlogTags,
|
|
type BlogPost,
|
|
type BlogStatus,
|
|
type BlogTaxonomy,
|
|
updateAdminBlogPost,
|
|
} from '../blog'
|
|
|
|
type EditorState = {
|
|
id?: string
|
|
title: string
|
|
slug: string
|
|
excerpt: string
|
|
content: string
|
|
contentPagePath: string
|
|
status: BlogStatus
|
|
publishedAt: string
|
|
categories: string
|
|
tags: string
|
|
authorId: string
|
|
}
|
|
|
|
const EMPTY_EDITOR: EditorState = {
|
|
title: '',
|
|
slug: '',
|
|
excerpt: '',
|
|
content: '',
|
|
contentPagePath: '',
|
|
status: 'draft',
|
|
publishedAt: '',
|
|
categories: '',
|
|
tags: '',
|
|
authorId: '',
|
|
}
|
|
|
|
function toDateTimeInput(value?: number): string {
|
|
if (!value || !Number.isFinite(value)) {
|
|
return ''
|
|
}
|
|
const date = new Date(value)
|
|
const offsetMs = date.getTimezoneOffset() * 60_000
|
|
return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16)
|
|
}
|
|
|
|
function fromDateTimeInput(value: string): number | undefined {
|
|
const trimmed = value.trim()
|
|
if (!trimmed) {
|
|
return undefined
|
|
}
|
|
const timestamp = Date.parse(trimmed)
|
|
return Number.isFinite(timestamp) ? timestamp : undefined
|
|
}
|
|
|
|
function parseTokenList(value: string): string[] {
|
|
return value
|
|
.split(',')
|
|
.map(token => token.trim())
|
|
.filter(Boolean)
|
|
}
|
|
|
|
function toEditor(post: BlogPost): EditorState {
|
|
return {
|
|
id: post.id,
|
|
title: post.title,
|
|
slug: post.slug,
|
|
excerpt: post.excerpt ?? '',
|
|
content: post.content ?? '',
|
|
contentPagePath: post.contentPagePath ?? '',
|
|
status: post.status ?? 'draft',
|
|
publishedAt: toDateTimeInput(post.publishedAt),
|
|
categories: (post.categories ?? []).join(', '),
|
|
tags: (post.tags ?? []).join(', '),
|
|
authorId: post.authorId ?? '',
|
|
}
|
|
}
|
|
|
|
function formatDate(value?: number): string {
|
|
if (!value) {
|
|
return '-'
|
|
}
|
|
return new Date(value).toLocaleString()
|
|
}
|
|
|
|
export function AdminBlogPage() {
|
|
const [posts, setPosts] = useState<BlogPost[]>([])
|
|
const [categories, setCategories] = useState<BlogTaxonomy[]>([])
|
|
const [tags, setTags] = useState<BlogTaxonomy[]>([])
|
|
const [authors, setAuthors] = useState<BlogTaxonomy[]>([])
|
|
const [selectedID, setSelectedID] = useState<string | null>(null)
|
|
const [editor, setEditor] = useState<EditorState>(EMPTY_EDITOR)
|
|
const [loading, setLoading] = useState(true)
|
|
const [saving, setSaving] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [notice, setNotice] = useState<string | null>(null)
|
|
|
|
const sortedPosts = useMemo(
|
|
() => [...posts].sort((a, b) => (b.updatedAt ?? b.createdAt ?? 0) - (a.updatedAt ?? a.createdAt ?? 0)),
|
|
[posts],
|
|
)
|
|
|
|
const loadAll = useCallback(async () => {
|
|
setLoading(true)
|
|
setError(null)
|
|
|
|
try {
|
|
const [postsResult, categoriesResult, tagsResult, authorsResult] = await Promise.all([
|
|
fetchAdminBlogPosts(),
|
|
fetchAdminBlogCategories(),
|
|
fetchAdminBlogTags(),
|
|
fetchAdminBlogAuthors(),
|
|
])
|
|
|
|
setPosts(postsResult)
|
|
setCategories(categoriesResult)
|
|
setTags(tagsResult)
|
|
setAuthors(authorsResult)
|
|
|
|
if (selectedID) {
|
|
const current = postsResult.find(post => post.id === selectedID)
|
|
if (current) {
|
|
setEditor(toEditor(current))
|
|
}
|
|
}
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to load blog administration data')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [selectedID])
|
|
|
|
useEffect(() => {
|
|
void loadAll()
|
|
}, [loadAll])
|
|
|
|
const selectPost = (post: BlogPost) => {
|
|
setSelectedID(post.id)
|
|
setEditor(toEditor(post))
|
|
setNotice(null)
|
|
setError(null)
|
|
}
|
|
|
|
const newPost = () => {
|
|
setSelectedID(null)
|
|
setEditor(EMPTY_EDITOR)
|
|
setNotice(null)
|
|
setError(null)
|
|
}
|
|
|
|
const savePost = async () => {
|
|
const title = editor.title.trim()
|
|
if (!title) {
|
|
setError('Title is required')
|
|
return
|
|
}
|
|
|
|
setSaving(true)
|
|
setError(null)
|
|
setNotice(null)
|
|
|
|
const payload = {
|
|
title,
|
|
slug: editor.slug.trim() || undefined,
|
|
excerpt: editor.excerpt.trim() || undefined,
|
|
content: editor.content.trim() || undefined,
|
|
contentPagePath: editor.contentPagePath.trim() || undefined,
|
|
status: editor.status,
|
|
publishedAt: fromDateTimeInput(editor.publishedAt),
|
|
categories: parseTokenList(editor.categories),
|
|
tags: parseTokenList(editor.tags),
|
|
authorId: editor.authorId.trim() || undefined,
|
|
}
|
|
|
|
try {
|
|
if (editor.id) {
|
|
const updated = await updateAdminBlogPost(editor.id, payload)
|
|
setNotice('Post updated')
|
|
setSelectedID(updated.id)
|
|
} else {
|
|
const created = await createAdminBlogPost(payload)
|
|
setNotice('Post created')
|
|
setSelectedID(created.id)
|
|
}
|
|
await loadAll()
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to save blog post')
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const removePost = async () => {
|
|
if (!selectedID) {
|
|
return
|
|
}
|
|
|
|
setSaving(true)
|
|
setError(null)
|
|
setNotice(null)
|
|
|
|
try {
|
|
await deleteAdminBlogPost(selectedID)
|
|
setNotice('Post deleted')
|
|
setSelectedID(null)
|
|
setEditor(EMPTY_EDITOR)
|
|
await loadAll()
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to delete blog post')
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<main className="min-h-0 p-4 md:p-6">
|
|
<div className="mx-auto grid max-w-7xl gap-4 lg:grid-cols-[320px_1fr]">
|
|
<aside className="rounded-xl border border-zinc-200 bg-white shadow-sm">
|
|
<div className="flex items-center justify-between border-b border-zinc-200 px-4 py-3">
|
|
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-900">
|
|
<BookText size={16} />
|
|
Blog Posts
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={newPost}
|
|
className="inline-flex items-center gap-1 rounded-md border border-zinc-200 px-2 py-1 text-xs font-medium text-zinc-700 hover:bg-zinc-50"
|
|
>
|
|
<Plus size={14} />
|
|
New
|
|
</button>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<p className="px-4 py-6 text-sm text-zinc-500">Loading posts...</p>
|
|
) : sortedPosts.length === 0 ? (
|
|
<p className="px-4 py-6 text-sm text-zinc-500">No posts yet. Create your first post.</p>
|
|
) : (
|
|
<ul className="max-h-[70vh] overflow-auto p-2">
|
|
{sortedPosts.map(post => (
|
|
<li key={post.id}>
|
|
<button
|
|
type="button"
|
|
onClick={() => selectPost(post)}
|
|
className={`w-full rounded-md px-3 py-2 text-left ${selectedID === post.id ? 'bg-zinc-900 text-white' : 'text-zinc-800 hover:bg-zinc-100'}`}
|
|
>
|
|
<p className="truncate text-sm font-medium">{post.title || post.id}</p>
|
|
<p className={`text-xs ${selectedID === post.id ? 'text-zinc-200' : 'text-zinc-500'}`}>
|
|
{(post.status ?? 'draft')} · updated {formatDate(post.updatedAt ?? post.createdAt)}
|
|
</p>
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</aside>
|
|
|
|
<section className="rounded-xl border border-zinc-200 bg-white shadow-sm">
|
|
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-zinc-200 px-4 py-3">
|
|
<h1 className="text-sm font-semibold text-zinc-900">Blog Administration</h1>
|
|
<div className="flex items-center gap-2">
|
|
{selectedID ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => void removePost()}
|
|
disabled={saving}
|
|
className="inline-flex items-center gap-1 rounded-md border border-red-200 px-2.5 py-1.5 text-xs font-medium text-red-700 hover:bg-red-50 disabled:opacity-50"
|
|
>
|
|
<Trash2 size={14} />
|
|
Delete
|
|
</button>
|
|
) : null}
|
|
<button
|
|
type="button"
|
|
onClick={() => void savePost()}
|
|
disabled={saving}
|
|
className="inline-flex items-center gap-1 rounded-md bg-zinc-900 px-2.5 py-1.5 text-xs font-medium text-white hover:bg-zinc-800 disabled:opacity-50"
|
|
>
|
|
<Save size={14} />
|
|
{saving ? 'Saving...' : 'Save'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-4 p-4">
|
|
{error ? <p className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{error}</p> : null}
|
|
{notice ? <p className="rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{notice}</p> : null}
|
|
|
|
<div className="grid gap-3 md:grid-cols-2">
|
|
<label className="space-y-1 text-xs font-medium text-zinc-600">
|
|
Title
|
|
<input
|
|
value={editor.title}
|
|
onChange={event => setEditor(prev => ({ ...prev, title: event.target.value }))}
|
|
placeholder="Platform update"
|
|
className="w-full rounded-md border border-zinc-200 px-3 py-2 text-sm text-zinc-900"
|
|
/>
|
|
</label>
|
|
<label className="space-y-1 text-xs font-medium text-zinc-600">
|
|
Slug
|
|
<input
|
|
value={editor.slug}
|
|
onChange={event => setEditor(prev => ({ ...prev, slug: event.target.value }))}
|
|
placeholder="platform-update"
|
|
className="w-full rounded-md border border-zinc-200 px-3 py-2 text-sm text-zinc-900"
|
|
/>
|
|
</label>
|
|
|
|
<label className="space-y-1 text-xs font-medium text-zinc-600">
|
|
Status
|
|
<select
|
|
value={editor.status}
|
|
onChange={event => setEditor(prev => ({ ...prev, status: event.target.value as BlogStatus }))}
|
|
className="w-full rounded-md border border-zinc-200 px-3 py-2 text-sm text-zinc-900"
|
|
>
|
|
<option value="draft">Draft</option>
|
|
<option value="scheduled">Scheduled</option>
|
|
<option value="published">Published</option>
|
|
</select>
|
|
</label>
|
|
|
|
<label className="space-y-1 text-xs font-medium text-zinc-600">
|
|
Published At
|
|
<input
|
|
type="datetime-local"
|
|
value={editor.publishedAt}
|
|
onChange={event => setEditor(prev => ({ ...prev, publishedAt: event.target.value }))}
|
|
className="w-full rounded-md border border-zinc-200 px-3 py-2 text-sm text-zinc-900"
|
|
/>
|
|
</label>
|
|
|
|
<label className="space-y-1 text-xs font-medium text-zinc-600 md:col-span-2">
|
|
Excerpt
|
|
<textarea
|
|
value={editor.excerpt}
|
|
onChange={event => setEditor(prev => ({ ...prev, excerpt: event.target.value }))}
|
|
rows={3}
|
|
className="w-full rounded-md border border-zinc-200 px-3 py-2 text-sm text-zinc-900"
|
|
/>
|
|
</label>
|
|
|
|
<label className="space-y-1 text-xs font-medium text-zinc-600 md:col-span-2">
|
|
Content
|
|
<textarea
|
|
value={editor.content}
|
|
onChange={event => setEditor(prev => ({ ...prev, content: event.target.value }))}
|
|
rows={10}
|
|
className="w-full rounded-md border border-zinc-200 px-3 py-2 text-sm text-zinc-900"
|
|
/>
|
|
</label>
|
|
|
|
<label className="space-y-1 text-xs font-medium text-zinc-600 md:col-span-2">
|
|
Linked content page path
|
|
<input
|
|
value={editor.contentPagePath}
|
|
onChange={event => setEditor(prev => ({ ...prev, contentPagePath: event.target.value }))}
|
|
placeholder="/home"
|
|
className="w-full rounded-md border border-zinc-200 px-3 py-2 text-sm text-zinc-900"
|
|
/>
|
|
</label>
|
|
|
|
<label className="space-y-1 text-xs font-medium text-zinc-600">
|
|
Categories (comma-separated)
|
|
<input
|
|
value={editor.categories}
|
|
onChange={event => setEditor(prev => ({ ...prev, categories: event.target.value }))}
|
|
placeholder="news, release"
|
|
className="w-full rounded-md border border-zinc-200 px-3 py-2 text-sm text-zinc-900"
|
|
/>
|
|
</label>
|
|
|
|
<label className="space-y-1 text-xs font-medium text-zinc-600">
|
|
Tags (comma-separated)
|
|
<input
|
|
value={editor.tags}
|
|
onChange={event => setEditor(prev => ({ ...prev, tags: event.target.value }))}
|
|
placeholder="platform, launch"
|
|
className="w-full rounded-md border border-zinc-200 px-3 py-2 text-sm text-zinc-900"
|
|
/>
|
|
</label>
|
|
|
|
<label className="space-y-1 text-xs font-medium text-zinc-600 md:col-span-2">
|
|
Author
|
|
<select
|
|
value={editor.authorId}
|
|
onChange={event => setEditor(prev => ({ ...prev, authorId: event.target.value }))}
|
|
className="w-full rounded-md border border-zinc-200 px-3 py-2 text-sm text-zinc-900"
|
|
>
|
|
<option value="">Unassigned</option>
|
|
{authors.map(author => (
|
|
<option key={author.id || author.name} value={author.id || author.name}>
|
|
{author.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
|
|
<section className="grid gap-3 rounded-lg border border-zinc-200 bg-zinc-50 p-3 text-xs text-zinc-600 md:grid-cols-2">
|
|
<div>
|
|
<p className="font-semibold text-zinc-800">Known categories</p>
|
|
<p className="mt-1">{categories.map(item => item.name).join(', ') || 'No categories available yet.'}</p>
|
|
</div>
|
|
<div>
|
|
<p className="font-semibold text-zinc-800">Known tags</p>
|
|
<p className="mt-1">{tags.map(item => item.name).join(', ') || 'No tags available yet.'}</p>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</main>
|
|
)
|
|
}
|