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