Files
cms.fellies.org/apps/admin/src/app/page.tsx

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