Compare commits

...
15 Commits
Author SHA1 Message Date
mohandClaude Opus 4.8 7543271086 FIX - Type dock bounce ease as const tuple for framer-motion
CI / quality (push) Waiting to run
framer-motion's stricter types reject a cubic-bezier ease inferred as number[]; `as const` makes it a 4-tuple so the production build type check passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-22 10:41:48 +02:00
MOH 3c9af70f98 Merge branch 'main' of https://git.mohfarawati.de/mohs/sass-mohfarawati
CI / quality (push) Canceled after 0s
2026-09-20 22:00:09 +02:00
moh 546b2eadda Merge branch 'main' of https://git.mohfarawati.de/mohs/sass-mohfarawati
CI / quality (push) Canceled after 0s
2026-09-20 21:58:56 +02:00
moh dc21c33867 ADDED - Admin SEO page, robots/sitemap hardening and media/maintenance security fixes
SEO
- New Settings > SEO admin page (seo_settings in app_config): indexing switch,
  Google/Bing verification, X handle, JSON-LD identity (Person/Organization,
  sameAs), per-locale keywords, readiness checklist and open links for
  sitemap.xml / robots.txt / manifest.
- robots.txt is now dynamic: disallows admin, api, success and coming-soon
  paths; blocks everything while indexing is off or maintenance is on.
- sitemap.xml carries hreflang alternates per URL, lists only categories with
  published projects, and is empty while hidden.
- Metadata: robots + verification meta, og:locale in de_DE/en_US/ar_AR form,
  alternateLocale, twitter site/creator, project cover as OG image with
  article type, noindex on /success and /coming-soon.
- JSON-LD: WebSite + publisher graph on all public pages, CreativeWork per
  project (view-mode independent).

Security
- Maintenance bypass now requires a correctly signed admin cookie; the
  middleware previously only checked the cookie existed. Token helpers moved
  to lib/admin-session-token.ts (shared by proxy.ts and lib/admin-auth.ts).
- Media uploads: magic-byte validation against the declared type, SVG
  sanitization (script/handlers/foreignObject/javascript: rejected), upload
  folder sanitized, kind inferred from the real file.
- Media route: fixed prefix-based path check that accepted sibling
  directories, unknown extensions return 404, nosniff header, CSP sandbox on
  SVG, gif content type added.
