feat(web): render cms pages and navigation from db

This commit is contained in:
2026-02-12 19:58:01 +01:00
parent 281b1d7a1b
commit f65a9ea03f
11 changed files with 273 additions and 75 deletions

View File

@@ -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"

View File

@@ -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: [],
},
])
})
})

View File

@@ -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<PublicNavigationItem[]> {
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)