feat(admin-i18n): add cookie-based locale runtime and switcher baseline

This commit is contained in:
2026-02-10 20:56:03 +01:00
parent 07e5f53793
commit b618c8cb51
18 changed files with 931 additions and 156 deletions

View File

@@ -5,6 +5,9 @@ import { revalidatePath } from "next/cache"
import Link from "next/link"
import { redirect } from "next/navigation"
import { AdminLocaleSwitcher } from "@/components/admin-locale-switcher"
import { translateMessage } from "@/i18n/messages"
import { getAdminMessages, resolveAdminLocale } from "@/i18n/server"
import { resolveRoleFromServerContext } from "@/lib/access-server"
import { LogoutButton } from "./logout-button"
@@ -58,10 +61,18 @@ function redirectWithState(params: { notice?: string; error?: string }) {
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")
@@ -74,23 +85,28 @@ async function createPostAction(formData: FormData) {
status: status === "published" ? "published" : "draft",
})
} catch {
redirectWithState({ error: "Create failed. Please check your input." })
redirectWithState({
error: t("dashboard.posts.errors.createFailed", "Create failed. Please check your input."),
})
}
revalidatePath("/")
redirectWithState({ notice: "Post created." })
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: "Update failed. Missing post id." })
redirectWithState({
error: t("dashboard.posts.errors.updateMissingId", "Update failed. Missing post id."),
})
}
try {
@@ -102,32 +118,37 @@ async function updatePostAction(formData: FormData) {
status: status === "published" ? "published" : "draft",
})
} catch {
redirectWithState({ error: "Update failed. Please check your input." })
redirectWithState({
error: t("dashboard.posts.errors.updateFailed", "Update failed. Please check your input."),
})
}
revalidatePath("/")
redirectWithState({ notice: "Post updated." })
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: "Delete failed. Missing post id." })
redirectWithState({
error: t("dashboard.posts.errors.deleteMissingId", "Delete failed. Missing post id."),
})
}
try {
await deletePost(id)
} catch {
redirectWithState({ error: "Delete failed." })
redirectWithState({ error: t("dashboard.posts.errors.deleteFailed", "Delete failed.") })
}
revalidatePath("/")
redirectWithState({ notice: "Post deleted." })
redirectWithState({ notice: t("dashboard.posts.success.deleted", "Post deleted.") })
}
export default async function AdminHomePage({
@@ -145,24 +166,39 @@ export default async function AdminHomePage({
redirect("/unauthorized?required=news:read&scope=team")
}
const resolvedSearchParams = await searchParams
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")
const posts = await listPosts()
return (
<main className="mx-auto flex min-h-screen w-full max-w-4xl flex-col gap-8 px-6 py-16">
<header className="space-y-3">
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">Admin App</p>
<h1 className="text-4xl font-semibold tracking-tight">Content Dashboard</h1>
<p className="text-neutral-600">Manage posts from a dedicated admin surface.</p>
<div className="flex items-center justify-between gap-3">
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">
{t("dashboard.badge", "Admin App")}
</p>
<AdminLocaleSwitcher />
</div>
<h1 className="text-4xl font-semibold tracking-tight">
{t("dashboard.title", "Content Dashboard")}
</h1>
<p className="text-neutral-600">
{t("dashboard.description", "Manage posts from a dedicated admin surface.")}
</p>
<div className="flex items-center gap-3 pt-2">
<Link
href="/todo"
className="inline-flex rounded-md border border-neutral-300 px-4 py-2 text-sm font-medium hover:bg-neutral-100"
>
Open roadmap and progress
{t("dashboard.actions.openRoadmap", "Open roadmap and progress")}
</Link>
<LogoutButton />
</div>
@@ -183,8 +219,12 @@ export default async function AdminHomePage({
<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">Posts CRUD Sandbox</h2>
<p className="text-xs uppercase tracking-wide text-neutral-500">MVP0 functional test</p>
<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 ? (
@@ -192,10 +232,14 @@ export default async function AdminHomePage({
action={createPostAction}
className="space-y-3 rounded-lg border border-neutral-200 p-4"
>
<h3 className="text-sm font-semibold">Create post</h3>
<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">Title</span>
<span className="text-xs text-neutral-600">
{t("dashboard.posts.fields.title", "Title")}
</span>
<input
name="title"
required
@@ -204,7 +248,9 @@ export default async function AdminHomePage({
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Slug</span>
<span className="text-xs text-neutral-600">
{t("dashboard.posts.fields.slug", "Slug")}
</span>
<input
name="slug"
required
@@ -214,14 +260,18 @@ export default async function AdminHomePage({
</label>
</div>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Excerpt</span>
<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">Body</span>
<span className="text-xs text-neutral-600">
{t("dashboard.posts.fields.body", "Body")}
</span>
<textarea
name="body"
required
@@ -231,21 +281,28 @@ export default async function AdminHomePage({
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Status</span>
<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">Draft</option>
<option value="published">Published</option>
<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">Create post</Button>
<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">
You can read posts, but your role cannot create/update/delete posts.
{t(
"dashboard.notices.noCrudPermission",
"You can read posts, but your role cannot create/update/delete posts.",
)}
</div>
)}
</div>
@@ -259,7 +316,9 @@ export default async function AdminHomePage({
<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">Title</span>
<span className="text-xs text-neutral-600">
{t("dashboard.posts.fields.title", "Title")}
</span>
<input
name="title"
required
@@ -269,7 +328,9 @@ export default async function AdminHomePage({
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Slug</span>
<span className="text-xs text-neutral-600">
{t("dashboard.posts.fields.slug", "Slug")}
</span>
<input
name="slug"
required
@@ -280,7 +341,9 @@ export default async function AdminHomePage({
</label>
</div>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Excerpt</span>
<span className="text-xs text-neutral-600">
{t("dashboard.posts.fields.excerpt", "Excerpt")}
</span>
<input
name="excerpt"
defaultValue={post.excerpt ?? ""}
@@ -288,7 +351,9 @@ export default async function AdminHomePage({
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Body</span>
<span className="text-xs text-neutral-600">
{t("dashboard.posts.fields.body", "Body")}
</span>
<textarea
name="body"
required
@@ -299,22 +364,28 @@ export default async function AdminHomePage({
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Status</span>
<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">Draft</option>
<option value="published">Published</option>
<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">Save changes</Button>
<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">
Delete
{t("dashboard.posts.actions.delete", "Delete")}
</Button>
</form>
</>
@@ -327,7 +398,9 @@ export default async function AdminHomePage({
</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 ?? "No excerpt"}</p>
<p className="mt-2 text-sm text-neutral-600">
{post.excerpt ?? t("dashboard.posts.fallback.noExcerpt", "No excerpt")}
</p>
</>
)}
</article>