diff --git a/TODO.md b/TODO.md index ea28945..bc13ab8 100644 --- a/TODO.md +++ b/TODO.md @@ -128,7 +128,7 @@ This file is the single source of truth for roadmap and delivery progress. commission request intake + admin CRUD + kanban + customer entity/linking - [ ] [P1] `todo/mvp1-announcements-news`: announcement management/rendering + news/blog CRUD and public rendering -- [ ] [P1] `todo/mvp1-public-rendering-integration`: +- [~] [P1] `todo/mvp1-public-rendering-integration`: public rendering for pages/navigation/media/portfolio/announcements and commissioning entrypoints - [ ] [P1] `todo/mvp1-e2e-happy-paths`: end-to-end scenarios for page publish, media flow, announcement display, commission flow @@ -166,8 +166,8 @@ This file is the single source of truth for roadmap and delivery progress. ### Public App -- [ ] [P1] Dynamic page rendering from CMS page entities -- [ ] [P1] Navigation rendering from managed menu structure +- [~] [P1] Dynamic page rendering from CMS page entities +- [~] [P1] Navigation rendering from managed menu structure - [ ] [P1] Media entity rendering with enrichment data - [ ] [P1] Portfolio views (gallery/album/category/tag) for artworks with filter and sort controls - [ ] [P1] Rendition-aware media delivery (thumbnail/card/full) per template slot @@ -275,6 +275,7 @@ This file is the single source of truth for roadmap and delivery progress. - [2026-02-12] Media storage keys now use asset-centric layout (`tenant//asset///__.`) 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`). +- [2026-02-12] Public app now renders CMS-managed navigation (header) and CMS-managed pages by slug (including homepage when `home` page exists). ## How We Use This File diff --git a/apps/web/src/app/[locale]/[slug]/page.tsx b/apps/web/src/app/[locale]/[slug]/page.tsx new file mode 100644 index 0000000..b72a8c5 --- /dev/null +++ b/apps/web/src/app/[locale]/[slug]/page.tsx @@ -0,0 +1,21 @@ +import { getPublishedPageBySlug } from "@cms/db" +import { notFound } from "next/navigation" + +import { PublicPageView } from "@/components/public-page-view" + +export const dynamic = "force-dynamic" + +type PageProps = { + params: Promise<{ slug: string }> +} + +export default async function CmsPageRoute({ params }: PageProps) { + const { slug } = await params + const page = await getPublishedPageBySlug(slug) + + if (!page) { + notFound() + } + + return +} diff --git a/apps/web/src/app/[locale]/about/page.tsx b/apps/web/src/app/[locale]/about/page.tsx deleted file mode 100644 index da3118f..0000000 --- a/apps/web/src/app/[locale]/about/page.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { getTranslations } from "next-intl/server" - -export default async function AboutPage() { - const t = await getTranslations("About") - - return ( -
-

{t("badge")}

-

{t("title")}

-

{t("description")}