- External media URLs: protocol-relative (//host) URLs rejected.

Portfolio
- Project and category slugs share /portfolio/[slug]; saving now rejects a
  slug already used on the other side instead of silently shadowing it.

Tooling/docs
- Lint: ignore scripts/legacy-prisma-seed.cjs, drop unused import.
- New docs/SEO.md; FEATURES, ARCHITECTURE (Drizzle instead of Prisma), admin
  spec and CLAUDE.md updated.
- Tests for all of the above (unit + integration); suite green.
2026-09-20 21:36:16 +02:00
MOH 91ff6b9edb POLISHED - Soften dock bounce animation to match macOS feel 2026-09-20 21:34:50 +02:00
MOH d5e50b84a8 REPLACED - Swap dock SVG icons with Apple-style PNGs for light/dark themes
CI / quality (push) Canceled after 0s
Replace placeholder SVG dock icons with high-quality Apple-style PNG icons
(Contacts, Photos, App Store, Messages) with light and dark variants that
switch based on the active theme via next-themes.
2026-09-20 21:28:13 +02:00
moh 0b513551ca POLISHED - Soften dock hover magnification (92 to 66)
CI / quality (push) Canceled after 0s
2026-09-20 20:49:25 +02:00
moh 57423b1baf IMPROVED - Remove dock shadows, fix bounce artifact, mac-radius icons, gallery icon
- Drop all shadows (dock, icons, tooltip, corner pills); this also removes the
  square that showed behind an icon mid-bounce.
- Bounce now moves the whole tile (logo bg lives on the animated element), so
  nothing is left behind.
- Icons use a ~22% macOS-like corner radius (art carries its own shape).
- Portfolio gets an original photo/gallery icon (not Apple's system icon).
2026-09-20 20:48:23 +02:00
moh c41a2e38c9 IMPROVED - Make dock feel like the real macOS dock
- Stronger magnification (50 to 92) with snappier spring tracking.
- macOS-style launch bounce on click; running-dot indicator for the active
  page (works with fixed app-icon images, no recolor).
- Per-item icon paths in navItems so real macOS app icons can be dropped in.
- Drop the now-unused -active icon variants.
2026-09-20 20:38:35 +02:00
moh 9fd928cce6 REMOVED - Trim home page: drop projects/capabilities/process, full-bleed tools marquee
- Remove the Selected Projects, Capabilities and Process sections from the
  home page (placeholder-heavy content).
- Take the tools marquee out of its card and let it span the full site width
  (full-bleed band) instead.
2026-09-20 20:31:35 +02:00
moh 1e211c1201 REVERTED - Remove macOS shell window; restore full-bleed content 2026-09-20 20:17:33 +02:00
moh 2fc0641512 ADDED - Frame site content in a macOS-style shell window on an ambient desktop
- Wrap #smooth-wrapper in an .app-shell: a fixed, inset, rounded, bordered
  panel. Its transform makes it the containing block for the fixed wrapper, so
  ScrollSmoother sizes the scroll viewport to the shell (content scrolls inside
  it) without fighting ScrollSmoother's inline styles.
- Dock and corner controls stay outside the shell, on a brand-tinted 'desktop'
  wallpaper painted on body.
- Reduced-motion path: the shell scrolls its content natively.
2026-09-20 20:16:49 +02:00
MOH 31dc72b1c8 fix
CI / quality (push) Canceled after 0s
2026-09-20 20:04:48 +02:00
MOH 10600b48c4 WIP - save all current changes 2026-09-20 20:04:48 +02:00
moh a628b69133 FIXED - Move page transition from template to persistent layout so it animates
CI / quality (push) Canceled after 0s
A template.tsx is re-created on every navigation, resetting AnimatePresence
so neither enter nor exit ran. Move the transition into a PageTransition
client component rendered in the persistent (site) layout, so AnimatePresence
survives navigations and the old page animates out before the new one in.
2026-09-20 19:55:13 +02:00
70 changed files with 1976 additions and 378 deletions
+4 -1
View File
@@ -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/<feature>.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.
+12 -4
View File
@@ -4,12 +4,15 @@ import { getLocale } from "next-intl/server";
import { redirect } from "next/navigation";
import { SiteAmbientBackdrop } from "@/components/layout/site-ambient-backdrop";
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;
@@ -24,12 +27,14 @@ 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 header receives just this boolean and uses
// Server-side decision only. The dock receives just this boolean and uses
// it purely to show/hide the Admin shortcut link — it grants no access by
// itself. Every admin page/action re-checks the session independently, so
// this value is not a security boundary, only a UI convenience.
@@ -41,13 +46,16 @@ export default async function SiteLayout({ children, params }: SiteLayoutProps)
return (
<>
<JsonLd data={buildSiteJsonLd({ settings: siteSettings, seo, bindings: mediaBindings, locale: localeKey })} />
<ScrollSmootherProvider />
<SiteAmbientBackdrop />
<SiteDock defaultLocale={siteSettings.defaultLocale} isSuperAdmin={authenticated} />
<div id="smooth-wrapper">
<div id="smooth-content">
<div className="site-content-frame flex min-h-screen flex-col">
<main className="flex-1">{children}</main>
<main className="flex-1">
<PageTransition>{children}</PageTransition>
</main>
<SiteFooter defaultLocale={siteSettings.defaultLocale} />
</div>
</div>
+1 -86
View File
@@ -5,18 +5,11 @@ import { Container } from "@/components/layout/container";
import { HomeHero } from "@/components/layout/home-hero";
import { MotionFade } from "@/components/motion-fade";
import { MagicBentoSection } from "@/components/home/magic-bento-section";
import { CapabilitiesSection } from "@/components/home/capabilities-section";
import { ContactCtaSection } from "@/components/home/contact-cta-section";
import { MarqueeSection } from "@/components/home/marquee-section";
import { ProcessSection } from "@/components/home/process-section";
import { ProjectsSection } from "@/components/home/projects-section";
import type {
CapabilitiesSectionCopy,
ContactCtaSectionCopy,
MarqueeSectionCopy,
ProcessSectionCopy,
ProjectCardCopy,
ProjectsSectionCopy,
} from "@/components/home/types";
import { buildLocalizedMetadata } from "@/lib/metadata";
import { getLocalizedPath, resolveLocale } from "@/lib/locale";
@@ -25,10 +18,6 @@ import {
getSiteSettings,
splitMarqueeRowItems,
} from "@/lib/app-config";
import {
getLocalizedValue,
getPublishedPortfolioProjects,
} from "@/lib/portfolio";
import { type MarqueeRow } from "@/components/layout/stacked-marquee-section";
type HomePageProps = {
@@ -65,10 +54,9 @@ export default async function HomePage({ params }: HomePageProps) {
siteSettings.defaultLocale,
);
const [t, marqueeSettings, publishedProjects] = await Promise.all([
const [t, marqueeSettings] = await Promise.all([
getTranslations({ locale: localeKey, namespace: "homepage" }),
getMarqueeSettings(),
getPublishedPortfolioProjects(),
]);
const bentoCards = [
@@ -112,28 +100,6 @@ export default async function HomePage({ params }: HomePageProps) {
description: t("marquee.description"),
};
const projectsCopy: ProjectsSectionCopy = {
eyebrow: t("projects.eyebrow"),
title: t("projects.title"),
description: t("projects.description"),
stackLabel: t("projects.stackLabel"),
fallbackItems: t.raw("projects.fallbackItems") as ProjectsSectionCopy["fallbackItems"],
};
const capabilitiesCopy: CapabilitiesSectionCopy = {
eyebrow: t("capabilities.eyebrow"),
title: t("capabilities.title"),
description: t("capabilities.description"),
items: t.raw("capabilities.items") as CapabilitiesSectionCopy["items"],
};
const processCopy: ProcessSectionCopy = {
eyebrow: t("process.eyebrow"),
title: t("process.title"),
description: t("process.description"),
steps: t.raw("process.steps") as ProcessSectionCopy["steps"],
};
const contactCtaCopy: ContactCtaSectionCopy = {
eyebrow: t("contactCta.eyebrow"),
title: t("contactCta.title"),
@@ -157,45 +123,6 @@ export default async function HomePage({ params }: HomePageProps) {
const portfolioHref = getLocalizedPath(localeKey, "/portfolio", siteSettings.defaultLocale);
const contactHref = getLocalizedPath(localeKey, "/contact", siteSettings.defaultLocale);
const stackSets = t.raw("projects.stackSets") as string[][];
const selectedProjects = [
...publishedProjects.filter((project) => project.isFeatured),
...publishedProjects.filter((project) => !project.isFeatured),
].slice(0, 3);
const projectCards: ProjectCardCopy[] = selectedProjects.map((project, index) => ({
title: getLocalizedValue(project.title, localeKey),
summary: getLocalizedValue(project.summary, localeKey),
stack: stackSets[index] ?? stackSets[stackSets.length - 1] ?? [],
href: getLocalizedPath(
localeKey,
`/portfolio/${project.slug}`,
siteSettings.defaultLocale,
),
cta: t("projects.cta"),
meta: [
getLocalizedValue(project.category.name, localeKey),
getLocalizedValue(project.serviceLabel, localeKey),
String(project.projectYear),
],
featured: index === 0,
coverImagePath: project.coverImagePath,
}));
const fallbackProjectCards: ProjectCardCopy[] = projectsCopy.fallbackItems.map((item) => ({
title: item.title,
summary: item.summary,
stack: item.stack,
href: item.href === "/portfolio" ? portfolioHref : item.href === "/contact" ? contactHref : item.href,
cta: t("projects.cta"),
meta: [t("projects.fallbackMeta")],
}));
const mergedProjectCards = [
...projectCards,
...fallbackProjectCards.slice(0, Math.max(0, 3 - projectCards.length)),
].slice(0, 3);
return (
<>
@@ -226,19 +153,7 @@ export default async function HomePage({ params }: HomePageProps) {
<MarqueeSection copy={marqueeCopy} rows={marqueeRows} />
</MotionFade>
<MotionFade delay={0.08}>
<ProjectsSection copy={projectsCopy} items={mergedProjectCards} />
</MotionFade>
<MotionFade delay={0.1}>
<CapabilitiesSection copy={capabilitiesCopy} />
</MotionFade>
<MotionFade delay={0.12}>
<ProcessSection copy={processCopy} />
</MotionFade>
<MotionFade delay={0.14}>
<ContactCtaSection copy={contactCtaCopy} contactHref={contactHref} />
</MotionFade>
</Container>
+24 -3
View File
@@ -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 (
<>
<JsonLd data={jsonLd} />
<PageHero
locale={localeKey}
badge={category}
+1
View File
@@ -30,6 +30,7 @@ export async function generateMetadata({ params }: SuccessPageProps): Promise<Me
pathname: "/success",
title: t("title"),
description: t("text"),
noIndex: true,
});
}
+1
View File
@@ -34,6 +34,7 @@ export async function generateMetadata({ params }: ComingSoonPageProps): Promise
title: siteSettings.locales[localeKey].siteName,
description: t("description"),
applyTitleTemplate: false,
noIndex: true,
});
}
+26 -1
View File
@@ -1,6 +1,6 @@
"use server";
import { and, eq, inArray } from "drizzle-orm";
import { eq, inArray } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { isRedirectError } from "next/dist/client/components/redirect-error";
@@ -154,6 +154,19 @@ export async function upsertCategoryAction(formData: FormData) {
isActive: normalizeCheckboxValue(formData, "isActive"),
});
// Categories and projects share the public `/portfolio/[slug]` route, so a
// slug may only exist on one side. Categories win at resolve time, which
// would silently hide a project with the same slug.
const [projectWithSlug] = await db
.select({ id: portfolioProject.id })
.from(portfolioProject)
.where(eq(portfolioProject.slug, parsed.slug))
.limit(1);
if (projectWithSlug) {
throw new Error("Kategorie Slug ist bereits als Projekt Slug vergeben.");
}
if (parsed.id) {
await db.update(category).set(parsed).where(eq(category.id, parsed.id));
} else {
@@ -172,6 +185,8 @@ export async function upsertCategoryAction(formData: FormData) {
? parseZodError(error)
: isUniqueViolation(error)
? "Kategorie Slug muss eindeutig sein."
: error instanceof Error && error.message.includes("Slug")
? error.message
: "Kategorie konnte nicht gespeichert werden.";
redirect(withFlash(redirectPath, { error: message }));
@@ -298,6 +313,16 @@ export async function saveProjectAction(formData: FormData) {
assets,
});
const [categoryWithSlug] = await db
.select({ id: category.id })
.from(category)
.where(eq(category.slug, parsed.slug))
.limit(1);
if (categoryWithSlug) {
throw new Error("Projekt Slug ist bereits als Kategorie Slug vergeben.");
}
const existingProject = parsed.id
? (
await db
+71
View File
@@ -12,7 +12,9 @@ import {
SITE_SETTINGS_FAVICON_FIELD_KEY,
SITE_SETTINGS_LOGO_DARK_FIELD_KEY,
SITE_SETTINGS_LOGO_LIGHT_FIELD_KEY,
getSeoSettings,
getSiteSettings,
updateSeoSettings,
updateSiteSettings,
} from "@/lib/app-config";
import { getAdminAppPath, toInternalAdminPath } from "@/lib/admin-routing";
@@ -24,6 +26,15 @@ import {
type SiteSettings,
} from "@/lib/site-settings";
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
import {
normalizeKeywords,
normalizeSameAs,
normalizeStructuredDataType,
normalizeTwitterHandle,
normalizeVerificationToken,
type SeoSettings,
} from "@/lib/seo-settings";
import { isCheckedFormValue } from "@/lib/form-data";
import { replaceEntityMediaUsages } from "@/lib/media";
import { resolveMediaSelection } from "@/lib/media-service";
import { routing } from "@/i18n/routing";
@@ -329,3 +340,63 @@ export async function saveSiteLocalizationSettingsAction(formData: FormData) {
redirect(withFlash(getAdminAppPath("/site-settings/localization"), { error: message }));
}
}
export async function saveSeoSettingsAction(formData: FormData) {
await ensureAdmin();
try {
const currentSettings = await getSeoSettings();
const googleRaw = String(formData.get("googleSiteVerification") ?? "").trim();
const bingRaw = String(formData.get("bingSiteVerification") ?? "").trim();
const twitterRaw = String(formData.get("twitterHandle") ?? "").trim();
const googleSiteVerification = normalizeVerificationToken(googleRaw);
const bingSiteVerification = normalizeVerificationToken(bingRaw);
const twitterHandle = normalizeTwitterHandle(twitterRaw);
if (googleRaw && !googleSiteVerification) {
throw new Error("Google Verification Code darf nur Buchstaben, Zahlen, - und _ enthalten.");
}
if (bingRaw && !bingSiteVerification) {
throw new Error("Bing Verification Code darf nur Buchstaben, Zahlen, - und _ enthalten.");
}
if (twitterRaw && !twitterHandle) {
throw new Error("X/Twitter Handle ist ungueltig (max. 15 Zeichen, Buchstaben/Zahlen/_).");
}
const parsedSettings: SeoSettings = {
...currentSettings,
allowIndexing: isCheckedFormValue(formData.get("allowIndexing")),
googleSiteVerification,
bingSiteVerification,
twitterHandle,
structuredDataType: normalizeStructuredDataType(formData.get("structuredDataType")),
structuredDataName: String(formData.get("structuredDataName") ?? "").trim().slice(0, 120),
structuredDataJobTitle: String(formData.get("structuredDataJobTitle") ?? "").trim().slice(0, 160),
sameAs: normalizeSameAs(formData.get("sameAs")),
locales: {
ar: { keywords: normalizeKeywords(formData.get("keywordsAr")) },
en: { keywords: normalizeKeywords(formData.get("keywordsEn")) },
de: { keywords: normalizeKeywords(formData.get("keywordsDe")) },
},
};
await updateSeoSettings(parsedSettings);
const siteSettings = await getSiteSettings();
await revalidateSiteSettingsPages(siteSettings.defaultLocale);
revalidatePath("/sitemap.xml");
revalidatePath("/robots.txt");
revalidatePath(toInternalAdminPath("/site-settings/seo"));
redirect(withFlash(getAdminAppPath("/site-settings/seo"), { success: "SEO Einstellungen gespeichert." }));
} catch (error) {
if (isRedirectError(error)) {
throw error;
}
const message =
error instanceof Error ? error.message : "SEO Einstellungen konnten nicht gespeichert werden.";
redirect(withFlash(getAdminAppPath("/site-settings/seo"), { error: message }));
}
}
+104
View File
@@ -0,0 +1,104 @@
import { redirect } from "next/navigation";
import { MotionFade } from "@/components/motion-fade";
import { SeoSettingsForm } from "@/components/admin/seo-settings-form";
import { AdminDashboardShell } from "@/components/admin/admin-dashboard-shell";
import { readFlash } from "@/lib/admin-feedback";
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
import { buildSiteUrl, getAdminAppPath } from "@/lib/admin-routing";
import {
getMaintenanceMode,
getSeoSettings,
getSiteSettings,
getSiteSettingsMediaBindings,
} from "@/lib/app-config";
import { getSiteUrl } from "@/lib/metadata";
import { INTERNAL_MANIFEST_PATH } from "@/lib/site-icons";
import { getPublishedPortfolioProjects } from "@/lib/portfolio";
import { buildSeoChecklist } from "@/lib/seo-report";
import buildSitemap from "@/app/sitemap";
import { saveSeoSettingsAction } from "../actions";
export const dynamic = "force-dynamic";
const copy = {
title: "SEO",
subtitle: "Indexierung, Verifizierung, strukturierte Daten, Sitemap und robots.txt.",
overview: "Uebersicht",
maintenance: "Wartungsmodus",
uiKit: "UI Kit",
media: "Media",
siteSettings: "Settings",
brandSettings: "Brand",
localizationSettings: "Localization",
seoSettings: "SEO",
smtp: "SMTP",
portfolio: "Portfolio",
logout: "Ausloggen",
backToSite: "Zur Website",
};
export default async function AdminSeoSettingsPage({
searchParams,
}: {
searchParams?: Promise<{ success?: string; error?: string }>;
}) {
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 (
<AdminDashboardShell
copy={copy}
active="site-settings"
flash={flash}
siteSettingsChild="seo"
logoutAction={logoutAction}
headerTitle={copy.title}
headerDescription={copy.subtitle}
>
<MotionFade delay={0.16}>
<SeoSettingsForm
action={saveSeoSettingsAction}
settings={seo}
checks={checks}
sitemapEntryCount={sitemapEntries.length}
links={{
sitemap: buildSiteUrl("/sitemap.xml"),
robots: buildSiteUrl("/robots.txt"),
manifest: buildSiteUrl(INTERNAL_MANIFEST_PATH),
}}
/>
</MotionFade>
</AdminDashboardShell>
);
}
@@ -0,0 +1 @@
export { default } from "../../../_admin/site-settings/seo/page";
+37 -3
View File
@@ -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<MetadataRoute.Robots> {
noStore();
const [seo, maintenanceEnabled] = await Promise.all([getSeoSettings(), getMaintenanceMode()]);
return buildRobots({ indexable: seo.allowIndexing && !maintenanceEnabled });
}
+1
View File
@@ -0,0 +1 @@
export { default } from "../../../_admin/site-settings/seo/page";
+68 -29
View File
@@ -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<MetadataRoute.Sitemap[number], "changeFrequency" | "priority" | "lastModified">;
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<MetadataRoute.Sitemap[number], "changeFrequency" | "priority" | "lastModified">,
defaultLocale: AppLocale,
options?: EntryOptions,
): MetadataRoute.Sitemap {
const languages = Object.fromEntries(
routing.locales.map((locale) => [locale, toAbsoluteUrl(getLocalizedPath(locale, pathname, defaultLocale))]),
) as Record<AppLocale, string>;
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<MetadataRoute.Sitemap> {
noStore();
const siteSettings = await getSiteSettings();
const [siteSettings, seo, maintenanceEnabled] = await Promise.all([
getSiteSettings(),
getSeoSettings(),
getMaintenanceMode(),
]);
let projects: Awaited<ReturnType<typeof getPublishedPortfolioProjects>> = [];
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<ReturnType<typeof getPublishedPortfolioProjects>> = [];
let categories: Awaited<ReturnType<typeof getActivePortfolioCategories>> = [];
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<Date | undefined>((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,
+22 -6
View File
@@ -14,6 +14,7 @@ export const dynamic = "force-dynamic";
const CONTENT_TYPES: Record<string, string> = {
".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: {
if (!contentType) {
return new NextResponse("Not Found", { status: 404 });
}
const headers: Record<string, string> = {
"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,
+5 -1
View File
@@ -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<void>;
headerTitle: string;
@@ -100,6 +102,8 @@ export async function AdminDashboardShell({
: active === "site-settings"
? siteSettingsChild === "localization"
? Languages
: siteSettingsChild === "seo"
? Search
: siteSettingsChild === "brand"
? Palette
: Globe2
+278
View File
@@ -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<void>;
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 <CheckCircle2 className="h-4 w-4 text-status-success" aria-label="OK" />;
}
if (status === "warn") {
return <AlertTriangle className="h-4 w-4 text-status-warning" aria-label="Hinweis" />;
}
return <XCircle className="h-4 w-4 text-destructive" aria-label="Fehler" />;
}
function FileLink({
href,
label,
description,
icon: Icon,
}: {
href: string;
label: string;
description: string;
icon: typeof MapIcon;
}) {
return (
<AppCard level={2} padding="sm" contentClassName="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-nested border border-border bg-muted/50 text-muted-foreground">
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-foreground">{label}</p>
<p className="truncate text-xs text-muted-foreground">{description}</p>
</div>
</div>
<Button asChild variant="outline" size="sm">
<Link href={href} target="_blank" rel="noreferrer">
Oeffnen
<ExternalLink className="ml-1.5 h-3.5 w-3.5" />
</Link>
</Button>
</AppCard>
);
}
export function SeoSettingsForm({ action, settings, checks, links, sitemapEntryCount }: SeoSettingsFormProps) {
const summary = summarizeSeoChecklist(checks);
return (
<div className="space-y-6">
<div className="grid gap-3 sm:grid-cols-3">
<StatsCard title="Bereit" value={String(summary.ok)} description="Checks bestanden" />
<StatsCard title="Hinweise" value={String(summary.warn)} description="Empfohlen zu pruefen" />
<StatsCard title="Fehler" value={String(summary.error)} description="Blockiert Sichtbarkeit" />
</div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<form id="seo-settings-form" action={action} className="space-y-6">
<section className="space-y-3">
<div>
<h2 className="text-lg font-semibold text-foreground">Sichtbarkeit</h2>
<p className="text-sm text-muted-foreground">
Steuert robots.txt, die Sitemap und das robots Meta Tag aller oeffentlichen Seiten.
</p>
</div>
<AppCard level={2} padding="sm" contentClassName="space-y-4">
<label className="flex items-start gap-3">
<input
type="checkbox"
name="allowIndexing"
value="on"
defaultChecked={settings.allowIndexing}
className="mt-1 h-4 w-4 rounded border-border accent-primary"
/>
<span>
<span className="block text-sm font-medium text-foreground">Indexierung erlauben</span>
<span className="block text-xs text-muted-foreground">
Aus = noindex auf allen Seiten, robots.txt sperrt alles, Sitemap wird leer. Der Wartungsmodus
sperrt zusaetzlich automatisch.
</span>
</span>
</label>
</AppCard>
</section>
<section className="space-y-3">
<div>
<h2 className="text-lg font-semibold text-foreground">Verifizierung & Social</h2>
<p className="text-sm text-muted-foreground">
Codes aus Google Search Console / Bing Webmaster und das X-Handle fuer Twitter Cards.
</p>
</div>
<AppCard level={2} padding="sm" contentClassName="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="googleSiteVerification">Google Verification</Label>
<Input
id="googleSiteVerification"
name="googleSiteVerification"
defaultValue={settings.googleSiteVerification}
placeholder="google-site-verification Wert"
className="font-mono"
/>
</div>
<div className="space-y-2">
<Label htmlFor="bingSiteVerification">Bing Verification</Label>
<Input
id="bingSiteVerification"
name="bingSiteVerification"
defaultValue={settings.bingSiteVerification}
placeholder="msvalidate.01 Wert"
className="font-mono"
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="twitterHandle">X / Twitter Handle</Label>
<Input
id="twitterHandle"
name="twitterHandle"
defaultValue={settings.twitterHandle}
placeholder="@handle"
/>
</div>
</AppCard>
</section>
<section className="space-y-3">
<div>
<h2 className="text-lg font-semibold text-foreground">Strukturierte Daten</h2>
<p className="text-sm text-muted-foreground">
JSON-LD fuer Google: Wer steht hinter der Seite? Gilt fuer alle Seiten und jede Projekt-Ansicht.
</p>
</div>
<AppCard level={2} padding="sm" contentClassName="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="structuredDataType">Typ</Label>
<select
id="structuredDataType"
name="structuredDataType"
defaultValue={settings.structuredDataType}
className="flex h-10 w-full rounded-nested border border-input bg-background px-3 py-2 text-sm"
>
<option value="Person">Person (Freelancer / Portfolio)</option>
<option value="Organization">Organization (Studio / Firma)</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="structuredDataName">Name</Label>
<Input
id="structuredDataName"
name="structuredDataName"
defaultValue={settings.structuredDataName}
placeholder="Leer = Site Name"
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="structuredDataJobTitle">Job Title / Slogan</Label>
<Input
id="structuredDataJobTitle"
name="structuredDataJobTitle"
defaultValue={settings.structuredDataJobTitle}
placeholder="z.B. Brand & Motion Designer"
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="sameAs">Social Profile (eine https-URL pro Zeile)</Label>
<Textarea
id="sameAs"
name="sameAs"
rows={4}
defaultValue={settings.sameAs.join("\n")}
placeholder={"https://www.behance.net/...\nhttps://www.linkedin.com/in/..."}
className="font-mono text-xs"
/>
</div>
</AppCard>
</section>
<section className="space-y-3">
<div>
<h2 className="text-lg font-semibold text-foreground">Keywords</h2>
<p className="text-sm text-muted-foreground">
Kommagetrennt, pro Sprache. Geringe Gewichtung bei Google, aber nuetzlich fuer Bing und Struktur.
</p>
</div>
<AppCard level={2} padding="sm" contentClassName="grid gap-4">
{localeKeywordFields.map((field) => (
<div key={field.key} className="space-y-2">
<Label htmlFor={field.name}>{field.label}</Label>
<Input
id={field.name}
name={field.name}
defaultValue={settings.locales[field.key].keywords}
dir={field.key === "ar" ? "rtl" : "ltr"}
placeholder="branding, motion design, berlin"
/>
</div>
))}
</AppCard>
</section>
<div className="flex justify-end">
<Button type="submit">Save SEO Settings</Button>
</div>
</form>
<aside className="space-y-6">
<section className="space-y-3">
<div>
<h2 className="text-lg font-semibold text-foreground">Dateien</h2>
<p className="text-sm text-muted-foreground">Werden live aus den Einstellungen generiert.</p>
</div>
<div className="space-y-2">
<FileLink
href={links.sitemap}
label="sitemap.xml"
description={`${sitemapEntryCount} URLs, hreflang fuer DE/EN/AR`}
icon={MapIcon}
/>
<FileLink href={links.robots} label="robots.txt" description="Crawler-Regeln + Sitemap-Verweis" icon={Bot} />
<FileLink href={links.manifest} label="manifest.webmanifest" description="PWA / Icons" icon={FileCode2} />
</div>
</section>
<section className="space-y-3">
<div>
<h2 className="text-lg font-semibold text-foreground">Checkliste</h2>
<p className="text-sm text-muted-foreground">Status der wichtigsten SEO-Bausteine.</p>
</div>
<AppCard level={2} padding="sm" contentClassName="divide-y divide-border/60">
{checks.map((check) => (
<div key={check.id} className={cn("flex items-start gap-3 py-2.5 first:pt-0 last:pb-0")}>
<span className="mt-0.5 shrink-0">
<StatusIcon status={check.status} />
</span>
<div className="min-w-0">
<p className="text-sm font-medium text-foreground">{check.label}</p>
<p className="text-xs text-muted-foreground">{check.detail}</p>
</div>
</div>
))}
</AppCard>
</section>
</aside>
</div>
</div>
);
}
+4 -5
View File
@@ -1,5 +1,4 @@
import { StackedMarqueeSection, type MarqueeRow } from "@/components/layout/stacked-marquee-section";
import { AppCard } from "@/components/ui/app-card";
import { SectionHeading } from "./section-heading";
import type { MarqueeSectionCopy } from "./types";
@@ -10,18 +9,18 @@ type MarqueeSectionProps = {
export function MarqueeSection({ copy, rows }: MarqueeSectionProps) {
return (
<section className="space-y-8 overflow-hidden">
<section className="space-y-8">
<SectionHeading
eyebrow={copy.eyebrow}
title={copy.title}
description={copy.description}
/>
<AppCard className="overflow-hidden">
<div className="rounded-nested border border-border/70 bg-background py-6">
{/* Full-bleed band: breaks out of the centered container to span the
entire site width instead of sitting inside a card. */}
<div className="relative left-1/2 w-screen -translate-x-1/2 overflow-hidden border-y border-border/60 bg-background/40 py-8">
<StackedMarqueeSection rows={rows} className="py-0" />
</div>
</AppCard>
</section>
);
}
@@ -0,0 +1,25 @@
"use client";
import { ShieldCheck } from "lucide-react";
import { useLocale } from "next-intl";
import { buildAdminUrl } from "@/lib/admin-routing";
import { cn } from "@/lib/utils";
export function FloatingAdminButton() {
const locale = useLocale();
const isRtl = locale === "ar";
return (
<a
href={buildAdminUrl("/")}
aria-label="Admin"
className={cn(
"fixed bottom-5 z-50 flex h-11 w-11 items-center justify-center rounded-full border border-border/70 bg-background/90 text-foreground/70 shadow-lg backdrop-blur-chrome transition-all hover:scale-105 hover:bg-accent hover:text-foreground hover:shadow-xl",
isRtl ? "left-5" : "right-5",
)}
>
<ShieldCheck className="h-5 w-5" />
</a>
);
}
+1 -1
View File
@@ -19,7 +19,7 @@ type FloatingControlsProps = {
};
const pill =
"flex h-11 w-11 items-center justify-center rounded-full border border-border/60 bg-background/85 text-foreground/80 shadow-panel backdrop-blur-chrome transition-colors hover:bg-accent hover:text-foreground";
"flex h-11 w-11 items-center justify-center rounded-full border border-border/60 bg-background/85 text-foreground/80 backdrop-blur-chrome transition-colors hover:bg-accent hover:text-foreground";
/** Radius (px) of the arc the items fan out along. */
const RADIUS = 92;
@@ -6,15 +6,12 @@ import { usePathname } from "next/navigation";
import { useContext, useState, type ReactNode } from "react";
/**
* App Router swaps in the new page's content as soon as you navigate, so the
* outgoing page would normally vanish before it can animate out. `FrozenRouter`
* pins the layout-router context to the value it had when this instance
* mounted, keeping the *old* subtree rendered while its exit animation plays.
* Pins the layout-router context captured when this instance mounted. Each
* keyed page gets its own FrozenRouter, so the *outgoing* page keeps rendering
* its old route data while it animates out.
*/
function FrozenRouter({ children }: { children: ReactNode }) {
const context = useContext(LayoutRouterContext);
// Freeze the context captured at mount so the outgoing subtree keeps
// rendering (with its old route data) while its exit animation plays.
const [frozen] = useState(context);
if (!frozen) {
@@ -25,21 +22,23 @@ function FrozenRouter({ children }: { children: ReactNode }) {
}
/**
* One unified page transition on every navigation: the old page animates out
* (blur + rise up) before the new page animates in (blur + rise from below).
* One unified page transition on every navigation. Lives in the (persistent)
* site layout NOT in a `template.tsx`, which Next re-creates on each
* navigation and would reset AnimatePresence so nothing animates. The old page
* animates out (rise + blur), then the new page animates in from below.
*/
export default function SiteTemplate({ children }: { children: ReactNode }) {
export function PageTransition({ children }: { children: ReactNode }) {
const reducedMotion = useReducedMotion();
const pathname = usePathname();
return (
<AnimatePresence mode="wait" initial={false}>
<AnimatePresence mode="wait">
<motion.div
key={pathname}
initial={reducedMotion ? false : { opacity: 0, y: 18, filter: "blur(10px)" }}
initial={reducedMotion ? false : { opacity: 0, y: 20, filter: "blur(10px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={reducedMotion ? { opacity: 0 } : { opacity: 0, y: -18, filter: "blur(10px)" }}
transition={reducedMotion ? { duration: 0 } : { duration: 0.4, ease: [0.22, 1, 0.36, 1] }}
exit={reducedMotion ? { opacity: 0 } : { opacity: 0, y: -20, filter: "blur(10px)" }}
transition={reducedMotion ? { duration: 0 } : { duration: 0.45, ease: [0.22, 1, 0.36, 1] }}
>
<FrozenRouter>{children}</FrozenRouter>
</motion.div>
+53 -23
View File
@@ -1,9 +1,12 @@
"use client";
import { motion, useReducedMotion } from "framer-motion";
import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useLocale, useTranslations } from "next-intl";
import { useTheme } from "next-themes";
import { useState } from "react";
import { FloatingControls } from "@/components/layout/floating-controls";
import { Dock, DockIcon } from "@/components/ui/dock";
@@ -48,30 +51,49 @@ export function SiteDock({ defaultLocale, isSuperAdmin = false }: SiteDockProps)
const locale = useLocale();
const pathname = usePathname();
const t = useTranslations("navigation");
const reducedMotion = useReducedMotion();
const [bouncing, setBouncing] = useState<string | null>(null);
const { theme, resolvedTheme } = useTheme();
const activeTheme = theme === "system" ? resolvedTheme : theme;
const currentPath = stripLocalePrefix(pathname);
const homeHref = getLocalizedPath(locale, "/", defaultLocale);
// macOS-style launch hop on click.
const bounceProps = (id: string) =>
reducedMotion
? {}
: {
onClick: () => setBouncing(id),
animate: bouncing === id ? { y: [0, -5, 0, -2, 0] } : { y: 0 },
transition: { duration: 0.35, ease: [0.22, 1, 0.36, 1] as const },
onAnimationComplete: () => setBouncing((cur) => (cur === id ? null : cur)),
};
return (
<>
{/* ===== Bottom dock (Magic UI) — primary navigation ===== */}
{/* ===== Bottom dock — primary navigation ===== */}
<div
className="pointer-events-none fixed inset-x-0 bottom-0 z-40 flex justify-center px-4"
style={{ paddingBottom: "calc(0.9rem + env(safe-area-inset-bottom, 0px))" }}
>
<Dock
direction="bottom"
iconSize={52}
iconMagnification={72}
iconDistance={150}
className="pointer-events-auto h-[68px] gap-2.5 border-border/60 bg-background/70 shadow-panel"
iconSize={50}
iconMagnification={66}
iconDistance={140}
className="pointer-events-auto h-[66px] gap-3 border-border/60 bg-background/70"
>
{/* ── Logo (fixed src → no refresh flash) ── */}
<DockIcon className="group relative overflow-visible rounded-[26%] bg-white shadow-[0_6px_14px_-6px_rgba(0,0,0,0.5)] ring-1 ring-black/10">
<DockIcon className="group relative overflow-visible">
<Link
href={homeHref}
aria-label="mohfarawati — Home"
className="flex h-full w-full items-center justify-center rounded-[inherit]"
className="flex h-full w-full items-center justify-center"
>
<motion.span
className="flex h-full w-full items-center justify-center overflow-hidden rounded-[22%] bg-white ring-1 ring-black/10"
{...bounceProps("logo")}
>
<Image
src="/logos/light-primary.svg"
@@ -80,46 +102,45 @@ export function SiteDock({ defaultLocale, isSuperAdmin = false }: SiteDockProps)
height={104}
sizes="104px"
priority
className="h-full w-full rounded-[inherit] object-contain"
className="h-full w-full object-contain"
/>
</motion.span>
</Link>
<DockTip label="mohfarawati" />
</DockIcon>
<div className="mx-0.5 h-9 w-px self-center bg-border/70" aria-hidden />
{/* ── Pages ── */}
{/* ── Pages (icon art carries its own squircle shape) ── */}
{navItems.map(({ key, path, disabled }) => {
const itemPath = path || "/";
const isActive = isNavItemActive(currentPath, itemPath);
const label = t(key);
const src = `/dock/${key}${isActive ? "-active" : ""}.svg`;
const variant = activeTheme === "dark" ? "dark" : "light";
const src = `/dock/${key}-${variant}.png`;
const icon = (
<motion.span
className="flex h-full w-full items-center justify-center overflow-hidden rounded-[22%]"
{...(disabled ? {} : bounceProps(key))}
>
<Image
src={src}
alt={label}
width={104}
height={104}
sizes="104px"
className={cn("h-full w-full rounded-[inherit] object-contain", disabled && "opacity-55")}
className={cn("h-[110%] w-[110%] object-cover", disabled && "opacity-55")}
/>
</motion.span>
);
return (
<DockIcon
key={key}
className={cn(
"group relative overflow-visible rounded-[26%] transition-shadow duration-200",
isActive
? "shadow-[0_12px_26px_-8px_hsl(var(--primary)/0.65)]"
: "shadow-[0_6px_14px_-6px_rgba(0,0,0,0.5)] hover:shadow-[0_10px_22px_-8px_rgba(0,0,0,0.6)]",
)}
>
<DockIcon key={key} className="group relative overflow-visible">
{disabled ? (
<span
aria-disabled="true"
className="flex h-full w-full cursor-not-allowed items-center justify-center rounded-[inherit]"
className="flex h-full w-full cursor-not-allowed items-center justify-center"
>
{icon}
</span>
@@ -128,11 +149,20 @@ export function SiteDock({ defaultLocale, isSuperAdmin = false }: SiteDockProps)
href={getLocalizedPath(locale, itemPath, defaultLocale)}
aria-label={label}
aria-current={isActive ? "page" : undefined}
className="flex h-full w-full items-center justify-center rounded-[inherit]"
className="flex h-full w-full items-center justify-center"
>
{icon}
</Link>
)}
{/* macOS running indicator */}
{isActive ? (
<span
aria-hidden
className="pointer-events-none absolute -bottom-[7px] left-1/2 h-[5px] w-[5px] -translate-x-1/2 rounded-full bg-foreground/70"
/>
) : null}
<DockTip label={label} soon={disabled ? t("soon") : undefined} />
</DockIcon>
);
@@ -150,7 +180,7 @@ function DockTip({ label, soon }: { label: string; soon?: string }) {
return (
<span
role="tooltip"
className="pointer-events-none absolute bottom-[calc(100%+0.85rem)] left-1/2 -translate-x-1/2 translate-y-1 whitespace-nowrap rounded-lg border border-border/60 bg-background/90 px-3 py-1.5 text-sm font-semibold text-foreground opacity-0 shadow-md backdrop-blur-chrome transition-all duration-150 group-hover:translate-y-0 group-hover:opacity-100"
className="pointer-events-none absolute bottom-[calc(100%+0.85rem)] left-1/2 -translate-x-1/2 translate-y-1 whitespace-nowrap rounded-lg border border-border/60 bg-background/90 px-3 py-1.5 text-sm font-semibold text-foreground opacity-0 backdrop-blur-chrome transition-all duration-150 group-hover:translate-y-0 group-hover:opacity-100"
>
{label}
{soon ? <span className="ms-1.5 text-xs tracking-wide text-primary">{soon}</span> : null}
+14
View File
@@ -0,0 +1,14 @@
import { serializeJsonLd } from "@/lib/metadata";
/**
* Renders a JSON-LD `<script>` block. Server component only — the payload is
* serialized with `<` escaped so it can never break out of the script tag.
*/
export function JsonLd({ data }: { data: Record<string, unknown> }) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: serializeJsonLd(data) }}
/>
);
}
+4 -2
View File
@@ -123,10 +123,12 @@ const DockIcon = ({
[size, targetSize, size],
);
// Snappy, near-instant tracking with a touch of smoothing — closer to how the
// real macOS dock follows the cursor than a soft/bouncy spring.
const scaleSize = useSpring(sizeTransform, {
mass: 0.1,
stiffness: 150,
damping: 12,
stiffness: 280,
damping: 22,
});
return (
+9 -3
View File
@@ -181,8 +181,8 @@ i18n/routing.ts
Admin routing
lib/admin-routing.ts
Prisma access
lib/prisma.ts
Database access (Drizzle)
lib/db/index.ts, lib/db/schema.ts
Application configuration
lib/app-config.ts
@@ -191,7 +191,13 @@ Portfolio logic
lib/portfolio.ts
Media handling
lib/media.ts
lib/media.ts (DB), lib/media-storage.ts (filesystem + content validation), lib/media-service.ts
SEO / metadata
lib/metadata.ts, lib/seo-settings.ts, lib/seo-report.ts, app/robots.ts, app/sitemap.ts (docs/SEO.md)
Admin session token (shared by middleware and server auth)
lib/admin-session-token.ts
---
+11 -4
View File
@@ -14,8 +14,11 @@
- Contact form with:
- validation
- email delivery
- Success page after contact submission
- Maintenance redirect flow
- Success page after contact submission (noindex)
- Maintenance redirect flow (bypass requires a *signed* admin session cookie)
- SEO: localized metadata with canonical + hreflang, OG/Twitter cards (project
cover as share image), JSON-LD (WebSite + Person/Organization, CreativeWork per
project), dynamic `robots.txt` and hreflang `sitemap.xml` — see `docs/SEO.md`
### Admin
@@ -24,8 +27,12 @@
- Portfolio category management
- Portfolio project creation and editing
- Section and asset management inside each project
- Media library with usage bindings
- Site settings management
- Media library with usage bindings (uploads are magic-byte checked, SVGs are
sanitized and served sandboxed)
- Site settings management (Brand, Localization, SEO)
- SEO page: indexing switch, Search Console/Bing verification, X handle,
structured-data identity, per-locale keywords, readiness checklist and links
to sitemap/robots/manifest
- SMTP settings and test email
- Marquee settings
- Maintenance toggle
+83
View File
@@ -0,0 +1,83 @@
# SEO
How search visibility works in this project and where each piece is controlled.
Everything is data-driven from the admin; no code change is needed to adjust
titles, descriptions, indexing, verification, or structured data.
## Admin: Settings → SEO (`/site-settings/seo`)
Canonical page: `app/_admin/site-settings/seo/page.tsx` (mirrored under
`app/admin-internal/` and `app/root/`). Form: `components/admin/seo-settings-form.tsx`.
Action: `saveSeoSettingsAction` in `app/_admin/site-settings/actions.ts`.
Stored as one JSON blob in `app_config` under key `seo_settings`
(`lib/seo-settings.ts` parses/normalizes; `lib/app-config.ts` exposes
`getSeoSettings` / `updateSeoSettings`).
| Field | Effect |
|---|---|
| Indexierung erlauben | Off → `noindex,nofollow` meta on every page, `robots.txt` disallows `/`, `sitemap.xml` becomes empty. Maintenance mode forces the same automatically. |
| Google / Bing Verification | `<meta name="google-site-verification">` and `<meta name="msvalidate.01">` on all pages. Tokens are restricted to `[A-Za-z0-9_-]`. |
| X / Twitter Handle | `twitter:site` + `twitter:creator`. |
| Strukturierte Daten | Type (`Person` / `Organization`), name, job title/slogan, `sameAs` profile URLs → JSON-LD publisher on every public page. |
| Keywords (per locale) | `<meta name="keywords">` per language. |
The page also shows a **checklist** (`lib/seo-report.ts`) — indexing state, public
URL, meta description length per locale, OG image, favicon, verification,
structured data, published projects, sitemap URL count — and **open buttons** for
`/sitemap.xml`, `/robots.txt`, `/manifest.webmanifest`.
Titles, descriptions and the title template per locale live under
**Settings → Localization**; logos, favicon and the default OG image under
**Settings → Brand**.
## Generated files
- `app/robots.ts``/robots.txt`. Indexable: allow `/`, disallow admin
(`/admin-internal`, `/root`), `/api/`, `/success`, `/coming-soon` (+ locale
variants), plus the sitemap URL. Not indexable (setting off or maintenance):
disallow everything.
- `app/sitemap.ts``/sitemap.xml`. One entry per locale for home, about,
portfolio, contact, every category that has published projects, and every
published project — each with `xhtml:link hreflang` alternates and `x-default`.
Empty while maintenance mode is on or indexing is disabled.
- `app/manifest.ts``/manifest.webmanifest` (icons from Brand settings).
## Per-page metadata (`lib/metadata.ts`)
- `buildAppMetadata()` — root layout: `metadataBase`, robots, verification,
keywords, icons, manifest, OG (`og:locale` as `de_DE`/`en_US`/`ar_AR` +
`alternateLocale`), Twitter.
- `buildLocalizedMetadata({...})` — every public page: templated title,
description (≤300 chars), canonical + hreflang alternates, robots, OG, Twitter.
Options: `image` (page-specific share image), `noIndex`, `type: "article"`,
`publishedTime`.
- Portfolio project pages pass the project **cover** as OG image and `article`
type. This is independent of the project's view mode (`GRID` / `STORY` /
`CASE_STUDY`), so new view modes inherit full SEO automatically.
- `/success` and `/coming-soon` are `noindex`.
## Structured data (JSON-LD)
Rendered via `components/seo/json-ld.tsx` (server component; `<` is escaped).
- Site layout: `WebSite` + publisher (`Person` or `Organization`) graph linked by
`@id` (`buildSiteJsonLd`).
- Project page: `CreativeWork` with url, headline, description, image, genre
(category), keywords (service label, year), `datePublished`, author `@id`,
client as `sourceOrganization` (`buildProjectJsonLd`).
## Slugs
Categories and projects share `/portfolio/[slug]`; categories win at resolve
time. The admin therefore rejects a project slug that equals an existing
category slug and vice versa (`app/_admin/portfolio/actions.ts`).
## Operational checklist before launch
1. `NEXT_PUBLIC_SITE_URL` must be the public `https://` origin (canonical base).
2. Settings → Localization: site name + 50160 char description in DE/EN/AR.
3. Settings → Brand: default OG image (1200×630) + favicon.
4. Settings → SEO: indexing on, verification codes, Person/Organization data.
5. Maintenance mode off. Verify `/robots.txt` and `/sitemap.xml` from the SEO page.
6. Submit the sitemap in Google Search Console / Bing Webmaster.
+1 -1
View File
@@ -17,7 +17,7 @@ const config = [
},
},
{
ignores: ["prisma/seed.js"],
ignores: ["prisma/seed.js", "scripts/legacy-prisma-seed.cjs"],
},
];
+9 -27
View File
@@ -1,4 +1,4 @@
import { createHash, createHmac, timingSafeEqual } from "crypto";
import { createHash, timingSafeEqual } from "crypto";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
@@ -7,9 +7,13 @@ import { and, eq, like, lt } from "drizzle-orm";
import { db } from "./db";
import { appConfig } from "./db/schema";
import { getAdminAppPath } from "./admin-routing";
import {
ADMIN_SESSION_COOKIE as SHARED_ADMIN_SESSION_COOKIE,
buildAdminSessionToken,
verifyAdminSessionToken,
} from "./admin-session-token";
export const ADMIN_SESSION_COOKIE = "moh_admin_session";
const ADMIN_SESSION_VALUE = "superadmin";
export const ADMIN_SESSION_COOKIE = SHARED_ADMIN_SESSION_COOKIE;
const MAX_FAILED_ATTEMPTS = 5;
const LOCKOUT_SECONDS = 15 * 60;
const ADMIN_LOCKOUT_KEY_PREFIX = "admin_lockout";
@@ -91,34 +95,12 @@ function getAdminCookieDomain(): string | undefined {
return hostname ? `.${hostname}` : undefined;
}
function signValue(value: string): string {
return createHmac("sha256", getSecret()).update(value).digest("hex");
}
function buildToken(): string {
return `${ADMIN_SESSION_VALUE}.${signValue(ADMIN_SESSION_VALUE)}`;
return buildAdminSessionToken();
}
function verifyToken(token: string): boolean {
const parts = token.split(".");
if (parts.length !== 2) {
return false;
}
const [value, signature] = parts;
if (value !== ADMIN_SESSION_VALUE) {
return false;
}
const expected = signValue(value);
const left = Buffer.from(signature);
const right = Buffer.from(expected);
if (left.length !== right.length) {
return false;
}
return timingSafeEqual(left, right);
return verifyAdminSessionToken(token);
}
async function getClientIp(): Promise<string> {
+9 -1
View File
@@ -7,6 +7,7 @@ import {
Mail,
Palette,
PlusSquare,
Search,
ShieldAlert,
SwatchBook,
Tags,
@@ -24,6 +25,7 @@ type AdminNavigationCopy = {
siteSettings: string;
brandSettings?: string;
localizationSettings?: string;
seoSettings?: string;
marquee?: string;
smtp?: string;
};
@@ -41,7 +43,7 @@ export function getAdminNavigation(
copy: AdminNavigationCopy,
active: "overview" | "maintenance" | "ui-kit" | "portfolio" | "media" | "site-settings" | "smtp" | "marquee",
portfolioChild?: "overview" | "projects" | "new-project" | "categories",
siteSettingsChild?: "brand" | "localization",
siteSettingsChild?: "brand" | "localization" | "seo",
): AdminNavItem[] {
return [
{
@@ -87,6 +89,12 @@ export function getAdminNavigation(
icon: Languages,
active: siteSettingsChild === "localization",
},
{
label: copy.seoSettings ?? "SEO",
href: getAdminAppPath("/site-settings/seo"),
icon: Search,
active: siteSettingsChild === "seo",
},
],
},
{
+47
View File
@@ -0,0 +1,47 @@
import { createHmac, timingSafeEqual } from "crypto";
/**
* Pure helpers for the admin session cookie token. Kept free of `next/headers`
* and the database so the middleware (`proxy.ts`) can verify a session without
* pulling the server-only auth module into the edge/middleware bundle.
*/
export const ADMIN_SESSION_COOKIE = "moh_admin_session";
export const ADMIN_SESSION_VALUE = "superadmin";
function getSecret(): string {
return process.env.ADMIN_AUTH_SECRET ?? "";
}
function signValue(value: string): string {
return createHmac("sha256", getSecret()).update(value).digest("hex");
}
export function buildAdminSessionToken(): string {
return `${ADMIN_SESSION_VALUE}.${signValue(ADMIN_SESSION_VALUE)}`;
}
export function verifyAdminSessionToken(token: string | undefined | null): boolean {
if (!token || !getSecret()) {
return false;
}
const parts = token.split(".");
if (parts.length !== 2) {
return false;
}
const [value, signature] = parts;
if (value !== ADMIN_SESSION_VALUE) {
return false;
}
const expected = signValue(value);
const left = Buffer.from(signature);
const right = Buffer.from(expected);
if (left.length !== right.length) {
return false;
}
return timingSafeEqual(left, right);
}
+19
View File
@@ -67,6 +67,13 @@ import {
syncMarqueeSettingsToGermanSource,
type MarqueeSettings,
} from "./marquee-settings";
import { SEO_SETTINGS_KEY, buildDefaultSeoSettings, parseSeoSettingsValue, type SeoSettings } from "./seo-settings";
export {
SEO_SETTINGS_KEY,
buildDefaultSeoSettings,
parseSeoSettingsValue,
type SeoSettings,
} from "./seo-settings";
// Small helpers over the app_config key/value table (Drizzle).
async function readConfigValue(key: string): Promise<string | undefined> {
@@ -150,6 +157,18 @@ export async function updateMarqueeSettings(settings: MarqueeSettings): Promise<
await upsertConfig(MARQUEE_SETTINGS_KEY, JSON.stringify(normalizedSettings));
}
export async function getSeoSettings(): Promise<SeoSettings> {
try {
return parseSeoSettingsValue(await readConfigValue(SEO_SETTINGS_KEY));
} catch {
return buildDefaultSeoSettings();
}
}
export async function updateSeoSettings(settings: SeoSettings): Promise<void> {
await upsertConfig(SEO_SETTINGS_KEY, JSON.stringify(settings));
}
export async function getSiteSettingsMediaBindings(): Promise<SiteSettingsMediaBindings> {
try {
const usages = await db
+3 -2
View File
@@ -125,7 +125,8 @@ export async function createStandaloneMediaAsset(input: {
uploadFile: FormDataEntryValue | null;
}) {
if (input.uploadFile instanceof File && input.uploadFile.size > 0) {
const savedFile = await saveMediaUpload(input.uploadFile, input.kind.toLowerCase());
const kind = getKindFromUploadFile(input.uploadFile);
const savedFile = await saveMediaUpload(input.uploadFile, kind.toLowerCase());
const derivedLabel = input.uploadFile.name.replace(/\.[^.]+$/, "").trim();
const trimmedLabel = input.label.trim() || derivedLabel || "Media asset";
@@ -135,7 +136,7 @@ export async function createStandaloneMediaAsset(input: {
return createMediaAsset({
source: MediaSource.UPLOAD,
kind: input.kind,
kind,
url: savedFile.url,
fileName: savedFile.fileName,
label: trimmedLabel,
+61 -5
View File
@@ -37,16 +37,65 @@ export function resolveMediaUploadPath(filePath: string) {
throw new Error("Only managed media uploads can be resolved.");
}
const relativePath = filePath.replace("/uploads/media/", "");
const relativePath = filePath.slice("/uploads/media/".length);
if (!relativePath || relativePath.includes("\0")) {
throw new Error("Resolved media upload path escapes the upload root.");
}
const absolutePath = path.resolve(MEDIA_UPLOAD_ROOT, relativePath);
if (!absolutePath.startsWith(MEDIA_UPLOAD_ROOT)) {
// `startsWith(root)` alone would accept a sibling directory such as
// `.../uploads/media-evil/...`; require the separator so only true children pass.
if (absolutePath !== MEDIA_UPLOAD_ROOT && !absolutePath.startsWith(MEDIA_UPLOAD_ROOT + path.sep)) {
throw new Error("Resolved media upload path escapes the upload root.");
}
if (absolutePath === MEDIA_UPLOAD_ROOT) {
throw new Error("Resolved media upload path escapes the upload root.");
}
return absolutePath;
}
const MAGIC_SIGNATURES: Record<string, Array<{ offset: number; bytes: number[] }>> = {
".png": [{ offset: 0, bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] }],
".jpg": [{ offset: 0, bytes: [0xff, 0xd8, 0xff] }],
".gif": [{ offset: 0, bytes: [0x47, 0x49, 0x46, 0x38] }],
".webp": [
{ offset: 0, bytes: [0x52, 0x49, 0x46, 0x46] },
{ offset: 8, bytes: [0x57, 0x45, 0x42, 0x50] },
],
".pdf": [{ offset: 0, bytes: [0x25, 0x50, 0x44, 0x46] }],
".ico": [{ offset: 0, bytes: [0x00, 0x00, 0x01, 0x00] }],
};
const SVG_FORBIDDEN_PATTERN = /<script[\s>]|javascript:|on[a-z]+\s*=|<foreignObject|<iframe|<embed|<object|xlink:href\s*=\s*["']\s*(?!#|data:image\/)/i;
/**
* Verify that the file bytes match the extension derived from the declared
* MIME type. The browser-supplied `file.type` is untrusted: without this check
* an HTML/JS payload could be stored as `.png` and served from our origin.
*/
export function isMediaContentValid(extension: string, buffer: Buffer): boolean {
if (extension === ".svg") {
const head = buffer.subarray(0, 4096).toString("utf8").trimStart();
const looksLikeSvg = head.startsWith("<svg") || (head.startsWith("<?xml") && /<svg[\s>]/i.test(head));
return looksLikeSvg && !SVG_FORBIDDEN_PATTERN.test(buffer.toString("utf8"));
}
const signatures = MAGIC_SIGNATURES[extension];
if (!signatures) {
return false;
}
return signatures.every(({ offset, bytes }) =>
bytes.every((byte, index) => buffer[offset + index] === byte),
);
}
export async function removeManagedMediaFile(filePath: string | null | undefined) {
if (!isManagedMediaFilePath(filePath)) {
return false;
@@ -76,16 +125,23 @@ export async function saveMediaUpload(file: File, folder: string) {
throw new Error("File is too large.");
}
const buffer = Buffer.from(await file.arrayBuffer());
if (!isMediaContentValid(extension, buffer)) {
throw new Error("File content does not match its declared type.");
}
const safeFolder = sanitizeBaseName(folder) || "misc";
const safeBaseName = sanitizeBaseName(file.name.replace(/\.[^.]+$/, "")) || "asset";
const finalName = `${safeBaseName}-${randomUUID().slice(0, 8)}${extension}`;
const targetDir = path.join(MEDIA_UPLOAD_ROOT, folder);
const targetDir = path.join(MEDIA_UPLOAD_ROOT, safeFolder);
const targetPath = path.join(targetDir, finalName);
await mkdir(targetDir, { recursive: true });
await writeFile(targetPath, Buffer.from(await file.arrayBuffer()));
await writeFile(targetPath, buffer);
return {
url: `/uploads/media/${folder}/${finalName}`,
url: `/uploads/media/${safeFolder}/${finalName}`,
fileName: finalName,
mimeType: file.type,
size: file.size,
+3 -1
View File
@@ -30,7 +30,9 @@ export const mediaFieldInputSchema = z
});
}
if (value.url && !/^https?:\/\//.test(value.url) && !value.url.startsWith("/")) {
const isRootRelative = value.url.startsWith("/") && !value.url.startsWith("//");
if (value.url && !/^https?:\/\//i.test(value.url) && !isRootRelative) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ["url"],
+201 -18
View File
@@ -11,17 +11,50 @@ import {
getSiteSettings,
getSiteSettingsMediaBindings,
} from "./app-config";
import { AppLocale, getLocalizedPath, getLocalizedPathWithDefault, resolveLocale } from "./locale";
import { buildSiteIconUrls } from "./site-icons";
function getSiteUrl(): URL {
export function getSiteUrl(): URL {
return new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "https://mohfarawati.de");
}
function toAbsoluteUrl(pathname: string): string {
export function toAbsoluteUrl(pathname: string): string {
return new URL(pathname, getSiteUrl()).toString();
}
const NOINDEX_ROBOTS: Metadata["robots"] = {
index: false,
follow: false,
nocache: true,
googleBot: { index: false, follow: false, noimageindex: true },
};
const INDEX_ROBOTS: Metadata["robots"] = {
index: true,
follow: true,
googleBot: { index: true, follow: true, "max-image-preview": "large", "max-snippet": -1, "max-video-preview": -1 },
};
export function buildRobotsMetadata(seo: SeoSettings, noIndex = false): Metadata["robots"] {
return seo.allowIndexing && !noIndex ? INDEX_ROBOTS : NOINDEX_ROBOTS;
}
function buildVerification(seo: SeoSettings): Metadata["verification"] {
const verification: NonNullable<Metadata["verification"]> = {};
if (seo.googleSiteVerification) {
verification.google = seo.googleSiteVerification;
}
if (seo.bingSiteVerification) {
verification.other = { "msvalidate.01": seo.bingSiteVerification };
}
return Object.keys(verification).length > 0 ? verification : undefined;
}
import { AppLocale, getLocalizedPath, getLocalizedPathWithDefault, resolveLocale } from "./locale";
import { getSeoSettings } from "./app-config";
import { buildDefaultSeoSettings, toOpenGraphLocale, type SeoSettings } from "./seo-settings";
import { buildSiteIconUrls } from "./site-icons";
export function buildLocaleAlternates(pathname: string, defaultLocale: AppLocale) {
const languages = Object.fromEntries(
appLocales.map((locale) => [locale, toAbsoluteUrl(getLocalizedPathWithDefault(locale, pathname, defaultLocale))]),
@@ -46,7 +79,7 @@ export function applyTitleTemplateFn(title: string, template: string, siteName:
.replace(PAGE_TITLE_TOKEN, title);
}
function buildMetadataImages(imageUrl?: string | null) {
function buildMetadataImages(imageUrl?: string | null, alt?: string) {
if (!imageUrl) {
return undefined;
}
@@ -54,22 +87,29 @@ function buildMetadataImages(imageUrl?: string | null) {
return [
{
url: toAbsoluteUrl(imageUrl),
alt,
},
];
}
export async function buildAppMetadata(): Promise<Metadata> {
const [settings, bindings] = await Promise.all([getSiteSettings(), getSiteSettingsMediaBindings()]);
const [settings, bindings, seo] = await Promise.all([
getSiteSettings(),
getSiteSettingsMediaBindings(),
getSeoSettings(),
]);
return buildAppMetadataFromConfig(settings, bindings);
return buildAppMetadataFromConfig(settings, bindings, seo);
}
export function buildAppMetadataFromConfig(
settings: SiteSettings,
bindings: SiteSettingsMediaBindings,
seo: SeoSettings = buildDefaultSeoSettings(),
): Metadata {
const defaultLocaleSettings = settings.locales[settings.defaultLocale];
const openGraphImages = buildMetadataImages(bindings.defaultOgImage?.url);
const openGraphImages = buildMetadataImages(bindings.defaultOgImage?.url, defaultLocaleSettings.siteName);
const keywords = seo.locales[settings.defaultLocale].keywords;
const siteIconUrls = buildSiteIconUrls({
siteName: defaultLocaleSettings.siteName,
faviconVersion: bindings.favicon?.version,
@@ -81,6 +121,10 @@ export function buildAppMetadataFromConfig(
title: defaultLocaleSettings.siteName,
description: defaultLocaleSettings.siteDescription,
applicationName: defaultLocaleSettings.siteName,
keywords: keywords ? keywords.split(",").map((keyword) => keyword.trim()).filter(Boolean) : undefined,
robots: buildRobotsMetadata(seo),
verification: buildVerification(seo),
formatDetection: { telephone: false },
manifest: siteIconUrls.manifestHref,
icons: {
icon: [{ url: siteIconUrls.faviconHref }],
@@ -92,7 +136,8 @@ export function buildAppMetadataFromConfig(
description: defaultLocaleSettings.siteDescription,
url: toAbsoluteUrl(getLocalizedPathWithDefault(settings.defaultLocale, "/", settings.defaultLocale)),
siteName: defaultLocaleSettings.siteName,
locale: settings.defaultLocale,
locale: toOpenGraphLocale(settings.defaultLocale),
alternateLocale: appLocales.filter((locale) => locale !== settings.defaultLocale).map(toOpenGraphLocale),
type: "website",
images: openGraphImages,
},
@@ -100,12 +145,25 @@ export function buildAppMetadataFromConfig(
card: openGraphImages ? "summary_large_image" : "summary",
title: defaultLocaleSettings.siteName,
description: defaultLocaleSettings.siteDescription,
site: seo.twitterHandle || undefined,
creator: seo.twitterHandle || undefined,
images: openGraphImages?.map((image) => image.url),
},
};
}
type LocalizedMetadataInput = {
type LocalizedMetadataOptions = {
/** Page-specific share image (e.g. a project cover). Falls back to the default OG image. */
image?: string | null;
/** Force `noindex` (thank-you pages, coming-soon, etc.). */
noIndex?: boolean;
/** Open Graph object type. Portfolio projects use `article`. */
type?: "website" | "article";
publishedTime?: Date | null;
modifiedTime?: Date | null;
};
type LocalizedMetadataInput = LocalizedMetadataOptions & {
locale: string;
pathname: string;
title: string;
@@ -119,65 +177,190 @@ export async function buildLocalizedMetadata({
title,
description,
applyTitleTemplate,
...options
}: LocalizedMetadataInput): Promise<Metadata> {
const [settings, bindings] = await Promise.all([getSiteSettings(), getSiteSettingsMediaBindings()]);
const [settings, bindings, seo] = await Promise.all([
getSiteSettings(),
getSiteSettingsMediaBindings(),
getSeoSettings(),
]);
const localeKey = resolveLocale(locale, settings.defaultLocale);
return buildLocalizedMetadataFromConfig({
settings,
bindings,
seo,
locale: localeKey,
pathname,
title,
description,
applyTitleTemplate,
...options,
});
}
export function buildLocalizedMetadataFromConfig(input: {
export function buildLocalizedMetadataFromConfig(
input: LocalizedMetadataOptions & {
settings: SiteSettings;
bindings: SiteSettingsMediaBindings;
seo?: SeoSettings;
locale: AppLocale;
pathname: string;
title: string;
description?: string;
applyTitleTemplate?: boolean;
}): Metadata {
},
): Metadata {
const {
settings,
bindings,
seo = buildDefaultSeoSettings(),
locale,
pathname,
title,
description,
applyTitleTemplate = true,
image,
noIndex = false,
type = "website",
publishedTime,
modifiedTime,
} = input;
const localeKey = resolveLocale(locale, settings.defaultLocale);
const localeSettings = settings.locales[localeKey];
const resolvedDescription = description?.trim() || localeSettings.siteDescription;
const resolvedDescription = (description?.trim() || localeSettings.siteDescription).slice(0, 300);
const resolvedTitle = applyTitleTemplate
? applyTitleTemplateFn(title, localeSettings.titleTemplate, localeSettings.siteName)
: title;
const openGraphImages = buildMetadataImages(bindings.defaultOgImage?.url);
const openGraphImages = buildMetadataImages(image || bindings.defaultOgImage?.url, title);
const keywords = seo.locales[localeKey].keywords;
return {
title: resolvedTitle,
description: resolvedDescription,
keywords: keywords ? keywords.split(",").map((keyword) => keyword.trim()).filter(Boolean) : undefined,
robots: buildRobotsMetadata(seo, noIndex),
alternates: buildLocaleAlternates(pathname, settings.defaultLocale),
openGraph: {
title: resolvedTitle,
description: resolvedDescription,
url: toAbsoluteUrl(getLocalizedPath(localeKey, pathname, settings.defaultLocale)),
siteName: localeSettings.siteName,
locale: localeKey,
type: "website",
locale: toOpenGraphLocale(localeKey),
alternateLocale: appLocales.filter((entry) => entry !== localeKey).map(toOpenGraphLocale),
images: openGraphImages,
...(type === "article"
? {
type: "article" as const,
publishedTime: publishedTime?.toISOString(),
modifiedTime: (modifiedTime ?? publishedTime)?.toISOString(),
}
: { type: "website" as const }),
},
twitter: {
card: openGraphImages ? "summary_large_image" : "summary",
title: resolvedTitle,
description: resolvedDescription,
images: openGraphImages?.map((image) => image.url),
site: seo.twitterHandle || undefined,
creator: seo.twitterHandle || undefined,
images: openGraphImages?.map((entry) => entry.url),
},
};
}
// --- JSON-LD ----------------------------------------------------------------
type JsonLd = Record<string, unknown>;
/** WebSite + publisher (Person/Organization) graph for the home page. */
export function buildSiteJsonLd(input: {
settings: SiteSettings;
seo: SeoSettings;
bindings: SiteSettingsMediaBindings;
locale: AppLocale;
}): JsonLd {
const { settings, seo, bindings, locale } = input;
const localeSettings = settings.locales[locale];
const siteUrl = getSiteUrl().toString();
const publisherName = seo.structuredDataName || localeSettings.siteName;
const logoUrl = bindings.siteLogoLight?.url ?? bindings.defaultOgImage?.url ?? null;
const publisher: JsonLd = {
"@type": seo.structuredDataType,
"@id": `${siteUrl}#${seo.structuredDataType.toLowerCase()}`,
name: publisherName,
url: siteUrl,
};
if (seo.structuredDataJobTitle) {
publisher[seo.structuredDataType === "Person" ? "jobTitle" : "slogan"] = seo.structuredDataJobTitle;
}
if (logoUrl) {
publisher[seo.structuredDataType === "Person" ? "image" : "logo"] = toAbsoluteUrl(logoUrl);
}
if (seo.sameAs.length > 0) {
publisher.sameAs = seo.sameAs;
}
return {
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebSite",
"@id": `${siteUrl}#website`,
url: siteUrl,
name: localeSettings.siteName,
description: localeSettings.siteDescription || undefined,
inLanguage: appLocales,
publisher: { "@id": publisher["@id"] },
},
publisher,
],
};
}
/** CreativeWork for a single portfolio project (any view mode). */
export function buildProjectJsonLd(input: {
settings: SiteSettings;
seo: SeoSettings;
locale: AppLocale;
pathname: string;
title: string;
description: string;
image?: string | null;
datePublished?: Date | null;
dateModified?: Date | null;
genre?: string;
keywords?: string[];
clientName?: string;
}): JsonLd {
const { settings, seo, locale, pathname } = input;
const siteUrl = getSiteUrl().toString();
const url = toAbsoluteUrl(getLocalizedPath(locale, pathname, settings.defaultLocale));
return {
"@context": "https://schema.org",
"@type": "CreativeWork",
"@id": `${url}#work`,
url,
name: input.title,
headline: input.title,
description: input.description || undefined,
image: input.image ? toAbsoluteUrl(input.image) : undefined,
inLanguage: locale,
genre: input.genre || undefined,
keywords: input.keywords && input.keywords.length > 0 ? input.keywords.join(", ") : undefined,
datePublished: input.datePublished?.toISOString(),
dateModified: (input.dateModified ?? input.datePublished)?.toISOString(),
author: { "@id": `${siteUrl}#${seo.structuredDataType.toLowerCase()}` },
sourceOrganization: input.clientName ? { "@type": "Organization", name: input.clientName } : undefined,
isPartOf: { "@id": `${siteUrl}#website` },
};
}
/** Serialize JSON-LD safely for a `<script type="application/ld+json">` tag. */
export function serializeJsonLd(data: JsonLd): string {
return JSON.stringify(data).replace(/</g, "\\u003c");
}
+124
View File
@@ -0,0 +1,124 @@
import { appLocales } from "../i18n/routing";
import type { SeoSettings } from "./seo-settings";
import type { SiteSettings, SiteSettingsMediaBindings } from "./site-settings";
export type SeoCheckStatus = "ok" | "warn" | "error";
export type SeoCheck = {
id: string;
label: string;
status: SeoCheckStatus;
detail: string;
};
/**
* Pure readiness checklist shown on the admin SEO page. Every input comes from
* the caller so the same function is testable without a database.
*/
export function buildSeoChecklist(input: {
seo: SeoSettings;
settings: SiteSettings;
bindings: SiteSettingsMediaBindings;
maintenanceEnabled: boolean;
publishedProjectCount: number;
sitemapEntryCount: number;
siteUrl: string;
}): SeoCheck[] {
const { seo, settings, bindings, maintenanceEnabled, publishedProjectCount, sitemapEntryCount, siteUrl } = input;
const checks: SeoCheck[] = [];
checks.push({
id: "indexing",
label: "Indexierung",
status: seo.allowIndexing && !maintenanceEnabled ? "ok" : "error",
detail: maintenanceEnabled
? "Wartungsmodus aktiv: robots.txt sperrt alles, Sitemap ist leer."
: seo.allowIndexing
? "Suchmaschinen duerfen die Seite indexieren."
: "Indexierung ist deaktiviert (noindex + robots disallow).",
});
checks.push({
id: "site-url",
label: "Oeffentliche URL",
status: /^https:\/\//.test(siteUrl) && !/localhost|127\.0\.0\.1/.test(siteUrl) ? "ok" : "warn",
detail: `Canonical Basis: ${siteUrl}`,
});
for (const locale of appLocales) {
const localeSettings = settings.locales[locale];
const descriptionLength = localeSettings.siteDescription.length;
const status: SeoCheckStatus =
descriptionLength === 0 ? "error" : descriptionLength < 50 || descriptionLength > 160 ? "warn" : "ok";
checks.push({
id: `description-${locale}`,
label: `Meta Description (${locale.toUpperCase()})`,
status,
detail:
descriptionLength === 0
? "Fehlt. Wird unter Settings > Localization gepflegt."
: `${descriptionLength} Zeichen (empfohlen 50-160).`,
});
}
checks.push({
id: "og-image",
label: "Standard Share Bild (OG)",
status: bindings.defaultOgImage ? "ok" : "warn",
detail: bindings.defaultOgImage
? "Gesetzt. Projekte nutzen ihr Cover, alle anderen Seiten dieses Bild."
: "Nicht gesetzt. Links ohne Vorschaubild. Unter Settings > Brand pflegen.",
});
checks.push({
id: "favicon",
label: "Favicon",
status: bindings.favicon ? "ok" : "warn",
detail: bindings.favicon ? "Gesetzt." : "Nicht gesetzt (Fallback-Icon wird generiert).",
});
checks.push({
id: "verification",
label: "Search Console / Bing",
status: seo.googleSiteVerification || seo.bingSiteVerification ? "ok" : "warn",
detail:
seo.googleSiteVerification || seo.bingSiteVerification
? "Verification Meta Tags werden ausgegeben."
: "Kein Verification Code hinterlegt.",
});
checks.push({
id: "structured-data",
label: "Strukturierte Daten (JSON-LD)",
status: seo.structuredDataName || settings.locales[settings.defaultLocale].siteName ? "ok" : "warn",
detail: `${seo.structuredDataType} + WebSite auf allen Seiten, CreativeWork pro Projekt.`,
});
checks.push({
id: "projects",
label: "Veroeffentlichte Projekte",
status: publishedProjectCount > 0 ? "ok" : "warn",
detail:
publishedProjectCount > 0
? `${publishedProjectCount} Projekt(e) in der Sitemap.`
: "Noch kein Projekt veroeffentlicht. Portfolio-Seiten sind leer.",
});
checks.push({
id: "sitemap",
label: "Sitemap",
status: sitemapEntryCount > 0 ? "ok" : maintenanceEnabled || !seo.allowIndexing ? "warn" : "error",
detail: `${sitemapEntryCount} URL(s) in /sitemap.xml (alle Sprachen, mit hreflang).`,
});
return checks;
}
export function summarizeSeoChecklist(checks: SeoCheck[]) {
return {
ok: checks.filter((check) => check.status === "ok").length,
warn: checks.filter((check) => check.status === "warn").length,
error: checks.filter((check) => check.status === "error").length,
};
}
+145
View File
@@ -0,0 +1,145 @@
import type { AppLocale } from "./locale";
/**
* Site-wide SEO configuration stored as one JSON blob in `app_config`
* (key `seo_settings`). Everything here is pure: parsing/normalizing only.
* Read/write goes through `lib/app-config.ts`.
*/
export const SEO_SETTINGS_KEY = "seo_settings";
export type SeoStructuredDataType = "Person" | "Organization";
export type SeoLocaleSettings = {
/** Comma-separated keywords (optional, low SEO weight but harmless). */
keywords: string;
};
export type SeoSettings = {
/** Master switch: false → robots disallow all + `noindex` on every page. */
allowIndexing: boolean;
/** `google-site-verification` meta value. */
googleSiteVerification: string;
/** `msvalidate.01` meta value (Bing Webmaster). */
bingSiteVerification: string;
/** `@handle` used for twitter:site / twitter:creator. */
twitterHandle: string;
/** Publisher shape used for JSON-LD on the home page. */
structuredDataType: SeoStructuredDataType;
/** Name shown in JSON-LD (falls back to the site name when empty). */
structuredDataName: string;
/** Person job title / Organization tagline used in JSON-LD. */
structuredDataJobTitle: string;
/** Social profile URLs for `sameAs` in JSON-LD. */
sameAs: string[];
locales: Record<AppLocale, SeoLocaleSettings>;
};
export function buildDefaultSeoSettings(): SeoSettings {
return {
allowIndexing: true,
googleSiteVerification: "",
bingSiteVerification: "",
twitterHandle: "",
structuredDataType: "Person",
structuredDataName: "",
structuredDataJobTitle: "",
sameAs: [],
locales: {
ar: { keywords: "" },
en: { keywords: "" },
de: { keywords: "" },
},
};
}
function normalizeText(value: unknown, maxLength = 500): string {
return typeof value === "string" ? value.trim().slice(0, maxLength) : "";
}
/** Meta verification tokens are alphanumeric with `-` / `_`; anything else is dropped. */
export function normalizeVerificationToken(value: unknown): string {
const text = normalizeText(value, 200);
return /^[A-Za-z0-9_-]+$/.test(text) ? text : "";
}
export function normalizeTwitterHandle(value: unknown): string {
const text = normalizeText(value, 60).replace(/^https?:\/\/(www\.)?(twitter|x)\.com\//i, "").replace(/^@+/, "");
return /^[A-Za-z0-9_]{1,15}$/.test(text) ? `@${text}` : "";
}
export function normalizeSameAs(value: unknown): string[] {
const rawList = Array.isArray(value)
? value
: typeof value === "string"
? value.split(/[\n,]+/)
: [];
const urls = rawList
.map((entry) => normalizeText(entry, 500))
.filter((entry) => /^https:\/\/[^\s]+$/i.test(entry));
return Array.from(new Set(urls)).slice(0, 20);
}
export function normalizeStructuredDataType(value: unknown): SeoStructuredDataType {
return value === "Organization" ? "Organization" : "Person";
}
export function normalizeKeywords(value: unknown): string {
const text = normalizeText(value, 1000);
return text
.split(",")
.map((keyword) => keyword.trim())
.filter(Boolean)
.slice(0, 30)
.join(", ");
}
export function parseSeoSettingsValue(rawValue: string | null | undefined): SeoSettings {
const defaults = buildDefaultSeoSettings();
if (!rawValue) {
return defaults;
}
try {
const parsed = JSON.parse(rawValue) as Record<string, unknown>;
const locales =
parsed.locales && typeof parsed.locales === "object"
? (parsed.locales as Record<string, Record<string, unknown> | undefined>)
: {};
return {
allowIndexing: parsed.allowIndexing !== false,
googleSiteVerification: normalizeVerificationToken(parsed.googleSiteVerification),
bingSiteVerification: normalizeVerificationToken(parsed.bingSiteVerification),
twitterHandle: normalizeTwitterHandle(parsed.twitterHandle),
structuredDataType: normalizeStructuredDataType(parsed.structuredDataType),
structuredDataName: normalizeText(parsed.structuredDataName, 120),
structuredDataJobTitle: normalizeText(parsed.structuredDataJobTitle, 160),
sameAs: normalizeSameAs(parsed.sameAs),
locales: {
ar: { keywords: normalizeKeywords(locales.ar?.keywords) },
en: { keywords: normalizeKeywords(locales.en?.keywords) },
de: { keywords: normalizeKeywords(locales.de?.keywords) },
},
};
} catch {
return defaults;
}
}
/** Map an app locale to the Open Graph `og:locale` format. */
export function toOpenGraphLocale(locale: AppLocale): string {
switch (locale) {
case "ar":
return "ar_AR";
case "en":
return "en_US";
default:
return "de_DE";
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ import { isManagedMediaFilePath, resolveMediaUploadPath } from "./media-storage"
const INTERNAL_FAVICON_PATH = "/favicon.ico";
const INTERNAL_APPLE_ICON_PATH = "/apple-icon.png";
const INTERNAL_MANIFEST_PATH = "/manifest.webmanifest";
export const INTERNAL_MANIFEST_PATH = "/manifest.webmanifest";
const DEFAULT_ICON_VERSION = "default";
const TRANSPARENT_PNG_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9sot5WQAAAAASUVORK5CYII=";
+2 -3
View File
@@ -16,6 +16,7 @@ import {
isLegacyAdminPath,
toInternalAdminPath,
} from "./lib/admin-routing";
import { ADMIN_SESSION_COOKIE, verifyAdminSessionToken } from "./lib/admin-session-token";
import {
FALLBACK_LOCALE,
getLocalizedPathWithDefault,
@@ -23,8 +24,6 @@ import {
stripLocalePrefix,
} from "./lib/locale";
const ADMIN_SESSION_COOKIE = "moh_admin_session";
type SiteRuntimeState = {
defaultLocale: (typeof appLocales)[number];
maintenanceEnabled: boolean;
@@ -220,7 +219,7 @@ export default async function middleware(request: NextRequest) {
if (
siteRuntimeState.maintenanceEnabled &&
!request.cookies.has(ADMIN_SESSION_COOKIE) &&
!verifyAdminSessionToken(request.cookies.get(ADMIN_SESSION_COOKIE)?.value) &&
!isComingSoonPath(pathname)
) {
const locale = getPathLocale(pathname, configuredDefaultLocale);
-12
View File
@@ -1,12 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#ff8a5f"/><stop offset="1" stop-color="#e5431f"/></linearGradient>
<linearGradient id="gl" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#fff" stop-opacity="0.26"/><stop offset="0.5" stop-color="#fff" stop-opacity="0"/></linearGradient>
</defs>
<rect x="0" y="0" width="100" height="100" rx="28" fill="url(#bg)"/>
<rect x="0" y="0" width="100" height="100" rx="28" fill="url(#gl)"/>
<rect x="0.75" y="0.75" width="98.5" height="98.5" rx="27.25" fill="none" stroke="#fff" stroke-opacity="0.14" stroke-width="1.5"/>
<g fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round">
<circle cx="50" cy="41" r="11"/><path d="M30 72 C30 57, 70 57, 70 72"/></g>
</svg>

Before

Width:  |  Height:  |  Size: 853 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

-12
View File
@@ -1,12 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#454b57"/><stop offset="1" stop-color="#1c2027"/></linearGradient>
<linearGradient id="gl" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#fff" stop-opacity="0.26"/><stop offset="0.5" stop-color="#fff" stop-opacity="0"/></linearGradient>
</defs>
<rect x="0" y="0" width="100" height="100" rx="28" fill="url(#bg)"/>
<rect x="0" y="0" width="100" height="100" rx="28" fill="url(#gl)"/>
<rect x="0.75" y="0.75" width="98.5" height="98.5" rx="27.25" fill="none" stroke="#fff" stroke-opacity="0.14" stroke-width="1.5"/>
<g fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round">
<circle cx="50" cy="41" r="11"/><path d="M30 72 C30 57, 70 57, 70 72"/></g>
</svg>

Before

Width:  |  Height:  |  Size: 853 B

-12
View File
@@ -1,12 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#ff8a5f"/><stop offset="1" stop-color="#e5431f"/></linearGradient>
<linearGradient id="gl" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#fff" stop-opacity="0.26"/><stop offset="0.5" stop-color="#fff" stop-opacity="0"/></linearGradient>
</defs>
<rect x="0" y="0" width="100" height="100" rx="28" fill="url(#bg)"/>
<rect x="0" y="0" width="100" height="100" rx="28" fill="url(#gl)"/>
<rect x="0.75" y="0.75" width="98.5" height="98.5" rx="27.25" fill="none" stroke="#fff" stroke-opacity="0.14" stroke-width="1.5"/>
<g fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round">
<rect x="27" y="35" width="46" height="30" rx="6"/><path d="M30 40 L50 54 L70 40"/></g>
</svg>

Before

Width:  |  Height:  |  Size: 865 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

-12
View File
@@ -1,12 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#454b57"/><stop offset="1" stop-color="#1c2027"/></linearGradient>
<linearGradient id="gl" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#fff" stop-opacity="0.26"/><stop offset="0.5" stop-color="#fff" stop-opacity="0"/></linearGradient>
</defs>
<rect x="0" y="0" width="100" height="100" rx="28" fill="url(#bg)"/>
<rect x="0" y="0" width="100" height="100" rx="28" fill="url(#gl)"/>
<rect x="0.75" y="0.75" width="98.5" height="98.5" rx="27.25" fill="none" stroke="#fff" stroke-opacity="0.14" stroke-width="1.5"/>
<g fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round">
<rect x="27" y="35" width="46" height="30" rx="6"/><path d="M30 40 L50 54 L70 40"/></g>
</svg>

Before

Width:  |  Height:  |  Size: 865 B

-13
View File
@@ -1,13 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#ff8a5f"/><stop offset="1" stop-color="#e5431f"/></linearGradient>
<linearGradient id="gl" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#fff" stop-opacity="0.26"/><stop offset="0.5" stop-color="#fff" stop-opacity="0"/></linearGradient>
</defs>
<rect x="0" y="0" width="100" height="100" rx="28" fill="url(#bg)"/>
<rect x="0" y="0" width="100" height="100" rx="28" fill="url(#gl)"/>
<rect x="0.75" y="0.75" width="98.5" height="98.5" rx="27.25" fill="none" stroke="#fff" stroke-opacity="0.14" stroke-width="1.5"/>
<g fill="#fff">
<rect x="32" y="32" width="15" height="15" rx="4"/><rect x="53" y="32" width="15" height="15" rx="4"/>
<rect x="32" y="53" width="15" height="15" rx="4"/><rect x="53" y="53" width="15" height="15" rx="4"/></g>
</svg>

Before

Width:  |  Height:  |  Size: 909 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

-13
View File
@@ -1,13 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#454b57"/><stop offset="1" stop-color="#1c2027"/></linearGradient>
<linearGradient id="gl" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#fff" stop-opacity="0.26"/><stop offset="0.5" stop-color="#fff" stop-opacity="0"/></linearGradient>
</defs>
<rect x="0" y="0" width="100" height="100" rx="28" fill="url(#bg)"/>
<rect x="0" y="0" width="100" height="100" rx="28" fill="url(#gl)"/>
<rect x="0.75" y="0.75" width="98.5" height="98.5" rx="27.25" fill="none" stroke="#fff" stroke-opacity="0.14" stroke-width="1.5"/>
<g fill="#fff">
<rect x="32" y="32" width="15" height="15" rx="4"/><rect x="53" y="32" width="15" height="15" rx="4"/>
<rect x="32" y="53" width="15" height="15" rx="4"/><rect x="53" y="53" width="15" height="15" rx="4"/></g>
</svg>

Before

Width:  |  Height:  |  Size: 909 B

-12
View File
@@ -1,12 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#ff8a5f"/><stop offset="1" stop-color="#e5431f"/></linearGradient>
<linearGradient id="gl" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#fff" stop-opacity="0.26"/><stop offset="0.5" stop-color="#fff" stop-opacity="0"/></linearGradient>
</defs>
<rect x="0" y="0" width="100" height="100" rx="28" fill="url(#bg)"/>
<rect x="0" y="0" width="100" height="100" rx="28" fill="url(#gl)"/>
<rect x="0.75" y="0.75" width="98.5" height="98.5" rx="27.25" fill="none" stroke="#fff" stroke-opacity="0.14" stroke-width="1.5"/>
<g fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round">
<path d="M50 28 L71 40 L71 60 L50 72 L29 60 L29 40 Z"/><path d="M29 40 L50 52 L71 40"/><path d="M50 52 L50 72"/></g>
</svg>

Before

Width:  |  Height:  |  Size: 894 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

-12
View File
@@ -1,12 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#454b57"/><stop offset="1" stop-color="#1c2027"/></linearGradient>
<linearGradient id="gl" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#fff" stop-opacity="0.26"/><stop offset="0.5" stop-color="#fff" stop-opacity="0"/></linearGradient>
</defs>
<rect x="0" y="0" width="100" height="100" rx="28" fill="url(#bg)"/>
<rect x="0" y="0" width="100" height="100" rx="28" fill="url(#gl)"/>
<rect x="0.75" y="0.75" width="98.5" height="98.5" rx="27.25" fill="none" stroke="#fff" stroke-opacity="0.14" stroke-width="1.5"/>
<g fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round">
<path d="M50 28 L71 40 L71 60 L50 72 L29 60 L29 40 Z"/><path d="M29 40 L50 52 L71 40"/><path d="M50 52 L50 72"/></g>
</svg>

Before

Width:  |  Height:  |  Size: 894 B

+1 -1
View File
@@ -27,7 +27,7 @@
- Overview dashboard
- Maintenance mode
- Media Library
- Site Settings
- Site Settings (Brand / Localization / SEO — see `docs/SEO.md`)
- Marquee Settings
- SMTP Settings
- Contact Protection
+10 -1
View File
@@ -32,7 +32,7 @@ describe("createMediaAssetAction", () => {
});
it.skipIf(!canManageUploads)("creates an asset from an uploaded file", async () => {
const file = new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], "pic.png", { type: "image/png" });
const file = new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])], "pic.png", { type: "image/png" });
const url = await captureRedirect(() =>
createMediaAssetAction(formDataFrom({ kind: "IMAGE", label: "Pic", file })),
);
@@ -43,6 +43,15 @@ describe("createMediaAssetAction", () => {
await removeManagedMediaFile(assets[0].url);
});
it("rejects an upload whose bytes do not match the declared image type", async () => {
const file = new File(["<html><script>alert(1)</script></html>"], "evil.png", { type: "image/png" });
const url = await captureRedirect(() =>
createMediaAssetAction(formDataFrom({ kind: "IMAGE", label: "Evil", file })),
);
expect(url).toContain("error=");
expect((await db.select().from(mediaAsset)).length).toBe(0);
});
it("redirects unauthenticated callers to the admin root", async () => {
adminAuth.authenticated = false;
const url = await captureRedirect(() => createMediaAssetAction(formDataFrom({ kind: "IMAGE", label: "L" })));
@@ -104,6 +104,14 @@ describe("upsertCategoryAction", () => {
expect(category?.nameEn).toBe("Renamed");
});
it("rejects a category slug that already belongs to a project", async () => {
const other = await createCategory({ slug: "other" });
await createProject({ categoryId: other.id, slug: "taken" });
const url = await captureRedirect(() => upsertCategoryAction(categoryForm({ slug: "taken" })));
expect(new URL(url, "http://test").searchParams.get("error")).toContain("Projekt Slug vergeben");
expect(await db.query.category.findFirst({ where: eq(categoryTable.slug, "taken") })).toBeUndefined();
});
it("reports a unique-constraint violation on duplicate slugs", async () => {
await createCategory({ slug: "branding" });
const url = await captureRedirect(() => upsertCategoryAction(categoryForm({ slug: "branding" })));
@@ -141,6 +149,13 @@ describe("deleteCategoryAction", () => {
});
describe("saveProjectAction", () => {
it("rejects a project slug that already belongs to a category", async () => {
const category = await createCategory({ slug: "branding" });
const url = await captureRedirect(() => saveProjectAction(projectForm(category.id, { slug: "branding" })));
expect(new URL(url, "http://test").searchParams.get("error")).toContain("Kategorie Slug vergeben");
expect(await db.query.portfolioProject.findFirst({ where: eq(portfolioProjectTable.slug, "branding") })).toBeUndefined();
});
it("creates a published project with cover and asset media usages", async () => {
const category = await createCategory();
const url = await captureRedirect(() => saveProjectAction(projectForm(category.id)));
@@ -11,10 +11,11 @@ vi.mock("@/lib/admin-auth", async () => {
});
import {
saveSeoSettingsAction,
saveSiteBrandSettingsAction,
saveSiteLocalizationSettingsAction,
} from "@/app/_admin/site-settings/actions";
import { getSiteSettings, getSiteSettingsMediaBindings } from "@/lib/app-config";
import { getSeoSettings, getSiteSettings, getSiteSettingsMediaBindings } from "@/lib/app-config";
import { adminAuth, captureRedirect, formDataFrom, resetNextMocks } from "@/tests/helpers/next-mocks";
beforeEach(() => {
@@ -102,3 +103,51 @@ describe("saveSiteLocalizationSettingsAction", () => {
expect(url).toBe("/");
});
});
describe("saveSeoSettingsAction", () => {
it("persists normalized seo settings", async () => {
const url = await captureRedirect(() =>
saveSeoSettingsAction(
formDataFrom({
allowIndexing: "on",
googleSiteVerification: "g-1",
twitterHandle: "moh",
structuredDataType: "Organization",
structuredDataName: "Studio",
sameAs: "https://a.com\nhttps://b.com",
keywordsDe: "a, b",
}),
),
);
expect(url).toContain("success=");
const seo = await getSeoSettings();
expect(seo).toMatchObject({
allowIndexing: true,
googleSiteVerification: "g-1",
twitterHandle: "@moh",
structuredDataType: "Organization",
sameAs: ["https://a.com", "https://b.com"],
});
expect(seo.locales.de.keywords).toBe("a, b");
});
it("turns indexing off when the checkbox is missing", async () => {
await captureRedirect(() => saveSeoSettingsAction(formDataFrom({})));
expect((await getSeoSettings()).allowIndexing).toBe(false);
});
it("rejects an invalid verification token without saving", async () => {
await captureRedirect(() => saveSeoSettingsAction(formDataFrom({ allowIndexing: "on", googleSiteVerification: "ok" })));
const url = await captureRedirect(() =>
saveSeoSettingsAction(formDataFrom({ allowIndexing: "on", googleSiteVerification: "<bad>" })),
);
expect(url).toContain("error=");
expect((await getSeoSettings()).googleSiteVerification).toBe("ok");
});
it("redirects unauthenticated users", async () => {
adminAuth.authenticated = false;
const url = await captureRedirect(() => saveSeoSettingsAction(formDataFrom({})));
expect(url).not.toContain("success=");
});
});
+1 -1
View File
@@ -102,7 +102,7 @@ describe("resolveMediaSelection — missing configuration", () => {
describe("resolveMediaSelection — upload mode (filesystem)", () => {
it.skipIf(!canManageUploads)("saves the file and creates an UPLOAD asset", async () => {
const file = new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], "shot.png", { type: "image/png" });
const file = new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])], "shot.png", { type: "image/png" });
const result = await resolveMediaSelection({
media: { mode: "upload", assetId: "", url: "", label: "Shot", kind: "IMAGE" },
uploadFile: file,
+1 -1
View File
@@ -42,7 +42,7 @@ describe("metadata helpers", () => {
apple: [{ url: "/apple-icon.png?v=v1" }],
});
expect(metadata.openGraph).toMatchObject({
locale: "ar",
locale: "ar_AR",
url: "https://mohfarawati.de/",
});
expect(metadata.twitter).toMatchObject({
+38 -2
View File
@@ -20,7 +20,7 @@ vi.mock("../lib/admin-routing", () => ({
toInternalAdminPath: (pathname: string) => pathname,
}));
function createMockRequest(url: string) {
function createMockRequest(url: string, cookieValue?: string) {
const nextUrl = new URL(url) as URL & { clone: () => URL };
nextUrl.clone = () => new URL(nextUrl.toString());
@@ -31,11 +31,25 @@ function createMockRequest(url: string) {
host: nextUrl.host,
}),
cookies: {
has: vi.fn(() => false),
has: vi.fn(() => cookieValue !== undefined),
get: vi.fn(() => (cookieValue !== undefined ? { name: "moh_admin_session", value: cookieValue } : undefined)),
},
};
}
function stubMaintenanceRuntime() {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
ok: true,
json: async () => ({
defaultLocale: "de",
maintenanceEnabled: true,
}),
})),
);
}
describe("middleware locale runtime config", () => {
beforeEach(() => {
vi.resetModules();
@@ -123,6 +137,28 @@ describe("middleware locale runtime config", () => {
expect(intlHandlerMock).not.toHaveBeenCalled();
});
it("does not let a forged admin cookie bypass maintenance mode", async () => {
vi.stubEnv("ADMIN_AUTH_SECRET", "test-secret");
stubMaintenanceRuntime();
const { default: middleware } = await import("../proxy");
const response = await middleware(createMockRequest("https://example.com/about", "superadmin.forged") as never);
expect(response.headers.get("location")).toBe("https://example.com/coming-soon");
expect(intlHandlerMock).not.toHaveBeenCalled();
});
it("lets a correctly signed admin cookie through maintenance mode", async () => {
vi.stubEnv("ADMIN_AUTH_SECRET", "test-secret");
stubMaintenanceRuntime();
const { buildAdminSessionToken } = await import("../lib/admin-session-token");
const { default: middleware } = await import("../proxy");
await middleware(createMockRequest("https://example.com/about", buildAdminSessionToken()) as never);
expect(intlHandlerMock).toHaveBeenCalledTimes(1);
});
it("prefers the configured internal runtime origin when provided", async () => {
process.env.SITE_RUNTIME_ORIGIN = "http://app:3000";
+1 -1
View File
@@ -73,7 +73,7 @@ describe("getAdminNavigation", () => {
const minimal = { ...copy, brandSettings: undefined, localizationSettings: undefined, marquee: undefined, smtp: undefined };
const nav = getAdminNavigation(minimal, "overview");
const siteSettings = nav.find((item) => item.label === "Site Settings");
expect(siteSettings?.children?.map((c) => c.label)).toEqual(["Brand", "Localization"]);
expect(siteSettings?.children?.map((c) => c.label)).toEqual(["Brand", "Localization", "SEO"]);
expect(nav.find((item) => item.href.endsWith("/marquee"))?.label).toBe("Marquee");
expect(nav.find((item) => item.href.endsWith("/smtp"))?.label).toBe("SMTP");
});
+39
View File
@@ -0,0 +1,39 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { buildAdminSessionToken, verifyAdminSessionToken } from "@/lib/admin-session-token";
const originalSecret = process.env.ADMIN_AUTH_SECRET;
beforeEach(() => {
process.env.ADMIN_AUTH_SECRET = "unit-test-secret";
});
afterEach(() => {
process.env.ADMIN_AUTH_SECRET = originalSecret;
});
describe("admin session token", () => {
it("round-trips a signed token", () => {
expect(verifyAdminSessionToken(buildAdminSessionToken())).toBe(true);
});
it("rejects forged, malformed, or missing tokens", () => {
expect(verifyAdminSessionToken("superadmin")).toBe(false);
expect(verifyAdminSessionToken("superadmin.deadbeef")).toBe(false);
expect(verifyAdminSessionToken("other." + buildAdminSessionToken().split(".")[1])).toBe(false);
expect(verifyAdminSessionToken("")).toBe(false);
expect(verifyAdminSessionToken(undefined)).toBe(false);
expect(verifyAdminSessionToken("1")).toBe(false);
});
it("rejects a token signed with a different secret", () => {
const token = buildAdminSessionToken();
process.env.ADMIN_AUTH_SECRET = "rotated";
expect(verifyAdminSessionToken(token)).toBe(false);
});
it("never validates when no secret is configured", () => {
process.env.ADMIN_AUTH_SECRET = "";
expect(verifyAdminSessionToken("superadmin.anything")).toBe(false);
});
});
+37
View File
@@ -7,6 +7,7 @@ import {
MEDIA_UPLOAD_ROOT,
getExtensionForMimeType,
isManagedMediaFilePath,
isMediaContentValid,
removeManagedMediaFile,
resolveMediaUploadPath,
sanitizeBaseName,
@@ -69,6 +70,42 @@ describe("resolveMediaUploadPath", () => {
it("throws when a traversal attempt escapes the root", () => {
expect(() => resolveMediaUploadPath("/uploads/media/../../etc/passwd")).toThrow(/escapes/i);
});
it("rejects a sibling directory that merely shares the root prefix", () => {
// `.../uploads/media-evil` starts with `.../uploads/media` as a string.
expect(() => resolveMediaUploadPath("/uploads/media/../media-evil/x.png")).toThrow(/escapes/i);
});
it("rejects the root itself, empty paths and null bytes", () => {
expect(() => resolveMediaUploadPath("/uploads/media/")).toThrow(/escapes/i);
expect(() => resolveMediaUploadPath("/uploads/media/./")).toThrow(/escapes/i);
expect(() => resolveMediaUploadPath("/uploads/media/a\0.png")).toThrow(/escapes/i);
});
});
describe("isMediaContentValid", () => {
it("accepts files whose magic bytes match the extension", () => {
expect(isMediaContentValid(".png", Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2]))).toBe(true);
expect(isMediaContentValid(".jpg", Buffer.from([0xff, 0xd8, 0xff, 0xe0]))).toBe(true);
expect(isMediaContentValid(".gif", Buffer.from("GIF89a"))).toBe(true);
expect(isMediaContentValid(".pdf", Buffer.from("%PDF-1.7"))).toBe(true);
expect(isMediaContentValid(".webp", Buffer.from("RIFF\0\0\0\0WEBPVP8 "))).toBe(true);
});
it("rejects mismatched bytes (e.g. HTML disguised as an image)", () => {
expect(isMediaContentValid(".png", Buffer.from("<html><script>alert(1)</script>"))).toBe(false);
expect(isMediaContentValid(".jpg", Buffer.from("GIF89a"))).toBe(false);
expect(isMediaContentValid(".exe", Buffer.from("MZ"))).toBe(false);
});
it("accepts plain svg and rejects active content", () => {
expect(isMediaContentValid(".svg", Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>'))).toBe(true);
expect(isMediaContentValid(".svg", Buffer.from('<?xml version="1.0"?>\n<svg><circle/></svg>'))).toBe(true);
expect(isMediaContentValid(".svg", Buffer.from("<svg><script>alert(1)</script></svg>"))).toBe(false);
expect(isMediaContentValid(".svg", Buffer.from('<svg onload="alert(1)"></svg>'))).toBe(false);
expect(isMediaContentValid(".svg", Buffer.from('<svg><a xlink:href="javascript:x"/></svg>'))).toBe(false);
expect(isMediaContentValid(".svg", Buffer.from("<html><svg/></html>"))).toBe(false);
});
});
describe("removeManagedMediaFile", () => {
+5
View File
@@ -25,6 +25,11 @@ describe("mediaFieldInputSchema", () => {
expect(parsed.url).toBe("/uploads/media/x.png");
});
it("rejects protocol-relative and javascript urls", () => {
expect(() => mediaFieldInputSchema.parse({ ...base, mode: "external", url: "//evil.com/x.png" })).toThrow(/URL/i);
expect(() => mediaFieldInputSchema.parse({ ...base, mode: "external", url: "javascript:alert(1)" })).toThrow(/URL/i);
});
it("requires a url in external mode", () => {
expect(() => mediaFieldInputSchema.parse({ ...base, mode: "external" })).toThrow(/URL/i);
});
+106 -1
View File
@@ -1,11 +1,15 @@
import { describe, expect, it } from "vitest";
import { buildDefaultSeoSettings } from "@/lib/seo-settings";
import { buildDefaultSiteSettings } from "@/lib/site-settings";
import {
applyTitleTemplateFn,
buildAppMetadataFromConfig,
buildLocaleAlternates,
buildLocalizedMetadataFromConfig,
buildProjectJsonLd,
buildSiteJsonLd,
serializeJsonLd,
} from "@/lib/metadata";
const noBindings = {
@@ -82,7 +86,7 @@ describe("buildLocalizedMetadataFromConfig", () => {
});
expect(metadata.title).toBe("About | Studio");
expect(metadata.description).toBe("English description");
expect(metadata.openGraph?.locale).toBe("en");
expect(metadata.openGraph?.locale).toBe("en_US");
});
it("can skip the title template (homepage)", () => {
@@ -111,3 +115,104 @@ describe("buildLocalizedMetadataFromConfig", () => {
expect(metadata.description).toBe("Custom desc");
});
});
describe("seo-aware metadata", () => {
const settings = buildDefaultSiteSettings("Studio");
it("indexes by default and emits verification + twitter handle when configured", () => {
const seo = {
...buildDefaultSeoSettings(),
googleSiteVerification: "g123",
bingSiteVerification: "b456",
twitterHandle: "@moh",
};
const metadata = buildAppMetadataFromConfig(settings, noBindings, seo);
expect(metadata.robots).toMatchObject({ index: true, follow: true });
expect(metadata.verification).toEqual({ google: "g123", other: { "msvalidate.01": "b456" } });
expect(metadata.twitter).toMatchObject({ site: "@moh", creator: "@moh" });
expect(metadata.openGraph).toMatchObject({ locale: "de_DE", alternateLocale: ["en_US", "ar_AR"] });
});
it("emits noindex everywhere when indexing is disabled", () => {
const seo = { ...buildDefaultSeoSettings(), allowIndexing: false };
expect(buildAppMetadataFromConfig(settings, noBindings, seo).robots).toMatchObject({ index: false });
const page = buildLocalizedMetadataFromConfig({ settings, bindings: noBindings, seo, locale: "en", pathname: "/about", title: "About" });
expect(page.robots).toMatchObject({ index: false, follow: false });
});
it("supports per-page noindex, article type and a page-specific image", () => {
const published = new Date("2026-01-02T00:00:00Z");
const page = buildLocalizedMetadataFromConfig({
settings,
bindings: { ...noBindings, defaultOgImage: { assetId: "a", url: "/og.png", version: "1" } },
locale: "en",
pathname: "/portfolio/x",
title: "X",
image: "/uploads/media/covers/x.png",
type: "article",
publishedTime: published,
});
expect(page.robots).toMatchObject({ index: true });
expect(page.openGraph).toMatchObject({
type: "article",
publishedTime: published.toISOString(),
images: [{ url: "https://mohfarawati.de/uploads/media/covers/x.png", alt: "X" }],
});
const thanks = buildLocalizedMetadataFromConfig({ settings, bindings: noBindings, locale: "en", pathname: "/success", title: "Thanks", noIndex: true });
expect(thanks.robots).toMatchObject({ index: false });
});
it("falls back to the default og image when no page image is given", () => {
const page = buildLocalizedMetadataFromConfig({
settings,
bindings: { ...noBindings, defaultOgImage: { assetId: "a", url: "/og.png", version: "1" } },
locale: "de",
pathname: "/",
title: "Home",
});
expect(page.openGraph).toMatchObject({ images: [{ url: "https://mohfarawati.de/og.png", alt: "Home" }] });
});
});
describe("json-ld", () => {
const settings = buildDefaultSiteSettings("Studio");
it("builds a WebSite + Person graph linked by @id", () => {
const seo = { ...buildDefaultSeoSettings(), structuredDataName: "Moh", structuredDataJobTitle: "Designer", sameAs: ["https://x.com/moh"] };
const graph = buildSiteJsonLd({ settings, seo, bindings: noBindings, locale: "de" })["@graph"] as Array<Record<string, unknown>>;
expect(graph[0]).toMatchObject({ "@type": "WebSite", publisher: { "@id": "https://mohfarawati.de/#person" } });
expect(graph[1]).toMatchObject({ "@type": "Person", name: "Moh", jobTitle: "Designer", sameAs: ["https://x.com/moh"] });
});
it("uses Organization shape when configured", () => {
const seo = { ...buildDefaultSeoSettings(), structuredDataType: "Organization" as const, structuredDataJobTitle: "Studio" };
const graph = buildSiteJsonLd({ settings, seo, bindings: noBindings, locale: "en" })["@graph"] as Array<Record<string, unknown>>;
expect(graph[1]).toMatchObject({ "@type": "Organization", slogan: "Studio" });
});
it("builds a CreativeWork per project with localized url", () => {
const work = buildProjectJsonLd({
settings,
seo: buildDefaultSeoSettings(),
locale: "en",
pathname: "/portfolio/x",
title: "X",
description: "D",
image: "/c.png",
datePublished: new Date("2026-01-01T00:00:00Z"),
clientName: "ACME",
});
expect(work).toMatchObject({
"@type": "CreativeWork",
url: "https://mohfarawati.de/en/portfolio/x",
image: "https://mohfarawati.de/c.png",
sourceOrganization: { "@type": "Organization", name: "ACME" },
author: { "@id": "https://mohfarawati.de/#person" },
});
});
it("escapes < so the payload cannot close the script tag", () => {
expect(serializeJsonLd({ name: "</script><script>alert(1)</script>" })).not.toContain("</script>");
});
});
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { ROBOTS_DISALLOWED_PATHS, buildRobots } from "@/app/robots";
describe("buildRobots", () => {
it("blocks everything when not indexable and omits the sitemap", () => {
const robots = buildRobots({ indexable: false });
expect(robots.rules).toEqual([{ userAgent: "*", disallow: "/" }]);
expect(robots.sitemap).toBeUndefined();
});
it("allows crawling but hides admin, api and utility pages when indexable", () => {
const robots = buildRobots({ indexable: true });
const rule = Array.isArray(robots.rules) ? robots.rules[0] : robots.rules;
expect(rule.allow).toBe("/");
expect(rule.disallow).toEqual(ROBOTS_DISALLOWED_PATHS);
expect(rule.disallow).toEqual(expect.arrayContaining(["/admin-internal", "/root", "/api/", "/success", "/coming-soon"]));
expect(robots.sitemap).toBe("https://mohfarawati.de/sitemap.xml");
});
});
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { buildSeoChecklist, summarizeSeoChecklist } from "@/lib/seo-report";
import { buildDefaultSeoSettings } from "@/lib/seo-settings";
import { buildDefaultSiteSettings, getDefaultSiteSettingsMediaBindings } from "@/lib/site-settings";
function run(overrides: Partial<Parameters<typeof buildSeoChecklist>[0]> = {}) {
return buildSeoChecklist({
seo: buildDefaultSeoSettings(),
settings: buildDefaultSiteSettings(),
bindings: getDefaultSiteSettingsMediaBindings(),
maintenanceEnabled: false,
publishedProjectCount: 2,
sitemapEntryCount: 12,
siteUrl: "https://mohfarawati.de",
...overrides,
});
}
describe("buildSeoChecklist", () => {
it("flags maintenance mode as an indexing error", () => {
const check = run({ maintenanceEnabled: true }).find((entry) => entry.id === "indexing");
expect(check?.status).toBe("error");
expect(check?.detail).toMatch(/Wartungsmodus/);
});
it("flags disabled indexing and passes when enabled", () => {
const seo = { ...buildDefaultSeoSettings(), allowIndexing: false };
expect(run({ seo }).find((entry) => entry.id === "indexing")?.status).toBe("error");
expect(run().find((entry) => entry.id === "indexing")?.status).toBe("ok");
});
it("warns about localhost as public url", () => {
expect(run({ siteUrl: "http://localhost:3014" }).find((entry) => entry.id === "site-url")?.status).toBe("warn");
});
it("grades description length per locale", () => {
const settings = buildDefaultSiteSettings();
settings.locales.de.siteDescription = "";
settings.locales.en.siteDescription = "x".repeat(80);
const checks = run({ settings });
expect(checks.find((entry) => entry.id === "description-de")?.status).toBe("error");
expect(checks.find((entry) => entry.id === "description-en")?.status).toBe("ok");
expect(checks.find((entry) => entry.id === "description-ar")?.status).toBe("warn");
});
it("summarizes counts", () => {
const summary = summarizeSeoChecklist(run());
expect(summary.ok + summary.warn + summary.error).toBe(run().length);
});
});
+77
View File
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import {
buildDefaultSeoSettings,
normalizeKeywords,
normalizeSameAs,
normalizeTwitterHandle,
normalizeVerificationToken,
parseSeoSettingsValue,
toOpenGraphLocale,
} from "@/lib/seo-settings";
describe("parseSeoSettingsValue", () => {
it("returns defaults for empty or invalid JSON", () => {
expect(parseSeoSettingsValue(undefined)).toEqual(buildDefaultSeoSettings());
expect(parseSeoSettingsValue("{not json")).toEqual(buildDefaultSeoSettings());
expect(parseSeoSettingsValue(null).allowIndexing).toBe(true);
});
it("only disables indexing on an explicit false", () => {
expect(parseSeoSettingsValue(JSON.stringify({ allowIndexing: false })).allowIndexing).toBe(false);
expect(parseSeoSettingsValue(JSON.stringify({ allowIndexing: "no" })).allowIndexing).toBe(true);
});
it("normalizes every field and drops junk", () => {
const parsed = parseSeoSettingsValue(
JSON.stringify({
googleSiteVerification: "abc-123_XYZ",
bingSiteVerification: "<script>",
twitterHandle: "https://x.com/moh_farawati",
structuredDataType: "Company",
sameAs: ["https://behance.net/x", "http://insecure", "javascript:alert(1)", "https://behance.net/x"],
locales: { de: { keywords: " a , ,b,, c " } },
}),
);
expect(parsed.googleSiteVerification).toBe("abc-123_XYZ");
expect(parsed.bingSiteVerification).toBe("");
expect(parsed.twitterHandle).toBe("@moh_farawati");
expect(parsed.structuredDataType).toBe("Person");
expect(parsed.sameAs).toEqual(["https://behance.net/x"]);
expect(parsed.locales.de.keywords).toBe("a, b, c");
expect(parsed.locales.en.keywords).toBe("");
});
});
describe("normalizers", () => {
it("rejects verification tokens with unsafe characters", () => {
expect(normalizeVerificationToken("ok_token-1")).toBe("ok_token-1");
expect(normalizeVerificationToken('x" onload="1')).toBe("");
expect(normalizeVerificationToken(42)).toBe("");
});
it("normalizes twitter handles with or without @ / URL", () => {
expect(normalizeTwitterHandle("@moh")).toBe("@moh");
expect(normalizeTwitterHandle("moh")).toBe("@moh");
expect(normalizeTwitterHandle("https://twitter.com/moh")).toBe("@moh");
expect(normalizeTwitterHandle("this-has-dashes")).toBe("");
expect(normalizeTwitterHandle("a".repeat(16))).toBe("");
});
it("accepts newline or comma separated https URLs only", () => {
expect(normalizeSameAs("https://a.com\nhttps://b.com, ftp://c")).toEqual(["https://a.com", "https://b.com"]);
expect(normalizeSameAs(null)).toEqual([]);
});
it("caps keywords at 30 entries", () => {
const keywords = normalizeKeywords(Array.from({ length: 40 }, (_, index) => `k${index}`).join(","));
expect(keywords.split(", ")).toHaveLength(30);
});
it("maps locales to og:locale codes", () => {
expect(toOpenGraphLocale("de")).toBe("de_DE");
expect(toOpenGraphLocale("en")).toBe("en_US");
expect(toOpenGraphLocale("ar")).toBe("ar_AR");
});
});