406 lines
14 KiB
TypeScript
406 lines
14 KiB
TypeScript
import { hasPermission } from "@cms/content/rbac"
|
|
import { createPost, deletePost, listPosts, updatePost } from "@cms/db"
|
|
import { Button } from "@cms/ui/button"
|
|
import { revalidatePath } from "next/cache"
|
|
import Link from "next/link"
|
|
import { redirect } from "next/navigation"
|
|
|
|
import { AdminShell } from "@/components/admin-shell"
|
|
import { translateMessage } from "@/i18n/messages"
|
|
import { getAdminMessages, resolveAdminLocale } from "@/i18n/server"
|
|
import { requirePermissionForRoute } from "@/lib/route-guards"
|
|
|
|
export const dynamic = "force-dynamic"
|
|
|
|
type SearchParamsInput = Record<string, string | string[] | undefined>
|
|
|
|
function readFirstValue(value: string | string[] | undefined): string | null {
|
|
if (Array.isArray(value)) {
|
|
return value[0] ?? null
|
|
}
|
|
|
|
return value ?? null
|
|
}
|
|
|
|
function readRequiredField(formData: FormData, field: string): string {
|
|
const value = formData.get(field)
|
|
|
|
if (typeof value !== "string") {
|
|
return ""
|
|
}
|
|
|
|
return value.trim()
|
|
}
|
|
|
|
function readOptionalField(formData: FormData, field: string): string | undefined {
|
|
const value = readRequiredField(formData, field)
|
|
return value.length > 0 ? value : undefined
|
|
}
|
|
|
|
async function requireNewsWritePermission() {
|
|
await requirePermissionForRoute({
|
|
nextPath: "/",
|
|
permission: "news:write",
|
|
scope: "team",
|
|
})
|
|
}
|
|
|
|
function redirectWithState(params: { notice?: string; error?: string }) {
|
|
const query = new URLSearchParams()
|
|
|
|
if (params.notice) {
|
|
query.set("notice", params.notice)
|
|
}
|
|
|
|
if (params.error) {
|
|
query.set("error", params.error)
|
|
}
|
|
|
|
const value = query.toString()
|
|
redirect(value ? `/?${value}` : "/")
|
|
}
|
|
|
|
async function getDashboardTranslator() {
|
|
const locale = await resolveAdminLocale()
|
|
const messages = await getAdminMessages(locale)
|
|
|
|
return (key: string, fallback: string) => translateMessage(messages, key, fallback)
|
|
}
|
|
|
|
async function createPostAction(formData: FormData) {
|
|
"use server"
|
|
|
|
await requireNewsWritePermission()
|
|
const t = await getDashboardTranslator()
|
|
|
|
const status = readRequiredField(formData, "status")
|
|
|
|
try {
|
|
await createPost({
|
|
title: readRequiredField(formData, "title"),
|
|
slug: readRequiredField(formData, "slug"),
|
|
excerpt: readOptionalField(formData, "excerpt"),
|
|
body: readRequiredField(formData, "body"),
|
|
status: status === "published" ? "published" : "draft",
|
|
})
|
|
} catch {
|
|
redirectWithState({
|
|
error: t("dashboard.posts.errors.createFailed", "Create failed. Please check your input."),
|
|
})
|
|
}
|
|
|
|
revalidatePath("/")
|
|
redirectWithState({ notice: t("dashboard.posts.success.created", "Post created.") })
|
|
}
|
|
|
|
async function updatePostAction(formData: FormData) {
|
|
"use server"
|
|
|
|
await requireNewsWritePermission()
|
|
const t = await getDashboardTranslator()
|
|
|
|
const id = readRequiredField(formData, "id")
|
|
const status = readRequiredField(formData, "status")
|
|
|
|
if (!id) {
|
|
redirectWithState({
|
|
error: t("dashboard.posts.errors.updateMissingId", "Update failed. Missing post id."),
|
|
})
|
|
}
|
|
|
|
try {
|
|
await updatePost(id, {
|
|
title: readRequiredField(formData, "title"),
|
|
slug: readRequiredField(formData, "slug"),
|
|
excerpt: readOptionalField(formData, "excerpt"),
|
|
body: readRequiredField(formData, "body"),
|
|
status: status === "published" ? "published" : "draft",
|
|
})
|
|
} catch {
|
|
redirectWithState({
|
|
error: t("dashboard.posts.errors.updateFailed", "Update failed. Please check your input."),
|
|
})
|
|
}
|
|
|
|
revalidatePath("/")
|
|
redirectWithState({ notice: t("dashboard.posts.success.updated", "Post updated.") })
|
|
}
|
|
|
|
async function deletePostAction(formData: FormData) {
|
|
"use server"
|
|
|
|
await requireNewsWritePermission()
|
|
const t = await getDashboardTranslator()
|
|
|
|
const id = readRequiredField(formData, "id")
|
|
|
|
if (!id) {
|
|
redirectWithState({
|
|
error: t("dashboard.posts.errors.deleteMissingId", "Delete failed. Missing post id."),
|
|
})
|
|
}
|
|
|
|
try {
|
|
await deletePost(id)
|
|
} catch {
|
|
redirectWithState({ error: t("dashboard.posts.errors.deleteFailed", "Delete failed.") })
|
|
}
|
|
|
|
revalidatePath("/")
|
|
redirectWithState({ notice: t("dashboard.posts.success.deleted", "Post deleted.") })
|
|
}
|
|
|
|
export default async function AdminHomePage({
|
|
searchParams,
|
|
}: {
|
|
searchParams: Promise<SearchParamsInput>
|
|
}) {
|
|
const role = await requirePermissionForRoute({
|
|
nextPath: "/",
|
|
permission: "news:read",
|
|
scope: "team",
|
|
})
|
|
|
|
const [resolvedSearchParams, locale, posts] = await Promise.all([
|
|
searchParams,
|
|
resolveAdminLocale(),
|
|
listPosts(),
|
|
])
|
|
const messages = await getAdminMessages(locale)
|
|
const t = (key: string, fallback: string) => translateMessage(messages, key, fallback)
|
|
|
|
const notice = readFirstValue(resolvedSearchParams.notice)
|
|
const error = readFirstValue(resolvedSearchParams.error)
|
|
const canCreatePost = hasPermission(role, "news:write", "team")
|
|
|
|
return (
|
|
<AdminShell
|
|
role={role}
|
|
activePath="/"
|
|
badge={t("dashboard.badge", "Admin App")}
|
|
title={t("dashboard.title", "Content Dashboard")}
|
|
description={t("dashboard.description", "Manage posts from a dedicated admin surface.")}
|
|
actions={
|
|
<>
|
|
<Link
|
|
href="/todo"
|
|
className="inline-flex rounded-md border border-neutral-300 px-4 py-2 text-sm font-medium hover:bg-neutral-100"
|
|
>
|
|
{t("dashboard.actions.openRoadmap", "Open roadmap and progress")}
|
|
</Link>
|
|
<Link
|
|
href="/settings"
|
|
className="inline-flex rounded-md border border-neutral-300 px-4 py-2 text-sm font-medium hover:bg-neutral-100"
|
|
>
|
|
{t("settings.title", "Settings")}
|
|
</Link>
|
|
</>
|
|
}
|
|
>
|
|
{notice ? (
|
|
<section className="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
|
|
{notice}
|
|
</section>
|
|
) : null}
|
|
|
|
{error ? (
|
|
<section className="rounded-xl border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800">
|
|
{error}
|
|
</section>
|
|
) : null}
|
|
|
|
<section className="rounded-xl border border-neutral-200 p-6">
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-xl font-medium">
|
|
{t("dashboard.posts.title", "Posts CRUD Sandbox")}
|
|
</h2>
|
|
<p className="text-xs uppercase tracking-wide text-neutral-500">
|
|
{t("dashboard.notices.crudSandboxTag", "MVP0 functional test")}
|
|
</p>
|
|
</div>
|
|
|
|
{canCreatePost ? (
|
|
<form
|
|
action={createPostAction}
|
|
className="space-y-3 rounded-lg border border-neutral-200 p-4"
|
|
>
|
|
<h3 className="text-sm font-semibold">
|
|
{t("dashboard.posts.createTitle", "Create post")}
|
|
</h3>
|
|
<div className="grid gap-3 md:grid-cols-2">
|
|
<label className="space-y-1">
|
|
<span className="text-xs text-neutral-600">
|
|
{t("dashboard.posts.fields.title", "Title")}
|
|
</span>
|
|
<input
|
|
name="title"
|
|
required
|
|
minLength={3}
|
|
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
|
/>
|
|
</label>
|
|
<label className="space-y-1">
|
|
<span className="text-xs text-neutral-600">
|
|
{t("dashboard.posts.fields.slug", "Slug")}
|
|
</span>
|
|
<input
|
|
name="slug"
|
|
required
|
|
minLength={3}
|
|
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
|
/>
|
|
</label>
|
|
</div>
|
|
<label className="space-y-1">
|
|
<span className="text-xs text-neutral-600">
|
|
{t("dashboard.posts.fields.excerpt", "Excerpt")}
|
|
</span>
|
|
<input
|
|
name="excerpt"
|
|
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
|
/>
|
|
</label>
|
|
<label className="space-y-1">
|
|
<span className="text-xs text-neutral-600">
|
|
{t("dashboard.posts.fields.body", "Body")}
|
|
</span>
|
|
<textarea
|
|
name="body"
|
|
required
|
|
minLength={1}
|
|
rows={4}
|
|
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
|
/>
|
|
</label>
|
|
<label className="space-y-1">
|
|
<span className="text-xs text-neutral-600">
|
|
{t("dashboard.posts.fields.status", "Status")}
|
|
</span>
|
|
<select
|
|
name="status"
|
|
defaultValue="draft"
|
|
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
|
>
|
|
<option value="draft">{t("dashboard.posts.status.draft", "Draft")}</option>
|
|
<option value="published">
|
|
{t("dashboard.posts.status.published", "Published")}
|
|
</option>
|
|
</select>
|
|
</label>
|
|
<Button type="submit">{t("dashboard.posts.actions.create", "Create post")}</Button>
|
|
</form>
|
|
) : (
|
|
<div className="rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
|
{t(
|
|
"dashboard.notices.noCrudPermission",
|
|
"You can read posts, but your role cannot create/update/delete posts.",
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
{posts.map((post) => (
|
|
<article key={post.id} className="rounded-lg border border-neutral-200 p-4">
|
|
{canCreatePost ? (
|
|
<>
|
|
<form action={updatePostAction} className="space-y-3">
|
|
<input type="hidden" name="id" value={post.id} />
|
|
<div className="grid gap-3 md:grid-cols-2">
|
|
<label className="space-y-1">
|
|
<span className="text-xs text-neutral-600">
|
|
{t("dashboard.posts.fields.title", "Title")}
|
|
</span>
|
|
<input
|
|
name="title"
|
|
required
|
|
minLength={3}
|
|
defaultValue={post.title}
|
|
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
|
/>
|
|
</label>
|
|
<label className="space-y-1">
|
|
<span className="text-xs text-neutral-600">
|
|
{t("dashboard.posts.fields.slug", "Slug")}
|
|
</span>
|
|
<input
|
|
name="slug"
|
|
required
|
|
minLength={3}
|
|
defaultValue={post.slug}
|
|
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
|
/>
|
|
</label>
|
|
</div>
|
|
<label className="space-y-1">
|
|
<span className="text-xs text-neutral-600">
|
|
{t("dashboard.posts.fields.excerpt", "Excerpt")}
|
|
</span>
|
|
<input
|
|
name="excerpt"
|
|
defaultValue={post.excerpt ?? ""}
|
|
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
|
/>
|
|
</label>
|
|
<label className="space-y-1">
|
|
<span className="text-xs text-neutral-600">
|
|
{t("dashboard.posts.fields.body", "Body")}
|
|
</span>
|
|
<textarea
|
|
name="body"
|
|
required
|
|
minLength={1}
|
|
rows={4}
|
|
defaultValue={post.body}
|
|
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
|
/>
|
|
</label>
|
|
<label className="space-y-1">
|
|
<span className="text-xs text-neutral-600">
|
|
{t("dashboard.posts.fields.status", "Status")}
|
|
</span>
|
|
<select
|
|
name="status"
|
|
defaultValue={post.status}
|
|
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
|
>
|
|
<option value="draft">{t("dashboard.posts.status.draft", "Draft")}</option>
|
|
<option value="published">
|
|
{t("dashboard.posts.status.published", "Published")}
|
|
</option>
|
|
</select>
|
|
</label>
|
|
<Button type="submit">
|
|
{t("dashboard.posts.actions.save", "Save changes")}
|
|
</Button>
|
|
</form>
|
|
<form action={deletePostAction} className="mt-3">
|
|
<input type="hidden" name="id" value={post.id} />
|
|
<Button type="submit" variant="secondary">
|
|
{t("dashboard.posts.actions.delete", "Delete")}
|
|
</Button>
|
|
</form>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<h3 className="text-lg font-medium">{post.title}</h3>
|
|
<span className="rounded-full bg-neutral-100 px-3 py-1 text-xs uppercase tracking-wide">
|
|
{post.status}
|
|
</span>
|
|
</div>
|
|
<p className="mt-2 text-sm text-neutral-600">{post.slug}</p>
|
|
<p className="mt-2 text-sm text-neutral-600">
|
|
{post.excerpt ?? t("dashboard.posts.fallback.noExcerpt", "No excerpt")}
|
|
</p>
|
|
</>
|
|
)}
|
|
</article>
|
|
))}
|
|
</div>
|
|
</section>
|
|
</AdminShell>
|
|
)
|
|
}
|