diff --git a/CLAUDE.md b/CLAUDE.md index 7076cca..5c16241 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,14 +83,17 @@ The Drizzle client is in `lib/db/index.ts` (postgres.js driver); the schema is i | DB schema | `lib/db/schema.ts` | | AppConfig aggregate | `lib/app-config.ts` | | Portfolio queries | `lib/portfolio.ts` | -| Media handling | `lib/media.ts` | +| Media handling | `lib/media.ts`, `lib/media-storage.ts` | | Contact flow | `lib/mail.ts` | +| SEO (metadata, robots, sitemap, JSON-LD) | `lib/metadata.ts`, `lib/seo-settings.ts`, `app/robots.ts`, `app/sitemap.ts` — see `docs/SEO.md` | +| Admin session token (middleware + auth) | `lib/admin-session-token.ts` | ### Documentation to read by task scope - **Small UI/copy/style fixes**: read only the relevant files - **Feature changes**: read `specs/.md` + `docs/ARCHITECTURE.md` if structure is affected - **Cross-cutting/architecture changes**: read `docs/ARCHITECTURE.md`, `docs/DOMAIN_RULES.md`, `docs/FEATURES.md`, and the relevant `specs/` file +- **SEO / metadata / robots / sitemap**: read `docs/SEO.md` first Update `docs/` and `specs/` only when the change affects feature scope, business rules, architecture, or public behavior. diff --git a/app/[locale]/(site)/layout.tsx b/app/[locale]/(site)/layout.tsx index 3a017c4..695cd2c 100644 --- a/app/[locale]/(site)/layout.tsx +++ b/app/[locale]/(site)/layout.tsx @@ -8,9 +8,11 @@ import { PageTransition } from "@/components/layout/page-transition"; import { SiteDock } from "@/components/layout/site-dock"; import { SiteFooter } from "@/components/layout/site-footer"; import { ScrollSmootherProvider } from "@/components/scroll-smoother-provider"; +import { JsonLd } from "@/components/seo/json-ld"; import { isSuperAdmin } from "@/lib/admin-auth"; -import { getMaintenanceMode, getSiteSettings } from "@/lib/app-config"; +import { getMaintenanceMode, getSeoSettings, getSiteSettings, getSiteSettingsMediaBindings } from "@/lib/app-config"; import { getLocalizedPath, resolveLocale } from "@/lib/locale"; +import { buildSiteJsonLd } from "@/lib/metadata"; type SiteLayoutProps = { children: ReactNode; @@ -25,9 +27,11 @@ export const revalidate = 0; export default async function SiteLayout({ children, params }: SiteLayoutProps) { noStore(); await params; - const [maintenanceEnabled, siteSettings] = await Promise.all([ + const [maintenanceEnabled, siteSettings, seo, mediaBindings] = await Promise.all([ getMaintenanceMode(), getSiteSettings(), + getSeoSettings(), + getSiteSettingsMediaBindings(), ]); const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale); // Server-side decision only. The dock receives just this boolean and uses @@ -42,6 +46,7 @@ export default async function SiteLayout({ children, params }: SiteLayoutProps) return ( <> + diff --git a/app/[locale]/(site)/portfolio/[slug]/page.tsx b/app/[locale]/(site)/portfolio/[slug]/page.tsx index 2ea58e0..90a3e26 100644 --- a/app/[locale]/(site)/portfolio/[slug]/page.tsx +++ b/app/[locale]/(site)/portfolio/[slug]/page.tsx @@ -7,8 +7,9 @@ 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 { JsonLd } from "@/components/seo/json-ld"; +import { getSeoSettings, getSiteSettings } from "@/lib/app-config"; +import { buildLocalizedMetadata, buildProjectJsonLd } from "@/lib/metadata"; import { resolveLocale } from "@/lib/locale"; import { getActivePortfolioCategories, @@ -57,6 +58,9 @@ export async function generateMetadata({ params }: PortfolioSlugPageProps): Prom pathname: `/portfolio/${slug}`, title: getLocalizedValue(resolved.project.title, localeKey), description: getLocalizedValue(resolved.project.summary, localeKey), + image: resolved.project.coverImagePath, + type: "article", + publishedTime: resolved.project.publishedAt, }); } @@ -108,13 +112,30 @@ export default async function PortfolioSlugPage({ params }: PortfolioSlugPagePro } const { project: item } = resolved; - const t = await getTranslations({ locale: localeKey, namespace: "portfolioDetail" }); + const [t, seo] = await Promise.all([ + getTranslations({ locale: localeKey, namespace: "portfolioDetail" }), + getSeoSettings(), + ]); const title = getLocalizedValue(item.title, localeKey); const category = getLocalizedValue(item.category.name, localeKey); const summary = getLocalizedValue(item.summary, localeKey); + const jsonLd = buildProjectJsonLd({ + settings: siteSettings, + seo, + locale: localeKey, + pathname: `/portfolio/${slug}`, + title, + description: summary, + image: item.coverImagePath, + datePublished: item.publishedAt, + genre: category, + keywords: [getLocalizedValue(item.serviceLabel, localeKey), String(item.projectYear)].filter(Boolean), + clientName: item.clientName, + }); return ( <> + ; +}) { + const flash = readFlash(await searchParams); + + if (!(await isAdminAuthenticated())) { + redirect(getAdminAppPath("/")); + } + + async function logoutAction() { + "use server"; + + await clearAdminSessionCookie(); + redirect(getAdminAppPath("/")); + } + + const [seo, siteSettings, bindings, maintenanceEnabled, projects, sitemapEntries] = await Promise.all([ + getSeoSettings(), + getSiteSettings(), + getSiteSettingsMediaBindings(), + getMaintenanceMode(), + getPublishedPortfolioProjects().catch(() => []), + buildSitemap().catch(() => []), + ]); + + const checks = buildSeoChecklist({ + seo, + settings: siteSettings, + bindings, + maintenanceEnabled, + publishedProjectCount: projects.length, + sitemapEntryCount: sitemapEntries.length, + siteUrl: getSiteUrl().origin, + }); + + return ( + + + + + + ); +} diff --git a/app/admin-internal/site-settings/seo/page.tsx b/app/admin-internal/site-settings/seo/page.tsx new file mode 100644 index 0000000..ee894f9 --- /dev/null +++ b/app/admin-internal/site-settings/seo/page.tsx @@ -0,0 +1 @@ +export { default } from "../../../_admin/site-settings/seo/page"; diff --git a/app/robots.ts b/app/robots.ts index b2a7721..2d04783 100644 --- a/app/robots.ts +++ b/app/robots.ts @@ -1,15 +1,49 @@ import type { MetadataRoute } from "next"; +import { unstable_noStore as noStore } from "next/cache"; -export default function robots(): MetadataRoute.Robots { - const siteUrl = new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "https://mohfarawati.de"); +import { getMaintenanceMode, getSeoSettings } from "@/lib/app-config"; +import { INTERNAL_ADMIN_PREFIX } from "@/lib/admin-routing"; +import { getSiteUrl } from "@/lib/metadata"; + +export const dynamic = "force-dynamic"; + +/** Paths that must never be crawled even when indexing is enabled. */ +export const ROBOTS_DISALLOWED_PATHS = [ + INTERNAL_ADMIN_PREFIX, + "/root", + "/api/", + "/success", + "/coming-soon", + "/*/success", + "/*/coming-soon", +]; + +export function buildRobots(input: { indexable: boolean }): MetadataRoute.Robots { + const siteUrl = getSiteUrl(); + + if (!input.indexable) { + return { + rules: [{ userAgent: "*", disallow: "/" }], + host: siteUrl.origin, + }; + } return { rules: [ { userAgent: "*", - disallow: ["/admin-internal"], + allow: "/", + disallow: ROBOTS_DISALLOWED_PATHS, }, ], sitemap: new URL("/sitemap.xml", siteUrl).toString(), + host: siteUrl.origin, }; } + +export default async function robots(): Promise { + noStore(); + const [seo, maintenanceEnabled] = await Promise.all([getSeoSettings(), getMaintenanceMode()]); + + return buildRobots({ indexable: seo.allowIndexing && !maintenanceEnabled }); +} diff --git a/app/root/site-settings/seo/page.tsx b/app/root/site-settings/seo/page.tsx new file mode 100644 index 0000000..ee894f9 --- /dev/null +++ b/app/root/site-settings/seo/page.tsx @@ -0,0 +1 @@ +export { default } from "../../../_admin/site-settings/seo/page"; diff --git a/app/sitemap.ts b/app/sitemap.ts index 35650b6..fec5a70 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -2,72 +2,111 @@ import type { MetadataRoute } from "next"; import { unstable_noStore as noStore } from "next/cache"; import { routing } from "@/i18n/routing"; -import { getSiteSettings } from "@/lib/app-config"; -import { getLocalizedPath } from "@/lib/locale"; -import { getPublishedPortfolioProjects } from "@/lib/portfolio"; +import { getMaintenanceMode, getSeoSettings, getSiteSettings } from "@/lib/app-config"; +import { getLocalizedPath, type AppLocale } from "@/lib/locale"; +import { toAbsoluteUrl } from "@/lib/metadata"; +import { getActivePortfolioCategories, getPublishedPortfolioProjects } from "@/lib/portfolio"; -function getSiteUrl(): URL { - return new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "https://mohfarawati.de"); -} +export const dynamic = "force-dynamic"; -function toAbsoluteUrl(pathname: string): string { - return new URL(pathname, getSiteUrl()).toString(); -} +type EntryOptions = Pick; -function buildLocalizedEntries( +/** + * One entry per locale for a path, each carrying hreflang alternates so search + * engines link the three language versions together. + */ +export function buildLocalizedEntries( pathname: string, - defaultLocale: "de" | "en" | "ar", - options?: Pick, + defaultLocale: AppLocale, + options?: EntryOptions, ): MetadataRoute.Sitemap { + const languages = Object.fromEntries( + routing.locales.map((locale) => [locale, toAbsoluteUrl(getLocalizedPath(locale, pathname, defaultLocale))]), + ) as Record; + return routing.locales.map((locale) => ({ - url: toAbsoluteUrl(getLocalizedPath(locale, pathname, defaultLocale)), + url: languages[locale], lastModified: options?.lastModified, changeFrequency: options?.changeFrequency, priority: options?.priority, + alternates: { + languages: { + ...languages, + "x-default": languages[defaultLocale], + }, + }, })); } export default async function sitemap(): Promise { noStore(); - const siteSettings = await getSiteSettings(); + const [siteSettings, seo, maintenanceEnabled] = await Promise.all([ + getSiteSettings(), + getSeoSettings(), + getMaintenanceMode(), + ]); - let projects: Awaited> = []; - - try { - projects = await getPublishedPortfolioProjects(); - } catch { - projects = []; + // While the site is hidden (maintenance) or indexing is off, publish an + // empty sitemap instead of advertising URLs that redirect or are noindex. + if (maintenanceEnabled || !seo.allowIndexing) { + return []; } - const categories = Array.from( - new Map(projects.map((project) => [project.category.slug, project.category])).values(), + const defaultLocale = siteSettings.defaultLocale; + + let projects: Awaited> = []; + let categories: Awaited> = []; + + try { + [projects, categories] = await Promise.all([ + getPublishedPortfolioProjects(), + getActivePortfolioCategories(), + ]); + } catch { + projects = []; + categories = []; + } + + // Only categories that actually have published work get a landing URL; + // an empty category page has nothing to index. + const categoriesWithProjects = categories.filter((category) => + projects.some((project) => project.category.slug === category.slug), ); + const latestProjectDate = projects.reduce((latest, project) => { + const date = project.publishedAt ?? undefined; + + return date && (!latest || date > latest) ? date : latest; + }, undefined); + return [ - ...buildLocalizedEntries("/", siteSettings.defaultLocale, { + ...buildLocalizedEntries("/", defaultLocale, { changeFrequency: "weekly", priority: 1, + lastModified: latestProjectDate, }), - ...buildLocalizedEntries("/about", siteSettings.defaultLocale, { + ...buildLocalizedEntries("/about", defaultLocale, { changeFrequency: "monthly", priority: 0.8, }), - ...buildLocalizedEntries("/portfolio", siteSettings.defaultLocale, { + ...buildLocalizedEntries("/portfolio", defaultLocale, { changeFrequency: "weekly", priority: 0.9, + lastModified: latestProjectDate, }), - ...categories.flatMap((category) => - buildLocalizedEntries(`/portfolio/${category.slug}`, siteSettings.defaultLocale, { + ...categoriesWithProjects.flatMap((category) => + buildLocalizedEntries(`/portfolio/${category.slug}`, defaultLocale, { changeFrequency: "weekly", priority: 0.8, + lastModified: latestProjectDate, }), ), - ...buildLocalizedEntries("/contact", siteSettings.defaultLocale, { + ...buildLocalizedEntries("/contact", defaultLocale, { changeFrequency: "monthly", priority: 0.7, }), ...projects.flatMap((project) => - buildLocalizedEntries(`/portfolio/${project.slug}`, siteSettings.defaultLocale, { + buildLocalizedEntries(`/portfolio/${project.slug}`, defaultLocale, { lastModified: project.publishedAt ?? undefined, changeFrequency: "monthly", priority: 0.8, diff --git a/app/uploads/media/[...segments]/route.ts b/app/uploads/media/[...segments]/route.ts index 3af5a18..77c3d8b 100644 --- a/app/uploads/media/[...segments]/route.ts +++ b/app/uploads/media/[...segments]/route.ts @@ -14,6 +14,7 @@ export const dynamic = "force-dynamic"; const CONTENT_TYPES: Record = { ".ico": "image/x-icon", + ".gif": "image/gif", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", @@ -30,15 +31,30 @@ export async function GET(_: Request, { params }: MediaFileRouteProps) { try { const absolutePath = resolveMediaUploadPath(publicPath); const fileBuffer = await readFile(absolutePath); - const contentType = CONTENT_TYPES[path.extname(absolutePath).toLowerCase()] ?? "application/octet-stream"; + const extension = path.extname(absolutePath).toLowerCase(); + const contentType = CONTENT_TYPES[extension]; - return new NextResponse(fileBuffer, { - status: 200, - headers: { - "Content-Type": contentType, - "Cache-Control": "public, max-age=31536000, immutable", - }, - }); + if (!contentType) { + return new NextResponse("Not Found", { status: 404 }); + } + + const headers: Record = { + "Content-Type": contentType, + "Cache-Control": "public, max-age=31536000, immutable", + "X-Content-Type-Options": "nosniff", + }; + + // SVG is an active document type: sandbox it so an uploaded file can never + // run script or reach our origin even if it is opened directly. + if (extension === ".svg") { + headers["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'; sandbox"; + } + + if (extension === ".pdf") { + headers["Content-Disposition"] = "inline"; + } + + return new NextResponse(new Uint8Array(fileBuffer), { status: 200, headers }); } catch { return new NextResponse("Not Found", { status: 404, diff --git a/components/admin/admin-dashboard-shell.tsx b/components/admin/admin-dashboard-shell.tsx index 25e434c..012a15f 100644 --- a/components/admin/admin-dashboard-shell.tsx +++ b/components/admin/admin-dashboard-shell.tsx @@ -8,6 +8,7 @@ import { LogOut, Palette, PlusSquare, + Search, ShieldAlert, SwatchBook, Tags, @@ -40,6 +41,7 @@ type AdminDashboardCopy = { siteSettings: string; brandSettings?: string; localizationSettings?: string; + seoSettings?: string; marquee?: string; smtp?: string; logout: string; @@ -50,7 +52,7 @@ type AdminDashboardShellProps = { copy: AdminDashboardCopy; active: "overview" | "maintenance" | "ui-kit" | "portfolio" | "media" | "site-settings" | "smtp" | "marquee"; portfolioChild?: "overview" | "projects" | "new-project" | "categories"; - siteSettingsChild?: "brand" | "localization"; + siteSettingsChild?: "brand" | "localization" | "seo"; flash?: FlashMessages; logoutAction: () => Promise; headerTitle: string; @@ -100,6 +102,8 @@ export async function AdminDashboardShell({ : active === "site-settings" ? siteSettingsChild === "localization" ? Languages + : siteSettingsChild === "seo" + ? Search : siteSettingsChild === "brand" ? Palette : Globe2 diff --git a/components/admin/seo-settings-form.tsx b/components/admin/seo-settings-form.tsx new file mode 100644 index 0000000..220ccc1 --- /dev/null +++ b/components/admin/seo-settings-form.tsx @@ -0,0 +1,278 @@ +import { ExternalLink, FileCode2, Map as MapIcon, Bot, CheckCircle2, AlertTriangle, XCircle } from "lucide-react"; +import Link from "next/link"; + +import { StatsCard } from "@/components/dashboard/stats-card"; +import { AppCard } from "@/components/ui/app-card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import type { SeoCheck } from "@/lib/seo-report"; +import { summarizeSeoChecklist } from "@/lib/seo-report"; +import type { SeoSettings } from "@/lib/seo-settings"; +import { cn } from "@/lib/utils"; + +type SeoSettingsFormProps = { + action: (formData: FormData) => Promise; + settings: SeoSettings; + checks: SeoCheck[]; + links: { + sitemap: string; + robots: string; + manifest: string; + }; + sitemapEntryCount: number; +}; + +const localeKeywordFields = [ + { key: "de", name: "keywordsDe", label: "Keywords (Deutsch)" }, + { key: "en", name: "keywordsEn", label: "Keywords (English)" }, + { key: "ar", name: "keywordsAr", label: "Keywords (Arabic)" }, +] as const; + +function StatusIcon({ status }: { status: SeoCheck["status"] }) { + if (status === "ok") { + return ; + } + + if (status === "warn") { + return ; + } + + return ; +} + +function FileLink({ + href, + label, + description, + icon: Icon, +}: { + href: string; + label: string; + description: string; + icon: typeof MapIcon; +}) { + return ( + +
+
+ +
+
+

{label}

+

{description}

+
+
+ +
+ ); +} + +export function SeoSettingsForm({ action, settings, checks, links, sitemapEntryCount }: SeoSettingsFormProps) { + const summary = summarizeSeoChecklist(checks); + + return ( +
+
+ + + +
+ +
+
+
+
+

Sichtbarkeit

+

+ Steuert robots.txt, die Sitemap und das robots Meta Tag aller oeffentlichen Seiten. +

+
+ + + +
+ +
+
+

Verifizierung & Social

+

+ Codes aus Google Search Console / Bing Webmaster und das X-Handle fuer Twitter Cards. +

+
+ +
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+

Strukturierte Daten

+

+ JSON-LD fuer Google: Wer steht hinter der Seite? Gilt fuer alle Seiten und jede Projekt-Ansicht. +

+
+ +
+ + +
+
+ + +
+
+ + +
+
+ +