diff --git a/app/[locale]/(site)/portfolio/[slug]/page.tsx b/app/[locale]/(site)/portfolio/[slug]/page.tsx index cff548c..2ea58e0 100644 --- a/app/[locale]/(site)/portfolio/[slug]/page.tsx +++ b/app/[locale]/(site)/portfolio/[slug]/page.tsx @@ -4,16 +4,20 @@ import { notFound } from "next/navigation"; import { Container } from "@/components/layout/container"; import { PageHero } from "@/components/layout/page-hero"; +import { PortfolioCategoryFilter } from "@/components/site/portfolio-category-filter"; import { PortfolioProjectDetail } from "@/components/site/portfolio-project-detail"; +import { PortfolioProjectGrid } from "@/components/site/portfolio-project-grid"; import { getSiteSettings } from "@/lib/app-config"; import { buildLocalizedMetadata } from "@/lib/metadata"; import { resolveLocale } from "@/lib/locale"; import { + getActivePortfolioCategories, getLocalizedValue, - getPublishedPortfolioProjectBySlug, + getPublishedPortfolioProjects, + resolvePortfolioSlug, } from "@/lib/portfolio"; -type PortfolioItemPageProps = { +type PortfolioSlugPageProps = { params: Promise<{ locale: string; slug: string; @@ -22,41 +26,88 @@ type PortfolioItemPageProps = { export const dynamic = "force-dynamic"; -export async function generateMetadata({ params }: PortfolioItemPageProps): Promise { +export async function generateMetadata({ params }: PortfolioSlugPageProps): Promise { const { slug } = await params; const siteSettings = await getSiteSettings(); const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale); - const item = await getPublishedPortfolioProjectBySlug(slug); + const resolved = await resolvePortfolioSlug(slug); - if (!item) { + if (!resolved) { + const t = await getTranslations({ locale: localeKey, namespace: "portfolioPage" }); return await buildLocalizedMetadata({ locale: localeKey, pathname: `/portfolio/${slug}`, - title: "Portfolio", - description: "Portfolio item", + title: t("title"), + description: t("intro"), + }); + } + + if (resolved.kind === "category") { + const t = await getTranslations({ locale: localeKey, namespace: "portfolioPage" }); + return await buildLocalizedMetadata({ + locale: localeKey, + pathname: `/portfolio/${slug}`, + title: getLocalizedValue(resolved.category.name, localeKey), + description: getLocalizedValue(resolved.category.description, localeKey) || t("intro"), }); } return await buildLocalizedMetadata({ locale: localeKey, pathname: `/portfolio/${slug}`, - title: getLocalizedValue(item.title, localeKey), - description: getLocalizedValue(item.summary, localeKey), + title: getLocalizedValue(resolved.project.title, localeKey), + description: getLocalizedValue(resolved.project.summary, localeKey), }); } -export default async function PortfolioItemPage({ - params, -}: PortfolioItemPageProps) { +export default async function PortfolioSlugPage({ params }: PortfolioSlugPageProps) { const { slug } = await params; const siteSettings = await getSiteSettings(); const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale); - const item = await getPublishedPortfolioProjectBySlug(slug); + const resolved = await resolvePortfolioSlug(slug); - if (!item) { + if (!resolved) { notFound(); } + if (resolved.kind === "category") { + const { category } = resolved; + const [categories, projects] = await Promise.all([ + getActivePortfolioCategories(), + getPublishedPortfolioProjects({ categorySlug: slug }), + ]); + const t = await getTranslations({ locale: localeKey, namespace: "portfolioPage" }); + + return ( + <> + + + + + + + + ); + } + + const { project: item } = resolved; const t = await getTranslations({ locale: localeKey, namespace: "portfolioDetail" }); const title = getLocalizedValue(item.title, localeKey); const category = getLocalizedValue(item.category.name, localeKey); diff --git a/app/[locale]/(site)/portfolio/category/[slug]/page.tsx b/app/[locale]/(site)/portfolio/category/[slug]/page.tsx deleted file mode 100644 index 132b6b7..0000000 --- a/app/[locale]/(site)/portfolio/category/[slug]/page.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import type { Metadata } from "next"; -import { getLocale, getTranslations } from "next-intl/server"; -import { notFound } from "next/navigation"; - -import { Container } from "@/components/layout/container"; -import { PageHero } from "@/components/layout/page-hero"; -import { PortfolioCategoryFilter } from "@/components/site/portfolio-category-filter"; -import { PortfolioProjectGrid } from "@/components/site/portfolio-project-grid"; -import { getSiteSettings } from "@/lib/app-config"; -import { buildLocalizedMetadata } from "@/lib/metadata"; -import { resolveLocale } from "@/lib/locale"; -import { - getActivePortfolioCategories, - getActivePortfolioCategoryBySlug, - getLocalizedValue, - getPublishedPortfolioProjects, -} from "@/lib/portfolio"; - -type PortfolioCategoryPageProps = { - params: Promise<{ - locale: string; - slug: string; - }>; -}; - -export const dynamic = "force-dynamic"; - -export async function generateMetadata({ params }: PortfolioCategoryPageProps): Promise { - const { slug } = await params; - const siteSettings = await getSiteSettings(); - const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale); - const t = await getTranslations({ locale: localeKey, namespace: "portfolioPage" }); - const category = await getActivePortfolioCategoryBySlug(slug); - - if (!category) { - return await buildLocalizedMetadata({ - locale: localeKey, - pathname: `/portfolio/category/${slug}`, - title: t("title"), - description: t("intro"), - }); - } - - return await buildLocalizedMetadata({ - locale: localeKey, - pathname: `/portfolio/category/${slug}`, - title: getLocalizedValue(category.name, localeKey), - description: getLocalizedValue(category.description, localeKey) || t("intro"), - }); -} - -export default async function PortfolioCategoryPage({ - params, -}: PortfolioCategoryPageProps) { - const { slug } = await params; - const [siteSettings, categories, category, projects] = await Promise.all([ - getSiteSettings(), - getActivePortfolioCategories(), - getActivePortfolioCategoryBySlug(slug), - getPublishedPortfolioProjects({ categorySlug: slug }), - ]); - const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale); - const t = await getTranslations({ locale: localeKey, namespace: "portfolioPage" }); - - if (!category) { - notFound(); - } - - return ( - <> - - - - - - - - ); -} diff --git a/app/[locale]/(site)/portfolio/category/page.tsx b/app/[locale]/(site)/portfolio/category/page.tsx deleted file mode 100644 index b277400..0000000 --- a/app/[locale]/(site)/portfolio/category/page.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { getLocale } from "next-intl/server"; -import { permanentRedirect } from "next/navigation"; - -import { getSiteSettings } from "@/lib/app-config"; -import { getLocalizedPath, resolveLocale } from "@/lib/locale"; - -type PortfolioCategoryIndexPageProps = { - params: Promise<{ - locale: string; - }>; -}; - -export default async function PortfolioCategoryIndexPage({ - params, -}: PortfolioCategoryIndexPageProps) { - await params; - const siteSettings = await getSiteSettings(); - const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale); - - permanentRedirect(getLocalizedPath(localeKey, "/portfolio", siteSettings.defaultLocale)); -} diff --git a/app/[locale]/(site)/portfolio/page.tsx b/app/[locale]/(site)/portfolio/page.tsx index 4ef269f..a34c590 100644 --- a/app/[locale]/(site)/portfolio/page.tsx +++ b/app/[locale]/(site)/portfolio/page.tsx @@ -50,7 +50,7 @@ export default async function PortfolioPage({ const selectedCategory = resolvedSearchParams?.category ?? ""; if (selectedCategory) { - redirect(getLocalizedPath(localeKey, `/portfolio/category/${selectedCategory}`, siteSettings.defaultLocale)); + redirect(getLocalizedPath(localeKey, `/portfolio/${selectedCategory}`, siteSettings.defaultLocale)); } const [categories, projects] = await Promise.all([ diff --git a/app/sitemap.ts b/app/sitemap.ts index 8b1d204..35650b6 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -57,7 +57,7 @@ export default async function sitemap(): Promise { priority: 0.9, }), ...categories.flatMap((category) => - buildLocalizedEntries(`/portfolio/category/${category.slug}`, siteSettings.defaultLocale, { + buildLocalizedEntries(`/portfolio/${category.slug}`, siteSettings.defaultLocale, { changeFrequency: "weekly", priority: 0.8, }), diff --git a/components/site/portfolio-category-filter.tsx b/components/site/portfolio-category-filter.tsx index 9c70246..9e468e4 100644 --- a/components/site/portfolio-category-filter.tsx +++ b/components/site/portfolio-category-filter.tsx @@ -30,7 +30,7 @@ export function PortfolioCategoryFilter({ {categories.map((category) => ( diff --git a/lib/portfolio.ts b/lib/portfolio.ts index 4030beb..518fd96 100644 --- a/lib/portfolio.ts +++ b/lib/portfolio.ts @@ -285,6 +285,30 @@ export const getPublishedPortfolioProjectBySlug = cache(async function (slug: st return mapProject(project); }); +export type ResolvedPortfolioSlug = + | { kind: "category"; category: PortfolioCategoryView } + | { kind: "project"; project: PortfolioProjectView } + | null; + +/** + * Resolves a `/portfolio/[slug]` segment to either a category or a project. + * Categories take precedence so `/portfolio/web` shows the category listing; + * a project slug only wins when no active category shares that slug. + */ +export async function resolvePortfolioSlug(slug: string): Promise { + const category = await getActivePortfolioCategoryBySlug(slug); + if (category) { + return { kind: "category", category }; + } + + const project = await getPublishedPortfolioProjectBySlug(slug); + if (project) { + return { kind: "project", project }; + } + + return null; +} + export async function getAdminPortfolioProjectById(id: string) { const project = await db.query.portfolioProject.findFirst({ where: eq(portfolioProject.id, id), diff --git a/tests/component/portfolio-category-filter.test.tsx b/tests/component/portfolio-category-filter.test.tsx index 615772d..e4b8171 100644 --- a/tests/component/portfolio-category-filter.test.tsx +++ b/tests/component/portfolio-category-filter.test.tsx @@ -35,7 +35,7 @@ describe("PortfolioCategoryFilter", () => { expect(screen.getByRole("link", { name: "All" })).toHaveAttribute("href", "/en/portfolio"); expect(screen.getByRole("link", { name: "Branding" })).toHaveAttribute( "href", - "/en/portfolio/category/branding", + "/en/portfolio/branding", ); }); @@ -51,7 +51,7 @@ describe("PortfolioCategoryFilter", () => { // German locale on the default locale -> unprefixed paths and German labels expect(screen.getByRole("link", { name: "Marke" })).toHaveAttribute( "href", - "/portfolio/category/branding", + "/portfolio/branding", ); expect(screen.getByRole("link", { name: "Alle" })).toHaveAttribute("href", "/portfolio"); }); diff --git a/tests/integration/portfolio.test.ts b/tests/integration/portfolio.test.ts index fdd8081..3a2b5f3 100644 --- a/tests/integration/portfolio.test.ts +++ b/tests/integration/portfolio.test.ts @@ -8,6 +8,7 @@ import { getAdminPortfolioProjects, getPublishedPortfolioProjectBySlug, getPublishedPortfolioProjects, + resolvePortfolioSlug, } from "@/lib/portfolio"; import { eq } from "drizzle-orm"; @@ -124,6 +125,44 @@ describe("published projects", () => { }); }); +describe("resolvePortfolioSlug", () => { + it("resolves an active category slug to a category", async () => { + await createCategory({ slug: "web", isActive: true }); + const resolved = await resolvePortfolioSlug("web"); + expect(resolved?.kind).toBe("category"); + expect(resolved?.kind === "category" && resolved.category.slug).toBe("web"); + }); + + it("resolves a published project slug to a project", async () => { + const cat = await createCategory({ isActive: true }); + await createProject({ categoryId: cat.id, slug: "my-project", isPublished: true }); + const resolved = await resolvePortfolioSlug("my-project"); + expect(resolved?.kind).toBe("project"); + expect(resolved?.kind === "project" && resolved.project.slug).toBe("my-project"); + }); + + it("prefers the category when a category and a project share a slug", async () => { + const cat = await createCategory({ slug: "shared", isActive: true }); + await createProject({ categoryId: cat.id, slug: "shared", isPublished: true }); + const resolved = await resolvePortfolioSlug("shared"); + expect(resolved?.kind).toBe("category"); + }); + + it("ignores an inactive category and falls back to a matching project", async () => { + // An inactive category named "hidden" must not shadow a published project "hidden". + await createCategory({ slug: "hidden", isActive: false }); + const activeCat = await createCategory({ isActive: true }); + await createProject({ categoryId: activeCat.id, slug: "hidden", isPublished: true }); + const resolved = await resolvePortfolioSlug("hidden"); + expect(resolved?.kind).toBe("project"); + expect(resolved?.kind === "project" && resolved.project.slug).toBe("hidden"); + }); + + it("returns null for an unknown slug", async () => { + expect(await resolvePortfolioSlug("does-not-exist")).toBeNull(); + }); +}); + describe("referential integrity", () => { it("restricts deleting a category that still has projects", async () => { const cat = await createCategory();