feat(pages): add pages and navigation builder baseline
This commit is contained in:
7
TODO.md
7
TODO.md
@@ -122,7 +122,7 @@ This file is the single source of truth for roadmap and delivery progress.
|
||||
media model, artwork entity, grouping primitives (gallery/album/category/tag), rendition slots
|
||||
- [~] [P1] `todo/mvp1-media-upload-pipeline`:
|
||||
S3/local upload adapter, media processing presets, metadata input flows, admin media CRUD UI
|
||||
- [ ] [P1] `todo/mvp1-pages-navigation-builder`:
|
||||
- [~] [P1] `todo/mvp1-pages-navigation-builder`:
|
||||
page CRUD, navigation tree, reusable page blocks (forms/price cards/gallery embeds)
|
||||
- [ ] [P1] `todo/mvp1-commissions-customers`:
|
||||
commission request intake + admin CRUD + kanban + customer entity/linking
|
||||
@@ -144,9 +144,9 @@ This file is the single source of truth for roadmap and delivery progress.
|
||||
|
||||
### Admin App (Primary Focus)
|
||||
|
||||
- [ ] [P1] Page management (create/edit/publish/unpublish/schedule)
|
||||
- [~] [P1] Page management (create/edit/publish/unpublish/schedule)
|
||||
- [ ] [P1] Page builder with reusable content blocks (hero, rich text, gallery, CTA, forms, price cards)
|
||||
- [ ] [P1] Navigation management (menus, nested items, order, visibility)
|
||||
- [~] [P1] Navigation management (menus, nested items, order, visibility)
|
||||
- [~] [P1] Media library (upload, browse, replace, delete) with media-type classification (artwork, banner, promo, generic, video/gif)
|
||||
- [ ] [P1] Media enrichment metadata (alt text, copyright, author, source, tags, licensing, usage context)
|
||||
- [ ] [P1] Portfolio grouping primitives (galleries, albums, categories, tags) with ordering/visibility controls
|
||||
@@ -274,6 +274,7 @@ This file is the single source of truth for roadmap and delivery progress.
|
||||
- [2026-02-12] Upload storage is now provider-based (`local` + `s3`) via `CMS_MEDIA_STORAGE_PROVIDER`; admin-side GUI toggle remains a later MVP item.
|
||||
- [2026-02-12] Media storage keys now use asset-centric layout (`tenant/<id>/asset/<assetId>/<fileRole>/<assetId>__<variant>.<ext>`) with DB-managed media taxonomy.
|
||||
- [2026-02-12] Admin media CRUD now includes list-to-detail flow (`/media/:id`) with metadata edit and delete actions.
|
||||
- [2026-02-12] MVP1 pages/navigation baseline started: `Page`, `NavigationMenu`, and `NavigationItem` models plus admin CRUD routes (`/pages`, `/pages/:id`, `/navigation`).
|
||||
|
||||
## How We Use This File
|
||||
|
||||
|
||||
446
apps/admin/src/app/navigation/page.tsx
Normal file
446
apps/admin/src/app/navigation/page.tsx
Normal file
@@ -0,0 +1,446 @@
|
||||
import {
|
||||
createNavigationItem,
|
||||
createNavigationMenu,
|
||||
deleteNavigationItem,
|
||||
listNavigationMenus,
|
||||
listPages,
|
||||
updateNavigationItem,
|
||||
} from "@cms/db"
|
||||
import { Button } from "@cms/ui/button"
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import { AdminShell } from "@/components/admin-shell"
|
||||
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 readInputString(formData: FormData, field: string): string {
|
||||
const value = formData.get(field)
|
||||
return typeof value === "string" ? value.trim() : ""
|
||||
}
|
||||
|
||||
function readNullableString(formData: FormData, field: string): string | null {
|
||||
const value = readInputString(formData, field)
|
||||
return value.length > 0 ? value : null
|
||||
}
|
||||
|
||||
function readInt(formData: FormData, field: string, fallback = 0): number {
|
||||
const value = readInputString(formData, field)
|
||||
|
||||
if (!value) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
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 ? `/navigation?${value}` : "/navigation")
|
||||
}
|
||||
|
||||
async function createMenuAction(formData: FormData) {
|
||||
"use server"
|
||||
|
||||
await requirePermissionForRoute({
|
||||
nextPath: "/navigation",
|
||||
permission: "navigation:write",
|
||||
scope: "team",
|
||||
})
|
||||
|
||||
try {
|
||||
await createNavigationMenu({
|
||||
name: readInputString(formData, "name"),
|
||||
slug: readInputString(formData, "slug"),
|
||||
location: readInputString(formData, "location"),
|
||||
isVisible: readInputString(formData, "isVisible") === "true",
|
||||
})
|
||||
} catch {
|
||||
redirectWithState({ error: "Failed to create navigation menu." })
|
||||
}
|
||||
|
||||
revalidatePath("/navigation")
|
||||
redirectWithState({ notice: "Navigation menu created." })
|
||||
}
|
||||
|
||||
async function createItemAction(formData: FormData) {
|
||||
"use server"
|
||||
|
||||
await requirePermissionForRoute({
|
||||
nextPath: "/navigation",
|
||||
permission: "navigation:write",
|
||||
scope: "team",
|
||||
})
|
||||
|
||||
try {
|
||||
await createNavigationItem({
|
||||
menuId: readInputString(formData, "menuId"),
|
||||
label: readInputString(formData, "label"),
|
||||
href: readNullableString(formData, "href"),
|
||||
pageId: readNullableString(formData, "pageId"),
|
||||
parentId: readNullableString(formData, "parentId"),
|
||||
sortOrder: readInt(formData, "sortOrder", 0),
|
||||
isVisible: readInputString(formData, "isVisible") === "true",
|
||||
})
|
||||
} catch {
|
||||
redirectWithState({ error: "Failed to create navigation item." })
|
||||
}
|
||||
|
||||
revalidatePath("/navigation")
|
||||
redirectWithState({ notice: "Navigation item created." })
|
||||
}
|
||||
|
||||
async function updateItemAction(formData: FormData) {
|
||||
"use server"
|
||||
|
||||
await requirePermissionForRoute({
|
||||
nextPath: "/navigation",
|
||||
permission: "navigation:write",
|
||||
scope: "team",
|
||||
})
|
||||
|
||||
try {
|
||||
await updateNavigationItem({
|
||||
id: readInputString(formData, "id"),
|
||||
label: readInputString(formData, "label"),
|
||||
href: readNullableString(formData, "href"),
|
||||
pageId: readNullableString(formData, "pageId"),
|
||||
parentId: readNullableString(formData, "parentId"),
|
||||
sortOrder: readInt(formData, "sortOrder", 0),
|
||||
isVisible: readInputString(formData, "isVisible") === "true",
|
||||
})
|
||||
} catch {
|
||||
redirectWithState({ error: "Failed to update navigation item." })
|
||||
}
|
||||
|
||||
revalidatePath("/navigation")
|
||||
redirectWithState({ notice: "Navigation item updated." })
|
||||
}
|
||||
|
||||
async function deleteItemAction(formData: FormData) {
|
||||
"use server"
|
||||
|
||||
await requirePermissionForRoute({
|
||||
nextPath: "/navigation",
|
||||
permission: "navigation:write",
|
||||
scope: "team",
|
||||
})
|
||||
|
||||
try {
|
||||
await deleteNavigationItem(readInputString(formData, "id"))
|
||||
} catch {
|
||||
redirectWithState({ error: "Failed to delete navigation item." })
|
||||
}
|
||||
|
||||
revalidatePath("/navigation")
|
||||
redirectWithState({ notice: "Navigation item deleted." })
|
||||
}
|
||||
|
||||
export default async function NavigationManagementPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParamsInput>
|
||||
}) {
|
||||
const role = await requirePermissionForRoute({
|
||||
nextPath: "/navigation",
|
||||
permission: "navigation:read",
|
||||
scope: "team",
|
||||
})
|
||||
|
||||
const [resolvedSearchParams, menus, pages] = await Promise.all([
|
||||
searchParams,
|
||||
listNavigationMenus(),
|
||||
listPages(200),
|
||||
])
|
||||
|
||||
const notice = readFirstValue(resolvedSearchParams.notice)
|
||||
const error = readFirstValue(resolvedSearchParams.error)
|
||||
|
||||
return (
|
||||
<AdminShell
|
||||
role={role}
|
||||
activePath="/navigation"
|
||||
badge="Admin App"
|
||||
title="Navigation"
|
||||
description="Manage menus and navigation entries linked to pages or custom routes."
|
||||
>
|
||||
{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="grid gap-4 lg:grid-cols-2">
|
||||
<article className="rounded-xl border border-neutral-200 p-6">
|
||||
<h2 className="text-xl font-medium">Create Menu</h2>
|
||||
<form action={createMenuAction} className="mt-4 space-y-3">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Name</span>
|
||||
<input
|
||||
name="name"
|
||||
required
|
||||
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">Slug</span>
|
||||
<input
|
||||
name="slug"
|
||||
required
|
||||
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">Location</span>
|
||||
<input
|
||||
name="location"
|
||||
defaultValue="primary"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-2 text-sm text-neutral-700">
|
||||
<input
|
||||
name="isVisible"
|
||||
type="checkbox"
|
||||
value="true"
|
||||
defaultChecked
|
||||
className="size-4"
|
||||
/>
|
||||
Visible
|
||||
</label>
|
||||
<Button type="submit">Create menu</Button>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
<article className="rounded-xl border border-neutral-200 p-6">
|
||||
<h2 className="text-xl font-medium">Create Navigation Item</h2>
|
||||
<form action={createItemAction} className="mt-4 space-y-3">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Menu</span>
|
||||
<select
|
||||
name="menuId"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
>
|
||||
{menus.map((menu) => (
|
||||
<option key={menu.id} value={menu.id}>
|
||||
{menu.name} ({menu.location})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Label</span>
|
||||
<input
|
||||
name="label"
|
||||
required
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Custom href</span>
|
||||
<input
|
||||
name="href"
|
||||
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">Linked page</span>
|
||||
<select
|
||||
name="pageId"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">(none)</option>
|
||||
{pages.map((page) => (
|
||||
<option key={page.id} value={page.id}>
|
||||
{page.title} (/{page.slug})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Parent item id</span>
|
||||
<input
|
||||
name="parentId"
|
||||
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">Sort order</span>
|
||||
<input
|
||||
name="sortOrder"
|
||||
defaultValue="0"
|
||||
type="number"
|
||||
min={0}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="inline-flex items-center gap-2 text-sm text-neutral-700">
|
||||
<input
|
||||
name="isVisible"
|
||||
type="checkbox"
|
||||
value="true"
|
||||
defaultChecked
|
||||
className="size-4"
|
||||
/>
|
||||
Visible
|
||||
</label>
|
||||
<Button type="submit">Create item</Button>
|
||||
</form>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
{menus.length === 0 ? (
|
||||
<article className="rounded-xl border border-neutral-200 p-6 text-sm text-neutral-600">
|
||||
No navigation menus yet.
|
||||
</article>
|
||||
) : (
|
||||
menus.map((menu) => (
|
||||
<article key={menu.id} className="rounded-xl border border-neutral-200 p-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="text-lg font-medium">
|
||||
{menu.name} <span className="text-sm text-neutral-500">({menu.location})</span>
|
||||
</h3>
|
||||
<span className="text-xs text-neutral-500">
|
||||
{menu.isVisible ? "visible" : "hidden"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
{menu.items.length === 0 ? (
|
||||
<p className="text-sm text-neutral-600">No items in this menu.</p>
|
||||
) : (
|
||||
menu.items.map((item) => (
|
||||
<form
|
||||
key={item.id}
|
||||
action={updateItemAction}
|
||||
className="rounded-lg border border-neutral-200 p-3"
|
||||
>
|
||||
<input type="hidden" name="id" value={item.id} />
|
||||
<div className="grid gap-3 md:grid-cols-5">
|
||||
<label className="space-y-1 md:col-span-2">
|
||||
<span className="text-xs text-neutral-600">Label</span>
|
||||
<input
|
||||
name="label"
|
||||
defaultValue={item.label}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1 md:col-span-2">
|
||||
<span className="text-xs text-neutral-600">Href</span>
|
||||
<input
|
||||
name="href"
|
||||
defaultValue={item.href ?? ""}
|
||||
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">Sort</span>
|
||||
<input
|
||||
name="sortOrder"
|
||||
type="number"
|
||||
min={0}
|
||||
defaultValue={item.sortOrder}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Linked page</span>
|
||||
<select
|
||||
name="pageId"
|
||||
defaultValue={item.pageId ?? ""}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">(none)</option>
|
||||
{pages.map((page) => (
|
||||
<option key={page.id} value={page.id}>
|
||||
{page.title} (/{page.slug})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Parent id</span>
|
||||
<input
|
||||
name="parentId"
|
||||
defaultValue={item.parentId ?? ""}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<label className="inline-flex items-center gap-2 text-sm text-neutral-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="isVisible"
|
||||
value="true"
|
||||
defaultChecked={item.isVisible}
|
||||
className="size-4"
|
||||
/>
|
||||
Visible
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="submit" size="sm">
|
||||
Save item
|
||||
</Button>
|
||||
<button
|
||||
type="submit"
|
||||
formAction={deleteItemAction}
|
||||
className="rounded-md border border-red-300 px-3 py-2 text-sm text-red-700"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
</AdminShell>
|
||||
)
|
||||
}
|
||||
242
apps/admin/src/app/pages/[id]/page.tsx
Normal file
242
apps/admin/src/app/pages/[id]/page.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
import { deletePage, getPageById, updatePage } from "@cms/db"
|
||||
import { Button } from "@cms/ui/button"
|
||||
import Link from "next/link"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import { AdminShell } from "@/components/admin-shell"
|
||||
import { requirePermissionForRoute } from "@/lib/route-guards"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
type SearchParamsInput = Record<string, string | string[] | undefined>
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ id: string }>
|
||||
searchParams: Promise<SearchParamsInput>
|
||||
}
|
||||
|
||||
function readFirstValue(value: string | string[] | undefined): string | null {
|
||||
if (Array.isArray(value)) {
|
||||
return value[0] ?? null
|
||||
}
|
||||
|
||||
return value ?? null
|
||||
}
|
||||
|
||||
function readInputString(formData: FormData, field: string): string {
|
||||
const value = formData.get(field)
|
||||
return typeof value === "string" ? value.trim() : ""
|
||||
}
|
||||
|
||||
function readNullableString(formData: FormData, field: string): string | null {
|
||||
const value = readInputString(formData, field)
|
||||
return value.length > 0 ? value : null
|
||||
}
|
||||
|
||||
function redirectWithState(pageId: string, 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 ? `/pages/${pageId}?${value}` : `/pages/${pageId}`)
|
||||
}
|
||||
|
||||
export default async function PageEditorPage({ params, searchParams }: PageProps) {
|
||||
const role = await requirePermissionForRoute({
|
||||
nextPath: "/pages",
|
||||
permission: "pages:read",
|
||||
scope: "team",
|
||||
})
|
||||
const resolvedParams = await params
|
||||
const pageId = resolvedParams.id
|
||||
|
||||
const [resolvedSearchParams, pageRecord] = await Promise.all([searchParams, getPageById(pageId)])
|
||||
|
||||
if (!pageRecord) {
|
||||
redirect("/pages?error=Page+not+found")
|
||||
}
|
||||
|
||||
const page = pageRecord
|
||||
const notice = readFirstValue(resolvedSearchParams.notice)
|
||||
const error = readFirstValue(resolvedSearchParams.error)
|
||||
|
||||
async function updatePageAction(formData: FormData) {
|
||||
"use server"
|
||||
|
||||
await requirePermissionForRoute({
|
||||
nextPath: "/pages",
|
||||
permission: "pages:write",
|
||||
scope: "team",
|
||||
})
|
||||
|
||||
try {
|
||||
await updatePage({
|
||||
id: pageId,
|
||||
title: readInputString(formData, "title"),
|
||||
slug: readInputString(formData, "slug"),
|
||||
status: readInputString(formData, "status"),
|
||||
summary: readNullableString(formData, "summary"),
|
||||
content: readInputString(formData, "content"),
|
||||
seoTitle: readNullableString(formData, "seoTitle"),
|
||||
seoDescription: readNullableString(formData, "seoDescription"),
|
||||
})
|
||||
} catch {
|
||||
redirectWithState(pageId, {
|
||||
error: "Failed to update page. Validate values and try again.",
|
||||
})
|
||||
}
|
||||
|
||||
redirectWithState(pageId, {
|
||||
notice: "Page updated.",
|
||||
})
|
||||
}
|
||||
|
||||
async function deletePageAction() {
|
||||
"use server"
|
||||
|
||||
await requirePermissionForRoute({
|
||||
nextPath: "/pages",
|
||||
permission: "pages:write",
|
||||
scope: "team",
|
||||
})
|
||||
|
||||
try {
|
||||
await deletePage(pageId)
|
||||
} catch {
|
||||
redirectWithState(pageId, {
|
||||
error: "Failed to delete page. Remove linked navigation references first.",
|
||||
})
|
||||
}
|
||||
|
||||
redirect("/pages?notice=Page+deleted")
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminShell
|
||||
role={role}
|
||||
activePath="/pages"
|
||||
badge="Admin App"
|
||||
title="Page Editor"
|
||||
description="Edit page metadata, content, and publication status."
|
||||
>
|
||||
{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="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-xl font-medium">{page.title}</h2>
|
||||
<p className="mt-1 text-xs text-neutral-600">ID: {page.id}</p>
|
||||
</div>
|
||||
<Link href="/pages" className="text-sm text-neutral-700 underline underline-offset-2">
|
||||
Back to pages
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<form action={updatePageAction} className="mt-6 space-y-3">
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<label className="space-y-1 md:col-span-2">
|
||||
<span className="text-xs text-neutral-600">Title</span>
|
||||
<input
|
||||
name="title"
|
||||
defaultValue={page.title}
|
||||
required
|
||||
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">Status</span>
|
||||
<select
|
||||
name="status"
|
||||
defaultValue={page.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>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Slug</span>
|
||||
<input
|
||||
name="slug"
|
||||
defaultValue={page.slug}
|
||||
required
|
||||
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">Summary</span>
|
||||
<input
|
||||
name="summary"
|
||||
defaultValue={page.summary ?? ""}
|
||||
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">Content</span>
|
||||
<textarea
|
||||
name="content"
|
||||
rows={10}
|
||||
defaultValue={page.content}
|
||||
required
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">SEO title</span>
|
||||
<input
|
||||
name="seoTitle"
|
||||
defaultValue={page.seoTitle ?? ""}
|
||||
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">SEO description</span>
|
||||
<input
|
||||
name="seoDescription"
|
||||
defaultValue={page.seoDescription ?? ""}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Button type="submit">Save page</Button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-red-300 bg-red-50 p-6">
|
||||
<h3 className="text-lg font-medium text-red-800">Danger Zone</h3>
|
||||
<p className="mt-1 text-sm text-red-700">
|
||||
Deleting this page is permanent and may break linked navigation items.
|
||||
</p>
|
||||
<form action={deletePageAction} className="mt-4">
|
||||
<Button type="submit" variant="secondary" className="border border-red-300 text-red-800">
|
||||
Delete page
|
||||
</Button>
|
||||
</form>
|
||||
</section>
|
||||
</AdminShell>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +1,92 @@
|
||||
import { AdminSectionPlaceholder } from "@/components/admin-section-placeholder"
|
||||
import { createPage, listPages } 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 { requirePermissionForRoute } from "@/lib/route-guards"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function PagesManagementPage() {
|
||||
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 readInputString(formData: FormData, field: string): string {
|
||||
const value = formData.get(field)
|
||||
return typeof value === "string" ? value.trim() : ""
|
||||
}
|
||||
|
||||
function readNullableString(formData: FormData, field: string): string | null {
|
||||
const value = readInputString(formData, field)
|
||||
return value.length > 0 ? value : null
|
||||
}
|
||||
|
||||
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 ? `/pages?${value}` : "/pages")
|
||||
}
|
||||
|
||||
async function createPageAction(formData: FormData) {
|
||||
"use server"
|
||||
|
||||
await requirePermissionForRoute({
|
||||
nextPath: "/pages",
|
||||
permission: "pages:write",
|
||||
scope: "team",
|
||||
})
|
||||
|
||||
try {
|
||||
await createPage({
|
||||
title: readInputString(formData, "title"),
|
||||
slug: readInputString(formData, "slug"),
|
||||
status: readInputString(formData, "status"),
|
||||
summary: readNullableString(formData, "summary"),
|
||||
content: readInputString(formData, "content"),
|
||||
seoTitle: readNullableString(formData, "seoTitle"),
|
||||
seoDescription: readNullableString(formData, "seoDescription"),
|
||||
})
|
||||
} catch {
|
||||
redirectWithState({
|
||||
error: "Failed to create page. Validate slug/title/content and try again.",
|
||||
})
|
||||
}
|
||||
|
||||
revalidatePath("/pages")
|
||||
revalidatePath("/navigation")
|
||||
redirectWithState({ notice: "Page created." })
|
||||
}
|
||||
|
||||
export default async function PagesManagementPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParamsInput>
|
||||
}) {
|
||||
const role = await requirePermissionForRoute({
|
||||
nextPath: "/pages",
|
||||
permission: "pages:read",
|
||||
scope: "team",
|
||||
})
|
||||
const [resolvedSearchParams, pages] = await Promise.all([searchParams, listPages(100)])
|
||||
const notice = readFirstValue(resolvedSearchParams.notice)
|
||||
const error = readFirstValue(resolvedSearchParams.error)
|
||||
|
||||
return (
|
||||
<AdminShell
|
||||
@@ -17,18 +94,137 @@ export default async function PagesManagementPage() {
|
||||
activePath="/pages"
|
||||
badge="Admin App"
|
||||
title="Pages"
|
||||
description="Manage page entities and publication workflows."
|
||||
description="Create, update, and manage published page entities."
|
||||
>
|
||||
<AdminSectionPlaceholder
|
||||
feature="Page Management"
|
||||
summary="This MVP0 scaffold defines information architecture and access boundaries for future page CRUD."
|
||||
requiredPermission="pages:read (team)"
|
||||
nextSteps={[
|
||||
"Add page entity list and search.",
|
||||
"Add create/edit draft flows with validation.",
|
||||
"Add publish/unpublish scheduling controls.",
|
||||
]}
|
||||
{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">
|
||||
<h2 className="text-xl font-medium">Create Page</h2>
|
||||
<form action={createPageAction} className="mt-4 space-y-3">
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<label className="space-y-1 md:col-span-2">
|
||||
<span className="text-xs text-neutral-600">Title</span>
|
||||
<input
|
||||
name="title"
|
||||
required
|
||||
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">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>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Slug</span>
|
||||
<input
|
||||
name="slug"
|
||||
required
|
||||
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">Summary</span>
|
||||
<input
|
||||
name="summary"
|
||||
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">Content</span>
|
||||
<textarea
|
||||
name="content"
|
||||
rows={6}
|
||||
required
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">SEO title</span>
|
||||
<input
|
||||
name="seoTitle"
|
||||
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">SEO description</span>
|
||||
<input
|
||||
name="seoDescription"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Button type="submit">Create page</Button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-neutral-200 p-6">
|
||||
<h2 className="text-xl font-medium">Pages</h2>
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<table className="min-w-full text-left text-sm">
|
||||
<thead className="text-xs uppercase tracking-wide text-neutral-500">
|
||||
<tr>
|
||||
<th className="py-2 pr-4">Title</th>
|
||||
<th className="py-2 pr-4">Slug</th>
|
||||
<th className="py-2 pr-4">Status</th>
|
||||
<th className="py-2 pr-4">Updated</th>
|
||||
<th className="py-2 pr-4">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pages.length === 0 ? (
|
||||
<tr>
|
||||
<td className="py-3 text-neutral-500" colSpan={5}>
|
||||
No pages yet.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
pages.map((page) => (
|
||||
<tr key={page.id} className="border-t border-neutral-200">
|
||||
<td className="py-3 pr-4">{page.title}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">/{page.slug}</td>
|
||||
<td className="py-3 pr-4">{page.status}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">
|
||||
{page.updatedAt.toLocaleDateString("en-US")}
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
<Link
|
||||
href={`/pages/${page.id}`}
|
||||
className="text-xs font-medium text-neutral-700 underline underline-offset-2"
|
||||
>
|
||||
Open
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</AdminShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ type NavItem = {
|
||||
const navItems: NavItem[] = [
|
||||
{ href: "/", label: "Dashboard", permission: "dashboard:read", scope: "global" },
|
||||
{ href: "/pages", label: "Pages", permission: "pages:read", scope: "team" },
|
||||
{ href: "/navigation", label: "Navigation", permission: "navigation:read", scope: "team" },
|
||||
{ href: "/media", label: "Media", permission: "media:read", scope: "team" },
|
||||
{ href: "/portfolio", label: "Portfolio", permission: "media:read", scope: "team" },
|
||||
{ href: "/users", label: "Users", permission: "users:read", scope: "own" },
|
||||
|
||||
@@ -27,6 +27,10 @@ describe("admin route access rules", () => {
|
||||
permission: "pages:read",
|
||||
scope: "team",
|
||||
})
|
||||
expect(getRequiredPermission("/navigation")).toEqual({
|
||||
permission: "navigation:read",
|
||||
scope: "team",
|
||||
})
|
||||
expect(getRequiredPermission("/media")).toEqual({
|
||||
permission: "media:read",
|
||||
scope: "team",
|
||||
|
||||
@@ -50,6 +50,13 @@ const guardRules: GuardRule[] = [
|
||||
scope: "team",
|
||||
},
|
||||
},
|
||||
{
|
||||
route: /^\/navigation(?:\/|$)/,
|
||||
requirement: {
|
||||
permission: "navigation:read",
|
||||
scope: "team",
|
||||
},
|
||||
},
|
||||
{
|
||||
route: /^\/media(?:\/|$)/,
|
||||
requirement: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export * from "./media"
|
||||
export * from "./pages-navigation"
|
||||
export * from "./rbac"
|
||||
|
||||
export const postStatusSchema = z.enum(["draft", "published"])
|
||||
|
||||
57
packages/content/src/pages-navigation.ts
Normal file
57
packages/content/src/pages-navigation.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const pageStatusSchema = z.enum(["draft", "published"])
|
||||
|
||||
export const createPageInputSchema = z.object({
|
||||
title: z.string().min(1).max(180),
|
||||
slug: z.string().min(1).max(180),
|
||||
status: pageStatusSchema.default("draft"),
|
||||
summary: z.string().max(500).nullable().optional(),
|
||||
content: z.string().min(1),
|
||||
seoTitle: z.string().max(180).nullable().optional(),
|
||||
seoDescription: z.string().max(320).nullable().optional(),
|
||||
})
|
||||
|
||||
export const updatePageInputSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
title: z.string().min(1).max(180).optional(),
|
||||
slug: z.string().min(1).max(180).optional(),
|
||||
status: pageStatusSchema.optional(),
|
||||
summary: z.string().max(500).nullable().optional(),
|
||||
content: z.string().min(1).optional(),
|
||||
seoTitle: z.string().max(180).nullable().optional(),
|
||||
seoDescription: z.string().max(320).nullable().optional(),
|
||||
})
|
||||
|
||||
export const createNavigationMenuInputSchema = z.object({
|
||||
name: z.string().min(1).max(180),
|
||||
slug: z.string().min(1).max(180),
|
||||
location: z.string().min(1).max(80).default("primary"),
|
||||
isVisible: z.boolean().default(true),
|
||||
})
|
||||
|
||||
export const createNavigationItemInputSchema = z.object({
|
||||
menuId: z.string().uuid(),
|
||||
label: z.string().min(1).max(180),
|
||||
href: z.string().max(500).nullable().optional(),
|
||||
pageId: z.string().uuid().nullable().optional(),
|
||||
parentId: z.string().uuid().nullable().optional(),
|
||||
sortOrder: z.number().int().min(0).default(0),
|
||||
isVisible: z.boolean().default(true),
|
||||
})
|
||||
|
||||
export const updateNavigationItemInputSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
label: z.string().min(1).max(180).optional(),
|
||||
href: z.string().max(500).nullable().optional(),
|
||||
pageId: z.string().uuid().nullable().optional(),
|
||||
parentId: z.string().uuid().nullable().optional(),
|
||||
sortOrder: z.number().int().min(0).optional(),
|
||||
isVisible: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export type CreatePageInput = z.infer<typeof createPageInputSchema>
|
||||
export type UpdatePageInput = z.infer<typeof updatePageInputSchema>
|
||||
export type CreateNavigationMenuInput = z.infer<typeof createNavigationMenuInputSchema>
|
||||
export type CreateNavigationItemInput = z.infer<typeof createNavigationItemInputSchema>
|
||||
export type UpdateNavigationItemInput = z.infer<typeof updateNavigationItemInputSchema>
|
||||
@@ -0,0 +1,75 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Page" (
|
||||
"id" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL,
|
||||
"summary" TEXT,
|
||||
"content" TEXT NOT NULL,
|
||||
"seoTitle" TEXT,
|
||||
"seoDescription" TEXT,
|
||||
"publishedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Page_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "NavigationMenu" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"location" TEXT NOT NULL,
|
||||
"isVisible" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "NavigationMenu_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "NavigationItem" (
|
||||
"id" TEXT NOT NULL,
|
||||
"menuId" TEXT NOT NULL,
|
||||
"pageId" TEXT,
|
||||
"label" TEXT NOT NULL,
|
||||
"href" TEXT,
|
||||
"parentId" TEXT,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"isVisible" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "NavigationItem_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Page_slug_key" ON "Page"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Page_status_idx" ON "Page"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "NavigationMenu_slug_key" ON "NavigationMenu"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "NavigationItem_menuId_idx" ON "NavigationItem"("menuId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "NavigationItem_pageId_idx" ON "NavigationItem"("pageId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "NavigationItem_parentId_idx" ON "NavigationItem"("parentId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "NavigationItem_menuId_parentId_sortOrder_label_key" ON "NavigationItem"("menuId", "parentId", "sortOrder", "label");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "NavigationItem" ADD CONSTRAINT "NavigationItem_menuId_fkey" FOREIGN KEY ("menuId") REFERENCES "NavigationMenu"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "NavigationItem" ADD CONSTRAINT "NavigationItem_pageId_fkey" FOREIGN KEY ("pageId") REFERENCES "Page"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "NavigationItem" ADD CONSTRAINT "NavigationItem_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "NavigationItem"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -252,3 +252,53 @@ model ArtworkTag {
|
||||
@@unique([artworkId, tagId])
|
||||
@@index([tagId])
|
||||
}
|
||||
|
||||
model Page {
|
||||
id String @id @default(uuid())
|
||||
title String
|
||||
slug String @unique
|
||||
status String
|
||||
summary String?
|
||||
content String
|
||||
seoTitle String?
|
||||
seoDescription String?
|
||||
publishedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
navItems NavigationItem[]
|
||||
|
||||
@@index([status])
|
||||
}
|
||||
|
||||
model NavigationMenu {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
slug String @unique
|
||||
location String
|
||||
isVisible Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
items NavigationItem[]
|
||||
}
|
||||
|
||||
model NavigationItem {
|
||||
id String @id @default(uuid())
|
||||
menuId String
|
||||
pageId String?
|
||||
label String
|
||||
href String?
|
||||
parentId String?
|
||||
sortOrder Int @default(0)
|
||||
isVisible Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
menu NavigationMenu @relation(fields: [menuId], references: [id], onDelete: Cascade)
|
||||
page Page? @relation(fields: [pageId], references: [id], onDelete: SetNull)
|
||||
parent NavigationItem? @relation("NavigationItemParent", fields: [parentId], references: [id], onDelete: Cascade)
|
||||
children NavigationItem[] @relation("NavigationItemParent")
|
||||
|
||||
@@index([menuId])
|
||||
@@index([pageId])
|
||||
@@index([parentId])
|
||||
@@unique([menuId, parentId, sortOrder, label])
|
||||
}
|
||||
|
||||
@@ -95,6 +95,69 @@ async function main() {
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const homePage = await db.page.upsert({
|
||||
where: { slug: "home" },
|
||||
update: {},
|
||||
create: {
|
||||
title: "Home",
|
||||
slug: "home",
|
||||
status: "published",
|
||||
summary: "Default homepage seeded for pages/navigation baseline.",
|
||||
content: "Welcome to your new artist CMS homepage.",
|
||||
seoTitle: "Home",
|
||||
seoDescription: "Seeded homepage",
|
||||
publishedAt: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
const primaryMenu = await db.navigationMenu.upsert({
|
||||
where: { slug: "primary" },
|
||||
update: {},
|
||||
create: {
|
||||
name: "Primary",
|
||||
slug: "primary",
|
||||
location: "header",
|
||||
isVisible: true,
|
||||
},
|
||||
})
|
||||
|
||||
const existingHomeItem = await db.navigationItem.findFirst({
|
||||
where: {
|
||||
menuId: primaryMenu.id,
|
||||
parentId: null,
|
||||
sortOrder: 0,
|
||||
label: "Home",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (existingHomeItem) {
|
||||
await db.navigationItem.update({
|
||||
where: {
|
||||
id: existingHomeItem.id,
|
||||
},
|
||||
data: {
|
||||
pageId: homePage.id,
|
||||
href: "/",
|
||||
isVisible: true,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
await db.navigationItem.create({
|
||||
data: {
|
||||
menuId: primaryMenu.id,
|
||||
label: "Home",
|
||||
href: "/",
|
||||
pageId: homePage.id,
|
||||
parentId: null,
|
||||
sortOrder: 0,
|
||||
isVisible: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
@@ -16,6 +16,18 @@ export {
|
||||
listMediaFoundationGroups,
|
||||
updateMediaAsset,
|
||||
} from "./media-foundation"
|
||||
export {
|
||||
createNavigationItem,
|
||||
createNavigationMenu,
|
||||
createPage,
|
||||
deleteNavigationItem,
|
||||
deletePage,
|
||||
getPageById,
|
||||
listNavigationMenus,
|
||||
listPages,
|
||||
updateNavigationItem,
|
||||
updatePage,
|
||||
} from "./pages-navigation"
|
||||
export {
|
||||
createPost,
|
||||
deletePost,
|
||||
|
||||
92
packages/db/src/pages-navigation.test.ts
Normal file
92
packages/db/src/pages-navigation.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
const { mockDb } = vi.hoisted(() => ({
|
||||
mockDb: {
|
||||
page: {
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
navigationMenu: {
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
navigationItem: {
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("./client", () => ({
|
||||
db: mockDb,
|
||||
}))
|
||||
|
||||
import {
|
||||
createNavigationItem,
|
||||
createNavigationMenu,
|
||||
createPage,
|
||||
updatePage,
|
||||
} from "./pages-navigation"
|
||||
|
||||
describe("pages-navigation service", () => {
|
||||
beforeEach(() => {
|
||||
for (const value of Object.values(mockDb)) {
|
||||
for (const fn of Object.values(value)) {
|
||||
if (typeof fn === "function") {
|
||||
fn.mockReset()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it("creates published pages with publishedAt", async () => {
|
||||
mockDb.page.create.mockResolvedValue({ id: "page-1" })
|
||||
|
||||
await createPage({
|
||||
title: "About",
|
||||
slug: "about",
|
||||
status: "published",
|
||||
content: "hello",
|
||||
})
|
||||
|
||||
expect(mockDb.page.create).toHaveBeenCalledTimes(1)
|
||||
expect(mockDb.page.create.mock.calls[0]?.[0].data.publishedAt).toBeInstanceOf(Date)
|
||||
})
|
||||
|
||||
it("updates page status publication timestamp", async () => {
|
||||
mockDb.page.update.mockResolvedValue({ id: "page-1" })
|
||||
|
||||
await updatePage({
|
||||
id: "550e8400-e29b-41d4-a716-446655440000",
|
||||
status: "draft",
|
||||
})
|
||||
|
||||
expect(mockDb.page.update).toHaveBeenCalledTimes(1)
|
||||
expect(mockDb.page.update.mock.calls[0]?.[0].data.publishedAt).toBeNull()
|
||||
})
|
||||
|
||||
it("creates menus and items with schema parsing", async () => {
|
||||
mockDb.navigationMenu.create.mockResolvedValue({ id: "menu-1" })
|
||||
mockDb.navigationItem.create.mockResolvedValue({ id: "item-1" })
|
||||
|
||||
await createNavigationMenu({
|
||||
name: "Primary",
|
||||
slug: "primary",
|
||||
location: "header",
|
||||
})
|
||||
|
||||
await createNavigationItem({
|
||||
menuId: "550e8400-e29b-41d4-a716-446655440001",
|
||||
label: "Home",
|
||||
href: "/",
|
||||
sortOrder: 0,
|
||||
})
|
||||
|
||||
expect(mockDb.navigationMenu.create).toHaveBeenCalledTimes(1)
|
||||
expect(mockDb.navigationItem.create).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
109
packages/db/src/pages-navigation.ts
Normal file
109
packages/db/src/pages-navigation.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
createNavigationItemInputSchema,
|
||||
createNavigationMenuInputSchema,
|
||||
createPageInputSchema,
|
||||
updateNavigationItemInputSchema,
|
||||
updatePageInputSchema,
|
||||
} from "@cms/content"
|
||||
|
||||
import { db } from "./client"
|
||||
|
||||
function resolvePublishedAt(status: string): Date | null {
|
||||
return status === "published" ? new Date() : null
|
||||
}
|
||||
|
||||
export async function listPages(limit = 50) {
|
||||
return db.page.findMany({
|
||||
orderBy: [{ updatedAt: "desc" }],
|
||||
take: limit,
|
||||
})
|
||||
}
|
||||
|
||||
export async function getPageById(id: string) {
|
||||
return db.page.findUnique({
|
||||
where: { id },
|
||||
})
|
||||
}
|
||||
|
||||
export async function createPage(input: unknown) {
|
||||
const payload = createPageInputSchema.parse(input)
|
||||
|
||||
return db.page.create({
|
||||
data: {
|
||||
...payload,
|
||||
publishedAt: resolvePublishedAt(payload.status),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function updatePage(input: unknown) {
|
||||
const payload = updatePageInputSchema.parse(input)
|
||||
const { id, ...data } = payload
|
||||
|
||||
return db.page.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...data,
|
||||
publishedAt:
|
||||
data.status === undefined ? undefined : data.status === "published" ? new Date() : null,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function deletePage(id: string) {
|
||||
return db.page.delete({
|
||||
where: { id },
|
||||
})
|
||||
}
|
||||
|
||||
export async function listNavigationMenus() {
|
||||
return db.navigationMenu.findMany({
|
||||
orderBy: [{ location: "asc" }, { name: "asc" }],
|
||||
include: {
|
||||
items: {
|
||||
orderBy: [{ sortOrder: "asc" }, { label: "asc" }],
|
||||
include: {
|
||||
page: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function createNavigationMenu(input: unknown) {
|
||||
const payload = createNavigationMenuInputSchema.parse(input)
|
||||
|
||||
return db.navigationMenu.create({
|
||||
data: payload,
|
||||
})
|
||||
}
|
||||
|
||||
export async function createNavigationItem(input: unknown) {
|
||||
const payload = createNavigationItemInputSchema.parse(input)
|
||||
|
||||
return db.navigationItem.create({
|
||||
data: payload,
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateNavigationItem(input: unknown) {
|
||||
const payload = updateNavigationItemInputSchema.parse(input)
|
||||
const { id, ...data } = payload
|
||||
|
||||
return db.navigationItem.update({
|
||||
where: { id },
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteNavigationItem(id: string) {
|
||||
return db.navigationItem.delete({
|
||||
where: { id },
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user