From 64976d6e123c0af2d4719d8c024f949b236383ae Mon Sep 17 00:00:00 2001 From: MOH Date: Wed, 11 Mar 2026 23:13:36 +0100 Subject: [PATCH] Add sitemap and portfolio category SEO routes --- .../(site)/portfolio/category/[slug]/page.tsx | 91 +++++++++++++++++ .../(site)/portfolio/category/page.tsx | 17 ++++ app/[locale]/(site)/portfolio/page.tsx | 98 ++++--------------- app/robots.ts | 3 + app/sitemap.ts | 85 ++++++++++++++++ components/site/portfolio-category-filter.tsx | 69 +++++++++++++ components/site/portfolio-project-grid.tsx | 67 +++++++++++++ lib/portfolio.ts | 11 +++ 8 files changed, 360 insertions(+), 81 deletions(-) create mode 100644 app/[locale]/(site)/portfolio/category/[slug]/page.tsx create mode 100644 app/[locale]/(site)/portfolio/category/page.tsx create mode 100644 app/sitemap.ts create mode 100644 components/site/portfolio-category-filter.tsx create mode 100644 components/site/portfolio-project-grid.tsx diff --git a/app/[locale]/(site)/portfolio/category/[slug]/page.tsx b/app/[locale]/(site)/portfolio/category/[slug]/page.tsx new file mode 100644 index 0000000..2bc0d4c --- /dev/null +++ b/app/[locale]/(site)/portfolio/category/[slug]/page.tsx @@ -0,0 +1,91 @@ +import type { Metadata } from "next"; +import { 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 { buildLocalizedMetadata } from "@/lib/metadata"; +import { resolveLocale } from "@/lib/locale"; +import { + getActivePortfolioCategories, + getActivePortfolioCategoryBySlug, + getLocalizedValue, + getPublishedPortfolioProjects, +} from "@/lib/portfolio"; + +type PortfolioCategoryPageProps = { + params: { + locale: string; + slug: string; + }; +}; + +export const dynamic = "force-dynamic"; + +export async function generateMetadata({ + params: { locale, slug }, +}: PortfolioCategoryPageProps): Promise { + const localeKey = resolveLocale(locale); + 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: { locale, slug }, +}: PortfolioCategoryPageProps) { + const localeKey = resolveLocale(locale); + const t = await getTranslations({ locale: localeKey, namespace: "portfolioPage" }); + const [categories, category, projects] = await Promise.all([ + getActivePortfolioCategories(), + getActivePortfolioCategoryBySlug(slug), + getPublishedPortfolioProjects({ categorySlug: slug }), + ]); + + if (!category) { + notFound(); + } + + return ( + <> + + + + + + + + ); +} diff --git a/app/[locale]/(site)/portfolio/category/page.tsx b/app/[locale]/(site)/portfolio/category/page.tsx new file mode 100644 index 0000000..fe31562 --- /dev/null +++ b/app/[locale]/(site)/portfolio/category/page.tsx @@ -0,0 +1,17 @@ +import { permanentRedirect } from "next/navigation"; + +import { getLocalizedPath, resolveLocale } from "@/lib/locale"; + +type PortfolioCategoryIndexPageProps = { + params: { + locale: string; + }; +}; + +export default function PortfolioCategoryIndexPage({ + params: { locale }, +}: PortfolioCategoryIndexPageProps) { + const localeKey = resolveLocale(locale); + + permanentRedirect(getLocalizedPath(localeKey, "/portfolio")); +} diff --git a/app/[locale]/(site)/portfolio/page.tsx b/app/[locale]/(site)/portfolio/page.tsx index c15e1ac..b8796c7 100644 --- a/app/[locale]/(site)/portfolio/page.tsx +++ b/app/[locale]/(site)/portfolio/page.tsx @@ -1,20 +1,14 @@ import type { Metadata } from "next"; -import { ArrowUpRight, CalendarDays } from "lucide-react"; -import Link from "next/link"; import { getTranslations } from "next-intl/server"; +import { redirect } from "next/navigation"; import { Container } from "@/components/layout/container"; import { PageHero } from "@/components/layout/page-hero"; -import { MotionFade } from "@/components/motion-fade"; +import { PortfolioCategoryFilter } from "@/components/site/portfolio-category-filter"; +import { PortfolioProjectGrid } from "@/components/site/portfolio-project-grid"; import { buildLocalizedMetadata } from "@/lib/metadata"; -import { AppCard } from "@/components/ui/app-card"; -import { CardContent } from "@/components/ui/card"; import { getLocalizedPath, resolveLocale } from "@/lib/locale"; -import { - getActivePortfolioCategories, - getLocalizedValue, - getPublishedPortfolioProjects, -} from "@/lib/portfolio"; +import { getActivePortfolioCategories, getPublishedPortfolioProjects } from "@/lib/portfolio"; type PortfolioPageProps = { params: { @@ -48,11 +42,14 @@ export default async function PortfolioPage({ const localeKey = resolveLocale(locale); const t = await getTranslations({ locale: localeKey, namespace: "portfolioPage" }); const selectedCategory = searchParams?.category ?? ""; + + if (selectedCategory) { + redirect(getLocalizedPath(localeKey, `/portfolio/category/${selectedCategory}`)); + } + const [categories, projects] = await Promise.all([ getActivePortfolioCategories(), - getPublishedPortfolioProjects({ - categorySlug: selectedCategory || undefined, - }), + getPublishedPortfolioProjects(), ]); return ( @@ -65,74 +62,13 @@ export default async function PortfolioPage({ /> -
- - {t("all")} - - {categories.map((category) => ( - - {getLocalizedValue(category.name, localeKey)} - - ))} -
- -
- {projects.map((item, index) => ( - - - - -
-

- {getLocalizedValue(item.category.name, localeKey)} -

-

- - {item.projectYear} -

-
-

- {getLocalizedValue(item.title, localeKey)} -

-

- {getLocalizedValue(item.summary, localeKey)} -

-

- {t("open")} - -

- -
-
-
- ))} - - {projects.length === 0 ? ( - - - {t("empty")} - - - ) : null} -
+ +
); diff --git a/app/robots.ts b/app/robots.ts index d634558..6525321 100644 --- a/app/robots.ts +++ b/app/robots.ts @@ -1,6 +1,8 @@ import type { MetadataRoute } from "next"; export default function robots(): MetadataRoute.Robots { + const siteUrl = new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "https://mohfarawati.de"); + return { rules: [ { @@ -8,5 +10,6 @@ export default function robots(): MetadataRoute.Robots { disallow: ["/root"], }, ], + sitemap: new URL("/sitemap.xml", siteUrl).toString(), }; } diff --git a/app/sitemap.ts b/app/sitemap.ts new file mode 100644 index 0000000..9330612 --- /dev/null +++ b/app/sitemap.ts @@ -0,0 +1,85 @@ +import type { MetadataRoute } from "next"; +import { unstable_noStore as noStore } from "next/cache"; + +import { routing } from "@/i18n/routing"; +import { getLocalizedPath } from "@/lib/locale"; +import { getPublishedPortfolioProjects } from "@/lib/portfolio"; +import { productItems } from "@/lib/site-data"; + +function getSiteUrl(): URL { + return new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "https://mohfarawati.de"); +} + +function toAbsoluteUrl(pathname: string): string { + return new URL(pathname, getSiteUrl()).toString(); +} + +function buildLocalizedEntries( + pathname: string, + options?: Pick, +): MetadataRoute.Sitemap { + return routing.locales.map((locale) => ({ + url: toAbsoluteUrl(getLocalizedPath(locale, pathname)), + lastModified: options?.lastModified, + changeFrequency: options?.changeFrequency, + priority: options?.priority, + })); +} + +export default async function sitemap(): Promise { + noStore(); + + let projects: Awaited> = []; + + try { + projects = await getPublishedPortfolioProjects(); + } catch { + projects = []; + } + + const categories = Array.from( + new Map(projects.map((project) => [project.category.slug, project.category])).values(), + ); + + return [ + ...buildLocalizedEntries("/", { + changeFrequency: "weekly", + priority: 1, + }), + ...buildLocalizedEntries("/about", { + changeFrequency: "monthly", + priority: 0.8, + }), + ...buildLocalizedEntries("/portfolio", { + changeFrequency: "weekly", + priority: 0.9, + }), + ...categories.flatMap((category) => + buildLocalizedEntries(`/portfolio/category/${category.slug}`, { + changeFrequency: "weekly", + priority: 0.8, + }), + ), + ...buildLocalizedEntries("/products", { + changeFrequency: "weekly", + priority: 0.8, + }), + ...buildLocalizedEntries("/contact", { + changeFrequency: "monthly", + priority: 0.7, + }), + ...productItems.flatMap((item) => + buildLocalizedEntries(`/products/${item.slug}`, { + changeFrequency: "monthly", + priority: 0.7, + }), + ), + ...projects.flatMap((project) => + buildLocalizedEntries(`/portfolio/${project.slug}`, { + lastModified: project.publishedAt ?? undefined, + changeFrequency: "monthly", + priority: 0.8, + }), + ), + ]; +} diff --git a/components/site/portfolio-category-filter.tsx b/components/site/portfolio-category-filter.tsx new file mode 100644 index 0000000..bc87376 --- /dev/null +++ b/components/site/portfolio-category-filter.tsx @@ -0,0 +1,69 @@ +import Link from "next/link"; + +import { AppCard } from "@/components/ui/app-card"; +import { getLocalizedPath, type AppLocale } from "@/lib/locale"; +import { getLocalizedValue, type PortfolioCategoryView } from "@/lib/portfolio"; +import { cn } from "@/lib/utils"; + +type PortfolioCategoryFilterProps = { + locale: AppLocale; + categories: PortfolioCategoryView[]; + allLabel: string; + activeCategorySlug?: string; +}; + +export function PortfolioCategoryFilter({ + locale, + categories, + allLabel, + activeCategorySlug, +}: PortfolioCategoryFilterProps) { + return ( +
+ + {categories.map((category) => ( + + ))} +
+ ); +} + +function CategoryLink({ + href, + label, + active, +}: { + href: string; + label: string; + active: boolean; +}) { + return ( + + + {label} + + + ); +} diff --git a/components/site/portfolio-project-grid.tsx b/components/site/portfolio-project-grid.tsx new file mode 100644 index 0000000..163f119 --- /dev/null +++ b/components/site/portfolio-project-grid.tsx @@ -0,0 +1,67 @@ +import { ArrowUpRight, CalendarDays } from "lucide-react"; +import Link from "next/link"; + +import { MotionFade } from "@/components/motion-fade"; +import { AppCard } from "@/components/ui/app-card"; +import { CardContent } from "@/components/ui/card"; +import { getLocalizedPath, type AppLocale } from "@/lib/locale"; +import { getLocalizedValue, type PortfolioProjectView } from "@/lib/portfolio"; + +type PortfolioProjectGridProps = { + locale: AppLocale; + projects: PortfolioProjectView[]; + emptyLabel: string; + openLabel: string; +}; + +export function PortfolioProjectGrid({ + locale, + projects, + emptyLabel, + openLabel, +}: PortfolioProjectGridProps) { + return ( +
+ {projects.map((item, index) => ( + + + + +
+

+ {getLocalizedValue(item.category.name, locale)} +

+

+ + {item.projectYear} +

+
+

+ {getLocalizedValue(item.title, locale)} +

+

+ {getLocalizedValue(item.summary, locale)} +

+

+ {openLabel} + +

+ +
+
+
+ ))} + + {projects.length === 0 ? ( + + + {emptyLabel} + + + ) : null} +
+ ); +} diff --git a/lib/portfolio.ts b/lib/portfolio.ts index 6a6a620..c420d46 100644 --- a/lib/portfolio.ts +++ b/lib/portfolio.ts @@ -266,6 +266,17 @@ export async function getActivePortfolioCategories() { return categories.map(mapCategory); } +export async function getActivePortfolioCategoryBySlug(slug: string) { + const category = await prisma.category.findFirst({ + where: { + slug, + isActive: true, + }, + }); + + return category ? mapCategory(category) : null; +} + export async function getAdminPortfolioProjects(filters?: { categoryId?: string; status?: "all" | "draft" | "published";