-
- ) -} diff --git a/apps/web/src/app/[locale]/contact/page.tsx b/apps/web/src/app/[locale]/contact/page.tsx deleted file mode 100644 index 8970263..0000000 --- a/apps/web/src/app/[locale]/contact/page.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { getTranslations } from "next-intl/server" - -export default async function ContactPage() { - const t = await getTranslations("Contact") - - return ( -
-

{t("badge")}

-

{t("title")}

-

{t("description")}

-
- ) -} diff --git a/apps/web/src/app/[locale]/page.tsx b/apps/web/src/app/[locale]/page.tsx index 1b52219..b5ad33f 100644 --- a/apps/web/src/app/[locale]/page.tsx +++ b/apps/web/src/app/[locale]/page.tsx @@ -1,35 +1,45 @@ -import { listPosts } from "@cms/db" +import { getPublishedPageBySlug, listPosts } from "@cms/db" import { Button } from "@cms/ui/button" import { getTranslations } from "next-intl/server" +import { PublicPageView } from "@/components/public-page-view" + export const dynamic = "force-dynamic" export default async function HomePage() { - const [posts, t] = await Promise.all([listPosts(), getTranslations("Home")]) + const [homePage, posts, t] = await Promise.all([ + getPublishedPageBySlug("home"), + listPosts(), + getTranslations("Home"), + ]) return ( -
-
-

{t("badge")}

-

{t("title")}

-

{t("description")}

-
+
+ {homePage ? : null} -
-
-

{t("latestPosts")}

- -
+
+
+

{t("badge")}

+

{t("latestPosts")}

+

{t("description")}

+
-
    - {posts.map((post) => ( -
  • -

    {post.status}

    -

    {post.title}

    -

    {post.excerpt ?? t("noExcerpt")}

    -
  • - ))} -
+
+
+

{t("latestPosts")}

+ +
+ +
    + {posts.map((post) => ( +
  • +

    {post.status}

    +

    {post.title}

    +

    {post.excerpt ?? t("noExcerpt")}

    +
  • + ))} +
+
) diff --git a/apps/web/src/app/sitemap.ts b/apps/web/src/app/sitemap.ts index 56de424..316af4a 100644 --- a/apps/web/src/app/sitemap.ts +++ b/apps/web/src/app/sitemap.ts @@ -1,14 +1,13 @@ +import { listPublishedPageSlugs } from "@cms/db" import type { MetadataRoute } from "next" const baseUrl = process.env.CMS_WEB_ORIGIN ?? "http://localhost:3000" -const publicRoutes = ["/", "/about", "/contact"] +export default async function sitemap(): Promise { + const pages = await listPublishedPageSlugs() -export default function sitemap(): MetadataRoute.Sitemap { - const now = new Date() - - return publicRoutes.map((route) => ({ - url: `${baseUrl}${route}`, - lastModified: now, + return pages.map((page) => ({ + url: page.slug === "home" ? `${baseUrl}/` : `${baseUrl}/${page.slug}`, + lastModified: page.updatedAt, })) } diff --git a/apps/web/src/components/public-page-view.tsx b/apps/web/src/components/public-page-view.tsx new file mode 100644 index 0000000..34f49a4 --- /dev/null +++ b/apps/web/src/components/public-page-view.tsx @@ -0,0 +1,26 @@ +type PageEntity = { + title: string + status: string + summary: string | null + content: string +} + +type PublicPageViewProps = { + page: PageEntity +} + +export function PublicPageView({ page }: PublicPageViewProps) { + return ( +
+
+

{page.status}

+

{page.title}

+ {page.summary ?

{page.summary}

: null} +
+ +
+ {page.content} +
+
+ ) +} diff --git a/apps/web/src/components/public-site-header.tsx b/apps/web/src/components/public-site-header.tsx index cf5703d..7d869aa 100644 --- a/apps/web/src/components/public-site-header.tsx +++ b/apps/web/src/components/public-site-header.tsx @@ -1,19 +1,11 @@ -"use client" - -import { useTranslations } from "next-intl" +import { listPublicNavigation } from "@cms/db" import { Link } from "@/i18n/navigation" import { LanguageSwitcher } from "./language-switcher" -export function PublicSiteHeader() { - const t = useTranslations("Layout") - - const navItems = [ - { href: "/", label: t("nav.home") }, - { href: "/about", label: t("nav.about") }, - { href: "/contact", label: t("nav.contact") }, - ] +export async function PublicSiteHeader() { + const navItems = await listPublicNavigation("header") return (
@@ -22,19 +14,28 @@ export function PublicSiteHeader() { href="/" className="text-sm font-semibold uppercase tracking-[0.2em] text-neutral-700" > - {t("brand")} + CMS Web diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 3008b44..62c4260 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -16,6 +16,7 @@ export { listMediaFoundationGroups, updateMediaAsset, } from "./media-foundation" +export type { PublicNavigationItem } from "./pages-navigation" export { createNavigationItem, createNavigationMenu, @@ -23,8 +24,11 @@ export { deleteNavigationItem, deletePage, getPageById, + getPublishedPageBySlug, listNavigationMenus, listPages, + listPublicNavigation, + listPublishedPageSlugs, updateNavigationItem, updatePage, } from "./pages-navigation" diff --git a/packages/db/src/pages-navigation.test.ts b/packages/db/src/pages-navigation.test.ts index 1942a37..ac722dd 100644 --- a/packages/db/src/pages-navigation.test.ts +++ b/packages/db/src/pages-navigation.test.ts @@ -12,6 +12,7 @@ const { mockDb } = vi.hoisted(() => ({ navigationMenu: { create: vi.fn(), findMany: vi.fn(), + findFirst: vi.fn(), }, navigationItem: { create: vi.fn(), @@ -29,6 +30,7 @@ import { createNavigationItem, createNavigationMenu, createPage, + listPublicNavigation, updatePage, } from "./pages-navigation" @@ -89,4 +91,33 @@ describe("pages-navigation service", () => { expect(mockDb.navigationMenu.create).toHaveBeenCalledTimes(1) expect(mockDb.navigationItem.create).toHaveBeenCalledTimes(1) }) + + it("maps public navigation href from linked pages", async () => { + mockDb.navigationMenu.findFirst.mockResolvedValue({ + id: "menu-1", + items: [ + { + id: "item-1", + label: "Home", + href: null, + parentId: null, + page: { + slug: "home", + status: "published", + }, + }, + ], + }) + + const navigation = await listPublicNavigation("header") + + expect(navigation).toEqual([ + { + id: "item-1", + label: "Home", + href: "/", + children: [], + }, + ]) + }) }) diff --git a/packages/db/src/pages-navigation.ts b/packages/db/src/pages-navigation.ts index a1fdae8..fd5da5b 100644 --- a/packages/db/src/pages-navigation.ts +++ b/packages/db/src/pages-navigation.ts @@ -8,6 +8,13 @@ import { import { db } from "./client" +export type PublicNavigationItem = { + id: string + label: string + href: string + children: PublicNavigationItem[] +} + function resolvePublishedAt(status: string): Date | null { return status === "published" ? new Date() : null } @@ -19,12 +26,34 @@ export async function listPages(limit = 50) { }) } +export async function listPublishedPageSlugs() { + const pages = await db.page.findMany({ + where: { status: "published" }, + orderBy: { updatedAt: "desc" }, + select: { + slug: true, + updatedAt: true, + }, + }) + + return pages +} + export async function getPageById(id: string) { return db.page.findUnique({ where: { id }, }) } +export async function getPublishedPageBySlug(slug: string) { + return db.page.findFirst({ + where: { + slug, + status: "published", + }, + }) +} + export async function createPage(input: unknown) { const payload = createPageInputSchema.parse(input) @@ -76,6 +105,108 @@ export async function listNavigationMenus() { }) } +function resolveNavigationHref(item: { + href: string | null + page: { + slug: string + status: string + } | null +}): string | null { + if (item.href) { + return item.href + } + + if (item.page?.status === "published") { + return item.page.slug === "home" ? "/" : `/${item.page.slug}` + } + + return null +} + +export async function listPublicNavigation(location = "header"): Promise { + const menu = await db.navigationMenu.findFirst({ + where: { + location, + isVisible: true, + }, + orderBy: { updatedAt: "desc" }, + include: { + items: { + where: { + isVisible: true, + }, + orderBy: [{ sortOrder: "asc" }, { label: "asc" }], + include: { + page: { + select: { + slug: true, + status: true, + }, + }, + }, + }, + }, + }) + + if (!menu) { + return [] + } + + const itemMap = new Map< + string, + { + id: string + label: string + href: string + parentId: string | null + children: PublicNavigationItem[] + } + >() + + for (const item of menu.items) { + const href = resolveNavigationHref(item) + + if (!href) { + continue + } + + itemMap.set(item.id, { + id: item.id, + label: item.label, + href, + parentId: item.parentId, + children: [], + }) + } + + const roots: PublicNavigationItem[] = [] + + for (const entry of itemMap.values()) { + if (entry.parentId) { + const parent = itemMap.get(entry.parentId) + + if (parent) { + parent.children.push({ + id: entry.id, + label: entry.label, + href: entry.href, + children: entry.children, + }) + continue + } + } + + roots.push({ + id: entry.id, + label: entry.label, + href: entry.href, + children: entry.children, + }) + } + + return roots +} + export async function createNavigationMenu(input: unknown) { const payload = createNavigationMenuInputSchema.parse(input)