Compare commits

..
32 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
moh 4df0d9ae86 IMPROVED - Add page exit animation via frozen-router AnimatePresence
CI / quality (push) Canceled after 0s
Wrap the template in AnimatePresence (mode="wait") with a FrozenRouter that
pins the layout-router context, so the outgoing page animates out (blur +
rise) before the incoming page animates in.
2026-09-20 19:50:46 +02:00
moh 5c10bce740 IMPROVED - Replace per-title hero entrance with a unified page transition
- Add (site)/template.tsx: one framer-motion enter animation (fade + rise +
  blur) that runs on every navigation, so moving between dock pages has a
  clean transition.
- Disable the staggered .hero-rise entrance now that the template handles it.
2026-09-20 19:46:58 +02:00
moh 572329097c REMOVED - Drop Home dock item since the logo already links home
CI / quality (push) Canceled after 0s
2026-09-20 19:42:56 +02:00
moh 4017ba3b72 POLISHED - Soften dock hover magnification and enlarge tooltip text 2026-09-20 19:40:02 +02:00
moh 70203732d2 REVERTED - Restore corner radial controls; make dock icons full-bleed
- Bring back the corner fan-out controls; drop the in-dock settings tile.
- Regenerate public/dock/*.svg so the squircle fills the whole tile with
  no inner margin.
2026-09-20 19:37:17 +02:00
moh a6be3c26d2 IMPROVED - Collapse dock controls into one settings tile that fans out in an arc 2026-09-20 19:34:57 +02:00
moh 008cfcc515 IMPROVED - Move theme/sound/language controls into the dock after a separator
Replace the custom radial corner menu with in-dock control tiles so the
whole bar reads as one macOS dock: logo | pages | settings, each group
divided by a separator. Controls reuse the existing toggle components on
graphite squircle tiles matching the page icons.
2026-09-20 19:19:45 +02:00
moh e1030b7f3e IMPROVED - Fan the corner controls out along a radial arc 2026-09-20 19:15:57 +02:00
moh ac8746a287 IMPROVED - Use swappable image icons and move controls to a corner dock
- Dock icons are now real SVG files under public/dock/ (macOS squircle
  look, active variants), so they can be replaced with custom art later.
- Full-bleed icons (DockIcon padding 0), fixed logo src to stop the
  refresh flash.
- Remove the top menu bar entirely; relocate theme/language/sound (+admin)
  into a corner control dock that expands on click.
2026-09-20 19:12:44 +02:00
moh 6e7cc4dbc6 IMPROVED - Add custom macOS-style squircle app icons to the dock 2026-09-20 19:05:29 +02:00
moh 6543ccada3 IMPROVED - Fix dock bar height jump, enlarge icons, refine active state 2026-09-20 19:01:42 +02:00
moh 6c213699a2 ADDED - Replace site header menu with macOS menu bar and Magic UI dock
Swap the pill navigation for a macOS-style dock as the primary nav and a
slim top menu bar for utilities:

- components/ui/dock.tsx: Magic UI Dock/DockIcon (magnification via
  useSpring/useTransform), ported to the project's framer-motion.
- components/layout/site-dock.tsx: bottom dock with the logo as the first
  icon, each item a real next/link (SEO preserved, active state from the
  pathname), monochrome squircle tiles, hover tooltips and a "soon" badge
  for Products; top menu bar keeps locale/sound/theme/admin + a live clock.
- (site)/layout.tsx: render SiteDock instead of SiteHeader.

Home bento grid and all page content are unchanged.
2026-09-20 18:56:46 +02:00
moh f627118b2c CONFIGURED - Auto-activate git hooks via npm prepare script
Add a prepare script that runs 'git config core.hooksPath .githooks' on every
npm install, so the commit-msg style gate and pre-push test gate turn on
automatically per clone with nothing to remember. Guarded with '|| true' so
installs never fail where git is absent (e.g. tarball deploys). Document that
the deploy server never needs it since it only pulls.
2026-09-20 18:54:55 +02:00
moh 213c45476c CONFIGURED - Enforce VERB-style commit subjects via commit-msg hook
Add a commit-msg git hook that rejects any subject not matching
"VERB - Description" (ALL-CAPS verb + " - " + description), letting
merge/revert/fixup commits through. Document the mandatory style and the
no-attribution rule in CLAUDE.md, and note that .githooks is activated per
clone with core.hooksPath. Prevents the prefix-less commits agents kept
producing.
2026-09-20 18:53:48 +02:00
moh 3f96abc60f REFACTORED - Flatten portfolio category routes to /portfolio/[slug]
Merge the category and project routes under a single /portfolio/[slug]
segment via resolvePortfolioSlug (category wins over project on a slug
clash). Removes the /portfolio/category/... prefix from links, redirect,
and sitemap. Adds resolver integration tests.
2026-09-20 18:53:48 +02:00
moh 5b019052b1 STYLED - Add CSS hero entrance, title halo, and scroll cue
Replace the framer-motion hero entrance with pure-CSS staggered animations so
the hero is painted immediately instead of flashing blank until hydration.
Add a focused brand glow behind the hero title and an animated scroll cue at
the bottom of the home hero. Entrance and cue respect prefers-reduced-motion.
2026-09-20 18:53:48 +02:00
moh bb9f1a2f6e STYLED - Add a layered ambient backdrop behind all heroes
Replace the flat, empty hero background with a pure-CSS layered backdrop:
faded grid, brand glow, drifting brand orbs, grain, and a bottom fade that
blends the hero into page content. Applies to both the home hero and the
compact page heroes via HeroMotionBackdrop; respects prefers-reduced-motion.
2026-09-20 18:53:48 +02:00
74 changed files with 2900 additions and 412 deletions
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
#
# commit-msg: enforce the project's commit subject style.
#
# The subject (first line) MUST be: VERB - Short description
# - VERB : an ALL-CAPS verb, >= 3 letters
# (e.g. ADDED, FIXED, STYLED, DOCUMENTED, CLEANED, REVERTED,
# CONFIGURED, IMPROVED, POLISHED, REMOVED, RENAMED, REFACTORED)
# - then " - " (space hyphen space)
# - then a description (capitalized, imperative, no trailing period).
#
# Good:
# FIXED - Load .env in drizzle.config so drizzle-kit targets the right DB
# STYLED - Turn the admin portfolio overview into a professional table
#
# Bad (rejected):
# Flatten portfolio category routes to /portfolio/[slug] (no VERB prefix)
# feat(hero): add ambient backdrop (wrong style)
#
# Install once: git config core.hooksPath .githooks
# Emergency skip: git commit --no-verify
#
set -euo pipefail
msg_file="$1"
# First non-empty, non-comment line = the subject.
subject="$(grep -vE '^[[:space:]]*#' "$msg_file" | grep -vE '^[[:space:]]*$' | head -n1 || true)"
# Let git's own housekeeping commits through untouched.
case "$subject" in
Merge\ * | Revert\ * | fixup!\ * | squash!\ *) exit 0 ;;
esac
pattern='^[A-Z]{3,} - .+'
if [[ ! "$subject" =~ $pattern ]]; then
cat >&2 <<EOF
✗ commit rejected: subject does not match the project style.
Required: VERB - Short description
Example: FIXED - Load .env so drizzle-kit targets the right DB
Your subject was:
${subject:-(empty)}
VERB must be ALL-CAPS (>= 3 letters), then " - ", then the description.
(Emergency skip: git commit --no-verify)
EOF
exit 1
fi
+33 -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` | | DB schema | `lib/db/schema.ts` |
| AppConfig aggregate | `lib/app-config.ts` | | AppConfig aggregate | `lib/app-config.ts` |
| Portfolio queries | `lib/portfolio.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` | | 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 ### Documentation to read by task scope
- **Small UI/copy/style fixes**: read only the relevant files - **Small UI/copy/style fixes**: read only the relevant files
- **Feature changes**: read `specs/<feature>.md` + `docs/ARCHITECTURE.md` if structure is affected - **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 - **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. Update `docs/` and `specs/` only when the change affects feature scope, business rules, architecture, or public behavior.
@@ -126,3 +129,32 @@ SITE_RUNTIME_ORIGIN Internal origin for middleware to fetch runtime state
- For schema or database changes, inspect the Drizzle schema (`lib/db/schema.ts`), the drizzle-kit migration flow, and migration impact before editing. - For schema or database changes, inspect the Drizzle schema (`lib/db/schema.ts`), the drizzle-kit migration flow, and migration impact before editing.
- Ask before performing large refactors, file moves, destructive changes, or broad formatting changes. - Ask before performing large refactors, file moves, destructive changes, or broad formatting changes.
- When updating behavior, also update docs/specs if the change affects public behavior, business rules, or architecture. - When updating behavior, also update docs/specs if the change affects public behavior, business rules, or architecture.
## Git commit messages (MANDATORY)
Every commit subject **must** follow this exact style, or the `commit-msg` hook
(`.githooks/commit-msg`) will reject the commit:
```
VERB - Short description
```
- `VERB` is an ALL-CAPS verb, at least 3 letters — e.g. `ADDED`, `FIXED`,
`STYLED`, `REMOVED`, `RENAMED`, `REFACTORED`, `IMPROVED`, `DOCUMENTED`,
`CLEANED`, `REVERTED`, `CONFIGURED`, `POLISHED`.
- Then exactly `" - "` (space, hyphen, space).
- Then a capitalized, imperative description with no trailing period.
Good: `FIXED - Load .env in drizzle.config so drizzle-kit targets the right DB`
Bad (rejected): `Flatten portfolio category routes` — no `VERB` prefix.
Bad (rejected): `feat(hero): add backdrop` — Conventional Commits is NOT used here.
Do **not** add any `Co-Authored-By` / "Generated with Claude Code" attribution
lines to commits in this repo. Write the body (when useful) as wrapped prose or
bullet points explaining the *why*, matching the existing history.
The hook (and the `pre-push` test gate) live in `.githooks/` and are activated
per clone via `core.hooksPath`. You don't need to remember this: the `prepare`
npm script runs `git config core.hooksPath .githooks` automatically on every
`npm install`. The deploy server does not need it — it only pulls, never commits
or pushes, so no hook ever fires there. Emergency skip: `git commit --no-verify`.
+14 -12
View File
@@ -4,12 +4,15 @@ import { getLocale } from "next-intl/server";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { SiteAmbientBackdrop } from "@/components/layout/site-ambient-backdrop"; 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 { SiteFooter } from "@/components/layout/site-footer";
import { SiteHeader } from "@/components/layout/site-header";
import { ScrollSmootherProvider } from "@/components/scroll-smoother-provider"; import { ScrollSmootherProvider } from "@/components/scroll-smoother-provider";
import { JsonLd } from "@/components/seo/json-ld";
import { isSuperAdmin } from "@/lib/admin-auth"; import { isSuperAdmin } from "@/lib/admin-auth";
import { getMaintenanceMode, getSiteSettings, getSiteSettingsMediaBindings } from "@/lib/app-config"; import { getMaintenanceMode, getSeoSettings, getSiteSettings, getSiteSettingsMediaBindings } from "@/lib/app-config";
import { getLocalizedPath, resolveLocale } from "@/lib/locale"; import { getLocalizedPath, resolveLocale } from "@/lib/locale";
import { buildSiteJsonLd } from "@/lib/metadata";
type SiteLayoutProps = { type SiteLayoutProps = {
children: ReactNode; children: ReactNode;
@@ -24,13 +27,14 @@ export const revalidate = 0;
export default async function SiteLayout({ children, params }: SiteLayoutProps) { export default async function SiteLayout({ children, params }: SiteLayoutProps) {
noStore(); noStore();
await params; await params;
const [maintenanceEnabled, mediaBindings, siteSettings] = await Promise.all([ const [maintenanceEnabled, siteSettings, seo, mediaBindings] = await Promise.all([
getMaintenanceMode(), getMaintenanceMode(),
getSiteSettingsMediaBindings(),
getSiteSettings(), getSiteSettings(),
getSeoSettings(),
getSiteSettingsMediaBindings(),
]); ]);
const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale); 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 // 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 // itself. Every admin page/action re-checks the session independently, so
// this value is not a security boundary, only a UI convenience. // this value is not a security boundary, only a UI convenience.
@@ -42,18 +46,16 @@ export default async function SiteLayout({ children, params }: SiteLayoutProps)
return ( return (
<> <>
<JsonLd data={buildSiteJsonLd({ settings: siteSettings, seo, bindings: mediaBindings, locale: localeKey })} />
<ScrollSmootherProvider /> <ScrollSmootherProvider />
<SiteAmbientBackdrop /> <SiteAmbientBackdrop />
<SiteHeader <SiteDock defaultLocale={siteSettings.defaultLocale} isSuperAdmin={authenticated} />
lightLogoUrl={mediaBindings.siteLogoLight?.url}
darkLogoUrl={mediaBindings.siteLogoDark?.url}
defaultLocale={siteSettings.defaultLocale}
isSuperAdmin={authenticated}
/>
<div id="smooth-wrapper"> <div id="smooth-wrapper">
<div id="smooth-content"> <div id="smooth-content">
<div className="site-content-frame flex min-h-screen flex-col"> <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} /> <SiteFooter defaultLocale={siteSettings.defaultLocale} />
</div> </div>
</div> </div>
+1 -86
View File
@@ -5,18 +5,11 @@ import { Container } from "@/components/layout/container";
import { HomeHero } from "@/components/layout/home-hero"; import { HomeHero } from "@/components/layout/home-hero";
import { MotionFade } from "@/components/motion-fade"; import { MotionFade } from "@/components/motion-fade";
import { MagicBentoSection } from "@/components/home/magic-bento-section"; import { MagicBentoSection } from "@/components/home/magic-bento-section";
import { CapabilitiesSection } from "@/components/home/capabilities-section";
import { ContactCtaSection } from "@/components/home/contact-cta-section"; import { ContactCtaSection } from "@/components/home/contact-cta-section";
import { MarqueeSection } from "@/components/home/marquee-section"; import { MarqueeSection } from "@/components/home/marquee-section";
import { ProcessSection } from "@/components/home/process-section";
import { ProjectsSection } from "@/components/home/projects-section";
import type { import type {
CapabilitiesSectionCopy,
ContactCtaSectionCopy, ContactCtaSectionCopy,
MarqueeSectionCopy, MarqueeSectionCopy,
ProcessSectionCopy,
ProjectCardCopy,
ProjectsSectionCopy,
} from "@/components/home/types"; } from "@/components/home/types";
import { buildLocalizedMetadata } from "@/lib/metadata"; import { buildLocalizedMetadata } from "@/lib/metadata";
import { getLocalizedPath, resolveLocale } from "@/lib/locale"; import { getLocalizedPath, resolveLocale } from "@/lib/locale";
@@ -25,10 +18,6 @@ import {
getSiteSettings, getSiteSettings,
splitMarqueeRowItems, splitMarqueeRowItems,
} from "@/lib/app-config"; } from "@/lib/app-config";
import {
getLocalizedValue,
getPublishedPortfolioProjects,
} from "@/lib/portfolio";
import { type MarqueeRow } from "@/components/layout/stacked-marquee-section"; import { type MarqueeRow } from "@/components/layout/stacked-marquee-section";
type HomePageProps = { type HomePageProps = {
@@ -65,10 +54,9 @@ export default async function HomePage({ params }: HomePageProps) {
siteSettings.defaultLocale, siteSettings.defaultLocale,
); );
const [t, marqueeSettings, publishedProjects] = await Promise.all([ const [t, marqueeSettings] = await Promise.all([
getTranslations({ locale: localeKey, namespace: "homepage" }), getTranslations({ locale: localeKey, namespace: "homepage" }),
getMarqueeSettings(), getMarqueeSettings(),
getPublishedPortfolioProjects(),
]); ]);
const bentoCards = [ const bentoCards = [
@@ -112,28 +100,6 @@ export default async function HomePage({ params }: HomePageProps) {
description: t("marquee.description"), 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 = { const contactCtaCopy: ContactCtaSectionCopy = {
eyebrow: t("contactCta.eyebrow"), eyebrow: t("contactCta.eyebrow"),
title: t("contactCta.title"), title: t("contactCta.title"),
@@ -157,45 +123,6 @@ export default async function HomePage({ params }: HomePageProps) {
const portfolioHref = getLocalizedPath(localeKey, "/portfolio", siteSettings.defaultLocale); const portfolioHref = getLocalizedPath(localeKey, "/portfolio", siteSettings.defaultLocale);
const contactHref = getLocalizedPath(localeKey, "/contact", 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 ( return (
<> <>
@@ -226,19 +153,7 @@ export default async function HomePage({ params }: HomePageProps) {
<MarqueeSection copy={marqueeCopy} rows={marqueeRows} /> <MarqueeSection copy={marqueeCopy} rows={marqueeRows} />
</MotionFade> </MotionFade>
<MotionFade delay={0.08}>
<ProjectsSection copy={projectsCopy} items={mergedProjectCards} />
</MotionFade>
<MotionFade delay={0.1}> <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} /> <ContactCtaSection copy={contactCtaCopy} contactHref={contactHref} />
</MotionFade> </MotionFade>
</Container> </Container>
+89 -17
View File
@@ -4,16 +4,21 @@ import { notFound } from "next/navigation";
import { Container } from "@/components/layout/container"; import { Container } from "@/components/layout/container";
import { PageHero } from "@/components/layout/page-hero"; import { PageHero } from "@/components/layout/page-hero";
import { PortfolioCategoryFilter } from "@/components/site/portfolio-category-filter";
import { PortfolioProjectDetail } from "@/components/site/portfolio-project-detail"; import { PortfolioProjectDetail } from "@/components/site/portfolio-project-detail";
import { getSiteSettings } from "@/lib/app-config"; import { PortfolioProjectGrid } from "@/components/site/portfolio-project-grid";
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 { resolveLocale } from "@/lib/locale";
import { import {
getActivePortfolioCategories,
getLocalizedValue, getLocalizedValue,
getPublishedPortfolioProjectBySlug, getPublishedPortfolioProjects,
resolvePortfolioSlug,
} from "@/lib/portfolio"; } from "@/lib/portfolio";
type PortfolioItemPageProps = { type PortfolioSlugPageProps = {
params: Promise<{ params: Promise<{
locale: string; locale: string;
slug: string; slug: string;
@@ -22,48 +27,115 @@ type PortfolioItemPageProps = {
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export async function generateMetadata({ params }: PortfolioItemPageProps): Promise<Metadata> { export async function generateMetadata({ params }: PortfolioSlugPageProps): Promise<Metadata> {
const { slug } = await params; const { slug } = await params;
const siteSettings = await getSiteSettings(); const siteSettings = await getSiteSettings();
const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale); const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale);
const item = await getPublishedPortfolioProjectBySlug(slug); const resolved = await resolvePortfolioSlug(slug);
if (!item) { if (!resolved) {
const t = await getTranslations({ locale: localeKey, namespace: "portfolioPage" });
return await buildLocalizedMetadata({ return await buildLocalizedMetadata({
locale: localeKey, locale: localeKey,
pathname: `/portfolio/${slug}`, pathname: `/portfolio/${slug}`,
title: "Portfolio", title: t("title"),
description: "Portfolio item", description: t("intro"),
});
}
if (resolved.kind === "category") {
const t = await getTranslations({ locale: localeKey, namespace: "portfolioPage" });
return await buildLocalizedMetadata({
locale: localeKey,
pathname: `/portfolio/${slug}`,
title: getLocalizedValue(resolved.category.name, localeKey),
description: getLocalizedValue(resolved.category.description, localeKey) || t("intro"),
}); });
} }
return await buildLocalizedMetadata({ return await buildLocalizedMetadata({
locale: localeKey, locale: localeKey,
pathname: `/portfolio/${slug}`, pathname: `/portfolio/${slug}`,
title: getLocalizedValue(item.title, localeKey), title: getLocalizedValue(resolved.project.title, localeKey),
description: getLocalizedValue(item.summary, localeKey), description: getLocalizedValue(resolved.project.summary, localeKey),
image: resolved.project.coverImagePath,
type: "article",
publishedTime: resolved.project.publishedAt,
}); });
} }
export default async function PortfolioItemPage({ export default async function PortfolioSlugPage({ params }: PortfolioSlugPageProps) {
params,
}: PortfolioItemPageProps) {
const { slug } = await params; const { slug } = await params;
const siteSettings = await getSiteSettings(); const siteSettings = await getSiteSettings();
const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale); const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale);
const item = await getPublishedPortfolioProjectBySlug(slug); const resolved = await resolvePortfolioSlug(slug);
if (!item) { if (!resolved) {
notFound(); notFound();
} }
const t = await getTranslations({ locale: localeKey, namespace: "portfolioDetail" }); if (resolved.kind === "category") {
const { category } = resolved;
const [categories, projects] = await Promise.all([
getActivePortfolioCategories(),
getPublishedPortfolioProjects({ categorySlug: slug }),
]);
const t = await getTranslations({ locale: localeKey, namespace: "portfolioPage" });
return (
<>
<PageHero
locale={localeKey}
badge={t("heroBadge")}
title={getLocalizedValue(category.name, localeKey)}
description={getLocalizedValue(category.description, localeKey) || t("intro")}
/>
<Container className="flex flex-col gap-section pb-12 lg:pb-16">
<PortfolioCategoryFilter
locale={localeKey}
defaultLocale={siteSettings.defaultLocale}
categories={categories}
allLabel={t("all")}
activeCategorySlug={category.slug}
/>
<PortfolioProjectGrid
locale={localeKey}
defaultLocale={siteSettings.defaultLocale}
projects={projects}
emptyLabel={t("empty")}
openLabel={t("open")}
/>
</Container>
</>
);
}
const { project: item } = resolved;
const [t, seo] = await Promise.all([
getTranslations({ locale: localeKey, namespace: "portfolioDetail" }),
getSeoSettings(),
]);
const title = getLocalizedValue(item.title, localeKey); const title = getLocalizedValue(item.title, localeKey);
const category = getLocalizedValue(item.category.name, localeKey); const category = getLocalizedValue(item.category.name, localeKey);
const summary = getLocalizedValue(item.summary, 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 ( return (
<> <>
<JsonLd data={jsonLd} />
<PageHero <PageHero
locale={localeKey} locale={localeKey}
badge={category} badge={category}
@@ -1,96 +0,0 @@
import type { Metadata } from "next";
import { getLocale, getTranslations } from "next-intl/server";
import { notFound } from "next/navigation";
import { Container } from "@/components/layout/container";
import { PageHero } from "@/components/layout/page-hero";
import { PortfolioCategoryFilter } from "@/components/site/portfolio-category-filter";
import { PortfolioProjectGrid } from "@/components/site/portfolio-project-grid";
import { getSiteSettings } from "@/lib/app-config";
import { buildLocalizedMetadata } from "@/lib/metadata";
import { resolveLocale } from "@/lib/locale";
import {
getActivePortfolioCategories,
getActivePortfolioCategoryBySlug,
getLocalizedValue,
getPublishedPortfolioProjects,
} from "@/lib/portfolio";
type PortfolioCategoryPageProps = {
params: Promise<{
locale: string;
slug: string;
}>;
};
export const dynamic = "force-dynamic";
export async function generateMetadata({ params }: PortfolioCategoryPageProps): Promise<Metadata> {
const { slug } = await params;
const siteSettings = await getSiteSettings();
const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale);
const t = await getTranslations({ locale: localeKey, namespace: "portfolioPage" });
const category = await getActivePortfolioCategoryBySlug(slug);
if (!category) {
return await buildLocalizedMetadata({
locale: localeKey,
pathname: `/portfolio/category/${slug}`,
title: t("title"),
description: t("intro"),
});
}
return await buildLocalizedMetadata({
locale: localeKey,
pathname: `/portfolio/category/${slug}`,
title: getLocalizedValue(category.name, localeKey),
description: getLocalizedValue(category.description, localeKey) || t("intro"),
});
}
export default async function PortfolioCategoryPage({
params,
}: PortfolioCategoryPageProps) {
const { slug } = await params;
const [siteSettings, categories, category, projects] = await Promise.all([
getSiteSettings(),
getActivePortfolioCategories(),
getActivePortfolioCategoryBySlug(slug),
getPublishedPortfolioProjects({ categorySlug: slug }),
]);
const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale);
const t = await getTranslations({ locale: localeKey, namespace: "portfolioPage" });
if (!category) {
notFound();
}
return (
<>
<PageHero
locale={localeKey}
badge={t("heroBadge")}
title={getLocalizedValue(category.name, localeKey)}
description={getLocalizedValue(category.description, localeKey) || t("intro")}
/>
<Container className="flex flex-col gap-section pb-12 lg:pb-16">
<PortfolioCategoryFilter
locale={localeKey}
defaultLocale={siteSettings.defaultLocale}
categories={categories}
allLabel={t("all")}
activeCategorySlug={category.slug}
/>
<PortfolioProjectGrid
locale={localeKey}
defaultLocale={siteSettings.defaultLocale}
projects={projects}
emptyLabel={t("empty")}
openLabel={t("open")}
/>
</Container>
</>
);
}
@@ -1,21 +0,0 @@
import { getLocale } from "next-intl/server";
import { permanentRedirect } from "next/navigation";
import { getSiteSettings } from "@/lib/app-config";
import { getLocalizedPath, resolveLocale } from "@/lib/locale";
type PortfolioCategoryIndexPageProps = {
params: Promise<{
locale: string;
}>;
};
export default async function PortfolioCategoryIndexPage({
params,
}: PortfolioCategoryIndexPageProps) {
await params;
const siteSettings = await getSiteSettings();
const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale);
permanentRedirect(getLocalizedPath(localeKey, "/portfolio", siteSettings.defaultLocale));
}
+1 -1
View File
@@ -50,7 +50,7 @@ export default async function PortfolioPage({
const selectedCategory = resolvedSearchParams?.category ?? ""; const selectedCategory = resolvedSearchParams?.category ?? "";
if (selectedCategory) { if (selectedCategory) {
redirect(getLocalizedPath(localeKey, `/portfolio/category/${selectedCategory}`, siteSettings.defaultLocale)); redirect(getLocalizedPath(localeKey, `/portfolio/${selectedCategory}`, siteSettings.defaultLocale));
} }
const [categories, projects] = await Promise.all([ const [categories, projects] = await Promise.all([
+1
View File
@@ -30,6 +30,7 @@ export async function generateMetadata({ params }: SuccessPageProps): Promise<Me
pathname: "/success", pathname: "/success",
title: t("title"), title: t("title"),
description: t("text"), description: t("text"),
noIndex: true,
}); });
} }
+1
View File
@@ -34,6 +34,7 @@ export async function generateMetadata({ params }: ComingSoonPageProps): Promise
title: siteSettings.locales[localeKey].siteName, title: siteSettings.locales[localeKey].siteName,
description: t("description"), description: t("description"),
applyTitleTemplate: false, applyTitleTemplate: false,
noIndex: true,
}); });
} }
+27 -2
View File
@@ -1,6 +1,6 @@
"use server"; "use server";
import { and, eq, inArray } from "drizzle-orm"; import { eq, inArray } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { isRedirectError } from "next/dist/client/components/redirect-error"; import { isRedirectError } from "next/dist/client/components/redirect-error";
@@ -154,6 +154,19 @@ export async function upsertCategoryAction(formData: FormData) {
isActive: normalizeCheckboxValue(formData, "isActive"), 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) { if (parsed.id) {
await db.update(category).set(parsed).where(eq(category.id, parsed.id)); await db.update(category).set(parsed).where(eq(category.id, parsed.id));
} else { } else {
@@ -172,7 +185,9 @@ export async function upsertCategoryAction(formData: FormData) {
? parseZodError(error) ? parseZodError(error)
: isUniqueViolation(error) : isUniqueViolation(error)
? "Kategorie Slug muss eindeutig sein." ? "Kategorie Slug muss eindeutig sein."
: "Kategorie konnte nicht gespeichert werden."; : error instanceof Error && error.message.includes("Slug")
? error.message
: "Kategorie konnte nicht gespeichert werden.";
redirect(withFlash(redirectPath, { error: message })); redirect(withFlash(redirectPath, { error: message }));
} }
@@ -298,6 +313,16 @@ export async function saveProjectAction(formData: FormData) {
assets, 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 const existingProject = parsed.id
? ( ? (
await db await db
+71
View File
@@ -12,7 +12,9 @@ import {
SITE_SETTINGS_FAVICON_FIELD_KEY, SITE_SETTINGS_FAVICON_FIELD_KEY,
SITE_SETTINGS_LOGO_DARK_FIELD_KEY, SITE_SETTINGS_LOGO_DARK_FIELD_KEY,
SITE_SETTINGS_LOGO_LIGHT_FIELD_KEY, SITE_SETTINGS_LOGO_LIGHT_FIELD_KEY,
getSeoSettings,
getSiteSettings, getSiteSettings,
updateSeoSettings,
updateSiteSettings, updateSiteSettings,
} from "@/lib/app-config"; } from "@/lib/app-config";
import { getAdminAppPath, toInternalAdminPath } from "@/lib/admin-routing"; import { getAdminAppPath, toInternalAdminPath } from "@/lib/admin-routing";
@@ -24,6 +26,15 @@ import {
type SiteSettings, type SiteSettings,
} from "@/lib/site-settings"; } from "@/lib/site-settings";
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth"; 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 { replaceEntityMediaUsages } from "@/lib/media";
import { resolveMediaSelection } from "@/lib/media-service"; import { resolveMediaSelection } from "@/lib/media-service";
import { routing } from "@/i18n/routing"; import { routing } from "@/i18n/routing";
@@ -329,3 +340,63 @@ export async function saveSiteLocalizationSettingsAction(formData: FormData) {
redirect(withFlash(getAdminAppPath("/site-settings/localization"), { error: message })); 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";
+217
View File
@@ -295,6 +295,223 @@ html[lang="ar"] .eyebrow {
display: none; display: none;
} }
/* ---- Layered hero backdrop (HeroMotionBackdrop) ---- */
.hero-backdrop-base {
position: absolute;
inset: 0;
background: hsl(var(--background));
}
.hero-backdrop-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(hsl(var(--border) / 0.55) 1px, transparent 1px),
linear-gradient(90deg, hsl(var(--border) / 0.55) 1px, transparent 1px);
background-size: 30px 30px;
background-position: center top;
opacity: 0.55;
-webkit-mask-image: radial-gradient(ellipse 78% 58% at 50% 30%, #000 0%, transparent 72%);
mask-image: radial-gradient(ellipse 78% 58% at 50% 30%, #000 0%, transparent 72%);
}
.dark .hero-backdrop-grid {
opacity: 0.4;
}
.hero-backdrop-glow {
position: absolute;
inset: 0;
background:
radial-gradient(58% 46% at 50% 20%, hsl(var(--brand-primary) / 0.16), transparent 70%),
radial-gradient(46% 42% at 80% 10%, hsl(var(--brand-secondary) / 0.13), transparent 68%),
radial-gradient(48% 44% at 16% 18%, hsl(var(--brand-primary) / 0.1), transparent 66%);
}
.hero-orb {
position: absolute;
border-radius: 9999px;
filter: blur(64px);
will-change: transform;
}
.hero-orb-1 {
top: -10%;
inset-inline-start: -8%;
height: 32rem;
width: 32rem;
background: hsl(var(--brand-primary) / 0.18);
animation: hero-orb-drift-a 22s ease-in-out infinite;
}
.hero-orb-2 {
top: -6%;
inset-inline-end: -10%;
height: 28rem;
width: 28rem;
background: hsl(var(--brand-secondary) / 0.15);
animation: hero-orb-drift-b 27s ease-in-out infinite;
}
.hero-orb-3 {
bottom: -22%;
inset-inline-start: 24%;
height: 26rem;
width: 26rem;
background: hsl(var(--brand-primary) / 0.1);
animation: hero-orb-drift-a 31s ease-in-out infinite reverse;
}
.hero-backdrop-noise {
position: absolute;
inset: -8%;
opacity: 0.26;
mix-blend-mode: soft-light;
}
.dark .hero-backdrop-noise {
opacity: 0.1;
}
.hero-backdrop-floor {
position: absolute;
inset-inline: 0;
bottom: 0;
height: 40%;
background: linear-gradient(to bottom, transparent, hsl(var(--background)) 92%);
}
.hero-backdrop.is-compact .hero-backdrop-grid {
opacity: 0.32;
}
.hero-backdrop.is-compact .hero-orb {
opacity: 0.7;
filter: blur(72px);
}
@keyframes hero-orb-drift-a {
0%,
100% {
transform: translate3d(0, 0, 0) scale(1);
}
33% {
transform: translate3d(70px, -48px, 0) scale(1.1);
}
66% {
transform: translate3d(-42px, 26px, 0) scale(0.94);
}
}
@keyframes hero-orb-drift-b {
0%,
100% {
transform: translate3d(0, 0, 0) scale(1);
}
33% {
transform: translate3d(-80px, 42px, 0) scale(0.92);
}
66% {
transform: translate3d(38px, -24px, 0) scale(1.08);
}
}
@media (prefers-reduced-motion: reduce) {
.hero-orb {
animation: none;
}
}
/* ---- Hero entrance ----
Per-element rise is disabled: the site `template.tsx` now runs one unified
page-transition animation on every navigation instead. ---- */
.hero-rise {
opacity: 1;
}
.hero-content > *:nth-child(1) {
animation-delay: 0.06s;
}
.hero-content > *:nth-child(2) {
animation-delay: 0.14s;
}
.hero-content > *:nth-child(3) {
animation-delay: 0.22s;
}
.hero-content > *:nth-child(4) {
animation-delay: 0.3s;
}
.hero-content > *:nth-child(5) {
animation-delay: 0.38s;
}
.hero-content > *:nth-child(6) {
animation-delay: 0.46s;
}
@keyframes hero-rise-in {
from {
opacity: 0;
transform: translateY(18px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Focused glow directly behind the hero title */
.hero-title {
position: relative;
isolation: isolate;
}
.hero-title::before {
content: "";
position: absolute;
left: 50%;
top: 46%;
z-index: -1;
width: 84%;
height: 74%;
transform: translate(-50%, -50%);
background: radial-gradient(closest-side, hsl(var(--brand-primary) / 0.2), transparent 78%);
filter: blur(48px);
pointer-events: none;
}
.dark .hero-title::before {
background: radial-gradient(closest-side, hsl(var(--brand-primary) / 0.3), transparent 78%);
}
/* Scroll cue pinned to the bottom of the home hero */
.hero-scroll-anchor {
position: absolute;
bottom: 1.5rem;
left: 50%;
z-index: 10;
transform: translateX(-50%);
opacity: 0;
animation: hero-fade-in 0.6s ease 0.7s forwards;
}
.hero-scroll-anchor:hover {
transform: translateX(-50%) translateY(2px);
}
@keyframes hero-fade-in {
to {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.hero-rise,
.hero-scroll-anchor {
animation: none;
opacity: 1;
}
}
.hero-title-line { .hero-title-line {
display: inline-block; display: inline-block;
padding-inline: 0.02em; padding-inline: 0.02em;
+37 -3
View File
@@ -1,15 +1,49 @@
import type { MetadataRoute } from "next"; import type { MetadataRoute } from "next";
import { unstable_noStore as noStore } from "next/cache";
export default function robots(): MetadataRoute.Robots { import { getMaintenanceMode, getSeoSettings } from "@/lib/app-config";
const siteUrl = new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "https://mohfarawati.de"); 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 { return {
rules: [ rules: [
{ {
userAgent: "*", userAgent: "*",
disallow: ["/admin-internal"], allow: "/",
disallow: ROBOTS_DISALLOWED_PATHS,
}, },
], ],
sitemap: new URL("/sitemap.xml", siteUrl).toString(), 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 { unstable_noStore as noStore } from "next/cache";
import { routing } from "@/i18n/routing"; import { routing } from "@/i18n/routing";
import { getSiteSettings } from "@/lib/app-config"; import { getMaintenanceMode, getSeoSettings, getSiteSettings } from "@/lib/app-config";
import { getLocalizedPath } from "@/lib/locale"; import { getLocalizedPath, type AppLocale } from "@/lib/locale";
import { getPublishedPortfolioProjects } from "@/lib/portfolio"; import { toAbsoluteUrl } from "@/lib/metadata";
import { getActivePortfolioCategories, getPublishedPortfolioProjects } from "@/lib/portfolio";
function getSiteUrl(): URL { export const dynamic = "force-dynamic";
return new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "https://mohfarawati.de");
}
function toAbsoluteUrl(pathname: string): string { type EntryOptions = Pick<MetadataRoute.Sitemap[number], "changeFrequency" | "priority" | "lastModified">;
return new URL(pathname, getSiteUrl()).toString();
}
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, pathname: string,
defaultLocale: "de" | "en" | "ar", defaultLocale: AppLocale,
options?: Pick<MetadataRoute.Sitemap[number], "changeFrequency" | "priority" | "lastModified">, options?: EntryOptions,
): MetadataRoute.Sitemap { ): 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) => ({ return routing.locales.map((locale) => ({
url: toAbsoluteUrl(getLocalizedPath(locale, pathname, defaultLocale)), url: languages[locale],
lastModified: options?.lastModified, lastModified: options?.lastModified,
changeFrequency: options?.changeFrequency, changeFrequency: options?.changeFrequency,
priority: options?.priority, priority: options?.priority,
alternates: {
languages: {
...languages,
"x-default": languages[defaultLocale],
},
},
})); }));
} }
export default async function sitemap(): Promise<MetadataRoute.Sitemap> { export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
noStore(); noStore();
const siteSettings = await getSiteSettings(); const [siteSettings, seo, maintenanceEnabled] = await Promise.all([
getSiteSettings(),
getSeoSettings(),
getMaintenanceMode(),
]);
let projects: Awaited<ReturnType<typeof getPublishedPortfolioProjects>> = []; // While the site is hidden (maintenance) or indexing is off, publish an
// empty sitemap instead of advertising URLs that redirect or are noindex.
try { if (maintenanceEnabled || !seo.allowIndexing) {
projects = await getPublishedPortfolioProjects(); return [];
} catch {
projects = [];
} }
const categories = Array.from( const defaultLocale = siteSettings.defaultLocale;
new Map(projects.map((project) => [project.category.slug, project.category])).values(),
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 [ return [
...buildLocalizedEntries("/", siteSettings.defaultLocale, { ...buildLocalizedEntries("/", defaultLocale, {
changeFrequency: "weekly", changeFrequency: "weekly",
priority: 1, priority: 1,
lastModified: latestProjectDate,
}), }),
...buildLocalizedEntries("/about", siteSettings.defaultLocale, { ...buildLocalizedEntries("/about", defaultLocale, {
changeFrequency: "monthly", changeFrequency: "monthly",
priority: 0.8, priority: 0.8,
}), }),
...buildLocalizedEntries("/portfolio", siteSettings.defaultLocale, { ...buildLocalizedEntries("/portfolio", defaultLocale, {
changeFrequency: "weekly", changeFrequency: "weekly",
priority: 0.9, priority: 0.9,
lastModified: latestProjectDate,
}), }),
...categories.flatMap((category) => ...categoriesWithProjects.flatMap((category) =>
buildLocalizedEntries(`/portfolio/category/${category.slug}`, siteSettings.defaultLocale, { buildLocalizedEntries(`/portfolio/${category.slug}`, defaultLocale, {
changeFrequency: "weekly", changeFrequency: "weekly",
priority: 0.8, priority: 0.8,
lastModified: latestProjectDate,
}), }),
), ),
...buildLocalizedEntries("/contact", siteSettings.defaultLocale, { ...buildLocalizedEntries("/contact", defaultLocale, {
changeFrequency: "monthly", changeFrequency: "monthly",
priority: 0.7, priority: 0.7,
}), }),
...projects.flatMap((project) => ...projects.flatMap((project) =>
buildLocalizedEntries(`/portfolio/${project.slug}`, siteSettings.defaultLocale, { buildLocalizedEntries(`/portfolio/${project.slug}`, defaultLocale, {
lastModified: project.publishedAt ?? undefined, lastModified: project.publishedAt ?? undefined,
changeFrequency: "monthly", changeFrequency: "monthly",
priority: 0.8, priority: 0.8,
+24 -8
View File
@@ -14,6 +14,7 @@ export const dynamic = "force-dynamic";
const CONTENT_TYPES: Record<string, string> = { const CONTENT_TYPES: Record<string, string> = {
".ico": "image/x-icon", ".ico": "image/x-icon",
".gif": "image/gif",
".jpg": "image/jpeg", ".jpg": "image/jpeg",
".jpeg": "image/jpeg", ".jpeg": "image/jpeg",
".png": "image/png", ".png": "image/png",
@@ -30,15 +31,30 @@ export async function GET(_: Request, { params }: MediaFileRouteProps) {
try { try {
const absolutePath = resolveMediaUploadPath(publicPath); const absolutePath = resolveMediaUploadPath(publicPath);
const fileBuffer = await readFile(absolutePath); 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, { if (!contentType) {
status: 200, return new NextResponse("Not Found", { status: 404 });
headers: { }
"Content-Type": contentType,
"Cache-Control": "public, max-age=31536000, immutable", 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 { } catch {
return new NextResponse("Not Found", { return new NextResponse("Not Found", {
status: 404, status: 404,
+5 -1
View File
@@ -8,6 +8,7 @@ import {
LogOut, LogOut,
Palette, Palette,
PlusSquare, PlusSquare,
Search,
ShieldAlert, ShieldAlert,
SwatchBook, SwatchBook,
Tags, Tags,
@@ -40,6 +41,7 @@ type AdminDashboardCopy = {
siteSettings: string; siteSettings: string;
brandSettings?: string; brandSettings?: string;
localizationSettings?: string; localizationSettings?: string;
seoSettings?: string;
marquee?: string; marquee?: string;
smtp?: string; smtp?: string;
logout: string; logout: string;
@@ -50,7 +52,7 @@ type AdminDashboardShellProps = {
copy: AdminDashboardCopy; copy: AdminDashboardCopy;
active: "overview" | "maintenance" | "ui-kit" | "portfolio" | "media" | "site-settings" | "smtp" | "marquee"; active: "overview" | "maintenance" | "ui-kit" | "portfolio" | "media" | "site-settings" | "smtp" | "marquee";
portfolioChild?: "overview" | "projects" | "new-project" | "categories"; portfolioChild?: "overview" | "projects" | "new-project" | "categories";
siteSettingsChild?: "brand" | "localization"; siteSettingsChild?: "brand" | "localization" | "seo";
flash?: FlashMessages; flash?: FlashMessages;
logoutAction: () => Promise<void>; logoutAction: () => Promise<void>;
headerTitle: string; headerTitle: string;
@@ -100,6 +102,8 @@ export async function AdminDashboardShell({
: active === "site-settings" : active === "site-settings"
? siteSettingsChild === "localization" ? siteSettingsChild === "localization"
? Languages ? Languages
: siteSettingsChild === "seo"
? Search
: siteSettingsChild === "brand" : siteSettingsChild === "brand"
? Palette ? Palette
: Globe2 : 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>
);
}
+6 -7
View File
@@ -1,5 +1,4 @@
import { StackedMarqueeSection, type MarqueeRow } from "@/components/layout/stacked-marquee-section"; import { StackedMarqueeSection, type MarqueeRow } from "@/components/layout/stacked-marquee-section";
import { AppCard } from "@/components/ui/app-card";
import { SectionHeading } from "./section-heading"; import { SectionHeading } from "./section-heading";
import type { MarqueeSectionCopy } from "./types"; import type { MarqueeSectionCopy } from "./types";
@@ -10,18 +9,18 @@ type MarqueeSectionProps = {
export function MarqueeSection({ copy, rows }: MarqueeSectionProps) { export function MarqueeSection({ copy, rows }: MarqueeSectionProps) {
return ( return (
<section className="space-y-8 overflow-hidden"> <section className="space-y-8">
<SectionHeading <SectionHeading
eyebrow={copy.eyebrow} eyebrow={copy.eyebrow}
title={copy.title} title={copy.title}
description={copy.description} description={copy.description}
/> />
<AppCard className="overflow-hidden"> {/* Full-bleed band: breaks out of the centered container to span the
<div className="rounded-nested border border-border/70 bg-background py-6"> entire site width instead of sitting inside a card. */}
<StackedMarqueeSection rows={rows} className="py-0" /> <div className="relative left-1/2 w-screen -translate-x-1/2 overflow-hidden border-y border-border/60 bg-background/40 py-8">
</div> <StackedMarqueeSection rows={rows} className="py-0" />
</AppCard> </div>
</section> </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>
);
}
+149
View File
@@ -0,0 +1,149 @@
"use client";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { Plus, ShieldCheck } from "lucide-react";
import { useLocale, useTranslations } from "next-intl";
import { useState, type ReactNode } from "react";
import { LocaleToggle } from "@/components/layout/locale-toggle";
import { SoundToggle } from "@/components/sound-toggle";
import { ThemeToggle } from "@/components/theme-toggle";
import { buildAdminUrl } from "@/lib/admin-routing";
import type { AppLocale } from "@/lib/locale";
import { cn } from "@/lib/utils";
type FloatingControlsProps = {
locale: string;
defaultLocale: AppLocale;
isSuperAdmin?: boolean;
};
const pill =
"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;
export function FloatingControls({
locale,
defaultLocale,
isSuperAdmin = false,
}: FloatingControlsProps) {
const [open, setOpen] = useState(false);
const t = useTranslations("navigation");
const activeLocale = useLocale();
const isRtl = activeLocale === "ar";
const reducedMotion = useReducedMotion();
const controls: ReactNode[] = [
<SoundToggle
key="sound"
ariaLabel={t("soundMute")}
mutedAriaLabel={t("soundUnmute")}
variant="ghost"
className={cn(pill, "border-transparent bg-transparent")}
/>,
<ThemeToggle
key="theme"
ariaLabel={t("themeToggle")}
variant="ghost"
className={cn(pill, "border-transparent bg-transparent")}
/>,
<LocaleToggle
key="locale"
locale={locale}
defaultLocale={defaultLocale}
className={cn(pill, "border-transparent bg-transparent p-0")}
/>,
];
if (isSuperAdmin) {
controls.push(
<a key="admin" href={buildAdminUrl("/")} aria-label="Open admin dashboard" className={pill}>
<ShieldCheck className="h-[1.125rem] w-[1.125rem]" />
</a>,
);
}
const count = controls.length;
// Fan across a quarter arc from "up" (90°) to "sideways" (180°), mirrored for
// RTL so the items always spread away from the corner into the page.
const startAngle = 94;
const endAngle = 176;
const positionFor = (index: number) => {
const angle = count === 1 ? 135 : startAngle + ((endAngle - startAngle) * index) / (count - 1);
const radians = (angle * Math.PI) / 180;
return {
x: (isRtl ? -1 : 1) * Math.cos(radians) * RADIUS,
y: -Math.sin(radians) * RADIUS,
};
};
return (
<div
className="pointer-events-none fixed bottom-0 end-0 z-50 p-4"
style={{ paddingBottom: "calc(1.1rem + env(safe-area-inset-bottom, 0px))" }}
>
<div className="pointer-events-auto relative h-12 w-12">
<AnimatePresence>
{open ? (
<>
{/* click-away scrim */}
<motion.button
key="scrim"
type="button"
aria-hidden
tabIndex={-1}
onClick={() => setOpen(false)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 -z-10 cursor-default bg-transparent"
/>
{controls.map((control, index) => {
const { x, y } = positionFor(index);
return (
<motion.div
key={index}
className="absolute bottom-0 end-0 grid h-12 w-12 place-items-center"
initial={reducedMotion ? { opacity: 0 } : { opacity: 0, x: 0, y: 0, scale: 0.4 }}
animate={reducedMotion ? { opacity: 1 } : { opacity: 1, x, y, scale: 1 }}
exit={reducedMotion ? { opacity: 0 } : { opacity: 0, x: 0, y: 0, scale: 0.4 }}
transition={
reducedMotion
? { duration: 0 }
: { type: "spring", stiffness: 460, damping: 26, delay: index * 0.035 }
}
>
{control}
</motion.div>
);
})}
</>
) : null}
</AnimatePresence>
{/* trigger */}
<button
type="button"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
aria-label={open ? t("closeMenu") : t("openMenu")}
className={cn(
pill,
"relative h-12 w-12",
open && "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground",
)}
>
<motion.span
animate={{ rotate: open ? 135 : 0 }}
transition={reducedMotion ? { duration: 0 } : { type: "spring", stiffness: 400, damping: 22 }}
className="flex items-center justify-center"
>
<Plus className="h-5 w-5" />
</motion.span>
</button>
</div>
</div>
);
}
+25 -5
View File
@@ -1,13 +1,33 @@
import { cn } from "@/lib/utils";
type HeroMotionBackdropProps = { type HeroMotionBackdropProps = {
compact?: boolean; compact?: boolean;
}; };
export function HeroMotionBackdrop({}: HeroMotionBackdropProps) { /**
* Layered ambient backdrop rendered behind every hero (home + page).
* Pure CSS so it needs no client JS: a faded grid for structure, a soft
* brand glow, slowly drifting brand orbs, a grain overlay for texture, and
* a bottom fade that blends the hero into the page content. The orb drift is
* disabled under `prefers-reduced-motion` (see globals.css).
*/
export function HeroMotionBackdrop({ compact = false }: HeroMotionBackdropProps) {
return ( return (
<div className="hero-sheet pointer-events-none absolute inset-0 overflow-hidden"> <div
<div className="hero-sheet-base" /> aria-hidden="true"
<div className="hero-sheet-veil" /> className={cn(
<div className="hero-sheet-fade" /> "hero-backdrop pointer-events-none absolute inset-0 z-0 overflow-hidden",
compact && "is-compact",
)}
>
<div className="hero-backdrop-base" />
<div className="hero-backdrop-grid" />
<div className="hero-backdrop-glow" />
<span className="hero-orb hero-orb-1" />
<span className="hero-orb hero-orb-2" />
<span className="hero-orb hero-orb-3" />
<div className="hero-noise hero-backdrop-noise" />
<div className="hero-backdrop-floor" />
</div> </div>
); );
} }
+47
View File
@@ -0,0 +1,47 @@
"use client";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { LayoutRouterContext } from "next/dist/shared/lib/app-router-context.shared-runtime";
import { usePathname } from "next/navigation";
import { useContext, useState, type ReactNode } from "react";
/**
* 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);
const [frozen] = useState(context);
if (!frozen) {
return <>{children}</>;
}
return <LayoutRouterContext.Provider value={frozen}>{children}</LayoutRouterContext.Provider>;
}
/**
* 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 function PageTransition({ children }: { children: ReactNode }) {
const reducedMotion = useReducedMotion();
const pathname = usePathname();
return (
<AnimatePresence mode="wait">
<motion.div
key={pathname}
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: -20, filter: "blur(10px)" }}
transition={reducedMotion ? { duration: 0 } : { duration: 0.45, ease: [0.22, 1, 0.36, 1] }}
>
<FrozenRouter>{children}</FrozenRouter>
</motion.div>
</AnimatePresence>
);
}
+189
View File
@@ -0,0 +1,189 @@
"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";
import { getLocalizedPath, stripLocalePrefix, type AppLocale } from "@/lib/locale";
import { cn } from "@/lib/utils";
type SiteDockProps = {
defaultLocale: AppLocale;
/**
* Server-computed via `isSuperAdmin()` in the parent layout. This client
* component only decides whether to *render* the Admin shortcut — it performs
* no auth check and grants no access; the real guard lives server-side on
* each admin page/action.
*/
isSuperAdmin?: boolean;
};
type NavKey = "about" | "portfolio" | "products" | "contact";
type NavItem = {
key: NavKey;
path: string;
disabled?: boolean;
};
// "Home" is intentionally omitted — the logo (first dock icon) is the home link.
const navItems: NavItem[] = [
{ key: "about", path: "/about" },
{ key: "portfolio", path: "/portfolio" },
{ key: "products", path: "/products", disabled: true },
{ key: "contact", path: "/contact" },
];
function isNavItemActive(currentPath: string, itemPath: string) {
if (itemPath === "/") {
return currentPath === "/";
}
return currentPath === itemPath || currentPath.startsWith(`${itemPath}/`);
}
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 — 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={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">
<Link
href={homeHref}
aria-label="mohfarawati — Home"
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"
alt="mohfarawati"
width={104}
height={104}
sizes="104px"
priority
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 (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 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-[110%] w-[110%] object-cover", disabled && "opacity-55")}
/>
</motion.span>
);
return (
<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"
>
{icon}
</span>
) : (
<Link
href={getLocalizedPath(locale, itemPath, defaultLocale)}
aria-label={label}
aria-current={isActive ? "page" : undefined}
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>
);
})}
</Dock>
</div>
{/* ===== Corner controls — theme / language / sound / admin (fan out) ===== */}
<FloatingControls locale={locale} defaultLocale={defaultLocale} isSuperAdmin={isSuperAdmin} />
</>
);
}
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 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}
</span>
);
}
+13 -37
View File
@@ -1,30 +1,11 @@
"use client";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { ArrowDown } from "lucide-react"; import { ArrowDown } from "lucide-react";
import { motion } from "framer-motion";
import { Container } from "@/components/layout/container"; import { Container } from "@/components/layout/container";
import { HeroMotionBackdrop } from "@/components/layout/hero-motion-backdrop"; import { HeroMotionBackdrop } from "@/components/layout/hero-motion-backdrop";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const heroItemVariants = {
hidden: { opacity: 0, y: 16 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.54, ease: [0.22, 1, 0.36, 1] as [number, number, number, number] },
},
};
const heroContainerVariants = {
hidden: {},
visible: {
transition: { staggerChildren: 0.13, delayChildren: 0.08 },
},
};
type HeroTone = "default" | "accent" | "soft"; type HeroTone = "default" | "accent" | "soft";
type HeroLineAlign = "start" | "center" | "end"; type HeroLineAlign = "start" | "center" | "end";
@@ -128,6 +109,14 @@ export function HeroShell({
{children} {children}
</div> </div>
</Container> </Container>
{variant === "home" ? (
<HeroScrollLink
href="#home-content"
label="Scroll to content"
className="hero-scroll-anchor hidden sm:inline-flex"
/>
) : null}
</section> </section>
); );
} }
@@ -136,27 +125,14 @@ export function HeroContentMotion({
children, children,
className, className,
}: HeroContentMotionProps) { }: HeroContentMotionProps) {
return ( return <div className={cn("hero-content", className)}>{children}</div>;
<motion.div
className={className}
initial="hidden"
animate="visible"
variants={heroContainerVariants}
>
{children}
</motion.div>
);
} }
export function HeroMotionItem({ export function HeroMotionItem({
children, children,
className, className,
}: HeroContentMotionProps) { }: HeroContentMotionProps) {
return ( return <div className={cn("hero-rise", className)}>{children}</div>;
<motion.div className={className} variants={heroItemVariants}>
{children}
</motion.div>
);
} }
export function HeroTitle({ export function HeroTitle({
@@ -167,7 +143,7 @@ export function HeroTitle({
}: HeroTitleProps) { }: HeroTitleProps) {
const isArabic = locale === "ar"; const isArabic = locale === "ar";
const titleClassName = cn( const titleClassName = cn(
"text-balance font-semibold text-[hsl(var(--hero-ink))]", "hero-title hero-rise text-balance font-semibold text-[hsl(var(--hero-ink))]",
variant === "home" variant === "home"
? isArabic ? isArabic
? "mt-6 flex w-full max-w-[26rem] flex-col gap-y-1 px-4 text-[clamp(2.85rem,11vw,5.9rem)] leading-[1.06] tracking-[-0.03em] sm:max-w-[30rem] sm:px-0 md:max-w-[36rem] lg:max-w-[32rem]" ? "mt-6 flex w-full max-w-[26rem] flex-col gap-y-1 px-4 text-[clamp(2.85rem,11vw,5.9rem)] leading-[1.06] tracking-[-0.03em] sm:max-w-[30rem] sm:px-0 md:max-w-[36rem] lg:max-w-[32rem]"
@@ -177,7 +153,7 @@ export function HeroTitle({
); );
return ( return (
<motion.h1 className={titleClassName} variants={heroItemVariants}> <h1 className={titleClassName}>
{lines.map((line, index) => ( {lines.map((line, index) => (
<span <span
key={`${index}-${line.text}`} key={`${index}-${line.text}`}
@@ -202,7 +178,7 @@ export function HeroTitle({
)} )}
</span> </span>
))} ))}
</motion.h1> </h1>
); );
} }
+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) }}
/>
);
}
@@ -30,7 +30,7 @@ export function PortfolioCategoryFilter({
{categories.map((category) => ( {categories.map((category) => (
<CategoryLink <CategoryLink
key={category.id} key={category.id}
href={getLocalizedPath(locale, `/portfolio/category/${category.slug}`, defaultLocale)} href={getLocalizedPath(locale, `/portfolio/${category.slug}`, defaultLocale)}
label={getLocalizedValue(category.name, locale)} label={getLocalizedValue(category.name, locale)}
active={activeCategorySlug === category.slug} active={activeCategorySlug === category.slug}
/> />
+152
View File
@@ -0,0 +1,152 @@
"use client";
import React, { useRef, type PropsWithChildren } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import {
motion,
useMotionValue,
useSpring,
useTransform,
type MotionValue,
type MotionProps,
} from "framer-motion";
import { cn } from "@/lib/utils";
export interface DockProps extends VariantProps<typeof dockVariants> {
className?: string;
iconSize?: number;
iconMagnification?: number;
disableMagnification?: boolean;
iconDistance?: number;
direction?: "top" | "middle" | "bottom";
children: React.ReactNode;
}
const DEFAULT_SIZE = 40;
const DEFAULT_MAGNIFICATION = 60;
const DEFAULT_DISTANCE = 140;
const DEFAULT_DISABLEMAGNIFICATION = false;
const dockVariants = cva(
"supports-backdrop-blur:bg-white/10 supports-backdrop-blur:dark:bg-black/10 mx-auto flex w-max items-center justify-center gap-2 rounded-2xl border p-2 backdrop-blur-md",
);
const Dock = React.forwardRef<HTMLDivElement, DockProps>(
(
{
className,
children,
iconSize = DEFAULT_SIZE,
iconMagnification = DEFAULT_MAGNIFICATION,
disableMagnification = DEFAULT_DISABLEMAGNIFICATION,
iconDistance = DEFAULT_DISTANCE,
direction = "bottom",
...props
},
ref,
) => {
const mouseX = useMotionValue(Infinity);
const renderChildren = () => {
return React.Children.map(children, (child) => {
if (React.isValidElement<DockIconProps>(child) && child.type === DockIcon) {
return React.cloneElement(child, {
...child.props,
mouseX: mouseX,
size: iconSize,
magnification: iconMagnification,
disableMagnification: disableMagnification,
distance: iconDistance,
});
}
return child;
});
};
return (
<motion.div
ref={ref}
onMouseMove={(e) => mouseX.set(e.pageX)}
onMouseLeave={() => mouseX.set(Infinity)}
{...props}
className={cn(dockVariants({ className }), {
"items-start": direction === "top",
"items-center": direction === "middle",
"items-end": direction === "bottom",
})}
>
{renderChildren()}
</motion.div>
);
},
);
Dock.displayName = "Dock";
export interface DockIconProps
extends Omit<MotionProps & React.HTMLAttributes<HTMLDivElement>, "children"> {
size?: number;
magnification?: number;
disableMagnification?: boolean;
distance?: number;
mouseX?: MotionValue<number>;
className?: string;
children?: React.ReactNode;
props?: PropsWithChildren;
}
const DockIcon = ({
size = DEFAULT_SIZE,
magnification = DEFAULT_MAGNIFICATION,
disableMagnification,
distance = DEFAULT_DISTANCE,
mouseX,
className,
children,
...props
}: DockIconProps) => {
const ref = useRef<HTMLDivElement>(null);
const padding = 0;
const defaultMouseX = useMotionValue(Infinity);
const distanceCalc = useTransform(mouseX ?? defaultMouseX, (val: number) => {
const bounds = ref.current?.getBoundingClientRect() ?? { x: 0, width: 0 };
return val - bounds.x - bounds.width / 2;
});
const targetSize = disableMagnification ? size : magnification;
const sizeTransform = useTransform(
distanceCalc,
[-distance, 0, distance],
[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: 280,
damping: 22,
});
return (
<motion.div
ref={ref}
style={{ width: scaleSize, height: scaleSize, padding }}
className={cn(
"flex aspect-square cursor-pointer items-center justify-center rounded-full",
disableMagnification && "hover:bg-muted-foreground transition-colors",
className,
)}
{...props}
>
{children}
</motion.div>
);
};
DockIcon.displayName = "DockIcon";
export { Dock, DockIcon, dockVariants };
+9 -3
View File
@@ -181,8 +181,8 @@ i18n/routing.ts
Admin routing Admin routing
lib/admin-routing.ts lib/admin-routing.ts
Prisma access Database access (Drizzle)
lib/prisma.ts lib/db/index.ts, lib/db/schema.ts
Application configuration Application configuration
lib/app-config.ts lib/app-config.ts
@@ -191,7 +191,13 @@ Portfolio logic
lib/portfolio.ts lib/portfolio.ts
Media handling 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: - Contact form with:
- validation - validation
- email delivery - email delivery
- Success page after contact submission - Success page after contact submission (noindex)
- Maintenance redirect flow - 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 ### Admin
@@ -24,8 +27,12 @@
- Portfolio category management - Portfolio category management
- Portfolio project creation and editing - Portfolio project creation and editing
- Section and asset management inside each project - Section and asset management inside each project
- Media library with usage bindings - Media library with usage bindings (uploads are magic-byte checked, SVGs are
- Site settings management 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 - SMTP settings and test email
- Marquee settings - Marquee settings
- Maintenance toggle - 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 { cookies, headers } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
@@ -7,9 +7,13 @@ import { and, eq, like, lt } from "drizzle-orm";
import { db } from "./db"; import { db } from "./db";
import { appConfig } from "./db/schema"; import { appConfig } from "./db/schema";
import { getAdminAppPath } from "./admin-routing"; 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"; export const ADMIN_SESSION_COOKIE = SHARED_ADMIN_SESSION_COOKIE;
const ADMIN_SESSION_VALUE = "superadmin";
const MAX_FAILED_ATTEMPTS = 5; const MAX_FAILED_ATTEMPTS = 5;
const LOCKOUT_SECONDS = 15 * 60; const LOCKOUT_SECONDS = 15 * 60;
const ADMIN_LOCKOUT_KEY_PREFIX = "admin_lockout"; const ADMIN_LOCKOUT_KEY_PREFIX = "admin_lockout";
@@ -91,34 +95,12 @@ function getAdminCookieDomain(): string | undefined {
return hostname ? `.${hostname}` : undefined; return hostname ? `.${hostname}` : undefined;
} }
function signValue(value: string): string {
return createHmac("sha256", getSecret()).update(value).digest("hex");
}
function buildToken(): string { function buildToken(): string {
return `${ADMIN_SESSION_VALUE}.${signValue(ADMIN_SESSION_VALUE)}`; return buildAdminSessionToken();
} }
function verifyToken(token: string): boolean { function verifyToken(token: string): boolean {
const parts = token.split("."); return verifyAdminSessionToken(token);
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);
} }
async function getClientIp(): Promise<string> { async function getClientIp(): Promise<string> {
+9 -1
View File
@@ -7,6 +7,7 @@ import {
Mail, Mail,
Palette, Palette,
PlusSquare, PlusSquare,
Search,
ShieldAlert, ShieldAlert,
SwatchBook, SwatchBook,
Tags, Tags,
@@ -24,6 +25,7 @@ type AdminNavigationCopy = {
siteSettings: string; siteSettings: string;
brandSettings?: string; brandSettings?: string;
localizationSettings?: string; localizationSettings?: string;
seoSettings?: string;
marquee?: string; marquee?: string;
smtp?: string; smtp?: string;
}; };
@@ -41,7 +43,7 @@ export function getAdminNavigation(
copy: AdminNavigationCopy, copy: AdminNavigationCopy,
active: "overview" | "maintenance" | "ui-kit" | "portfolio" | "media" | "site-settings" | "smtp" | "marquee", active: "overview" | "maintenance" | "ui-kit" | "portfolio" | "media" | "site-settings" | "smtp" | "marquee",
portfolioChild?: "overview" | "projects" | "new-project" | "categories", portfolioChild?: "overview" | "projects" | "new-project" | "categories",
siteSettingsChild?: "brand" | "localization", siteSettingsChild?: "brand" | "localization" | "seo",
): AdminNavItem[] { ): AdminNavItem[] {
return [ return [
{ {
@@ -87,6 +89,12 @@ export function getAdminNavigation(
icon: Languages, icon: Languages,
active: siteSettingsChild === "localization", 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, syncMarqueeSettingsToGermanSource,
type MarqueeSettings, type MarqueeSettings,
} from "./marquee-settings"; } 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). // Small helpers over the app_config key/value table (Drizzle).
async function readConfigValue(key: string): Promise<string | undefined> { 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)); 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> { export async function getSiteSettingsMediaBindings(): Promise<SiteSettingsMediaBindings> {
try { try {
const usages = await db const usages = await db
+3 -2
View File
@@ -125,7 +125,8 @@ export async function createStandaloneMediaAsset(input: {
uploadFile: FormDataEntryValue | null; uploadFile: FormDataEntryValue | null;
}) { }) {
if (input.uploadFile instanceof File && input.uploadFile.size > 0) { 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 derivedLabel = input.uploadFile.name.replace(/\.[^.]+$/, "").trim();
const trimmedLabel = input.label.trim() || derivedLabel || "Media asset"; const trimmedLabel = input.label.trim() || derivedLabel || "Media asset";
@@ -135,7 +136,7 @@ export async function createStandaloneMediaAsset(input: {
return createMediaAsset({ return createMediaAsset({
source: MediaSource.UPLOAD, source: MediaSource.UPLOAD,
kind: input.kind, kind,
url: savedFile.url, url: savedFile.url,
fileName: savedFile.fileName, fileName: savedFile.fileName,
label: trimmedLabel, 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."); 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); 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."); throw new Error("Resolved media upload path escapes the upload root.");
} }
return absolutePath; 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) { export async function removeManagedMediaFile(filePath: string | null | undefined) {
if (!isManagedMediaFilePath(filePath)) { if (!isManagedMediaFilePath(filePath)) {
return false; return false;
@@ -76,16 +125,23 @@ export async function saveMediaUpload(file: File, folder: string) {
throw new Error("File is too large."); 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 safeBaseName = sanitizeBaseName(file.name.replace(/\.[^.]+$/, "")) || "asset";
const finalName = `${safeBaseName}-${randomUUID().slice(0, 8)}${extension}`; 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); const targetPath = path.join(targetDir, finalName);
await mkdir(targetDir, { recursive: true }); await mkdir(targetDir, { recursive: true });
await writeFile(targetPath, Buffer.from(await file.arrayBuffer())); await writeFile(targetPath, buffer);
return { return {
url: `/uploads/media/${folder}/${finalName}`, url: `/uploads/media/${safeFolder}/${finalName}`,
fileName: finalName, fileName: finalName,
mimeType: file.type, mimeType: file.type,
size: file.size, 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({ context.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
path: ["url"], path: ["url"],
+208 -25
View File
@@ -11,17 +11,50 @@ import {
getSiteSettings, getSiteSettings,
getSiteSettingsMediaBindings, getSiteSettingsMediaBindings,
} from "./app-config"; } 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"); 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(); 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) { export function buildLocaleAlternates(pathname: string, defaultLocale: AppLocale) {
const languages = Object.fromEntries( const languages = Object.fromEntries(
appLocales.map((locale) => [locale, toAbsoluteUrl(getLocalizedPathWithDefault(locale, pathname, defaultLocale))]), 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); .replace(PAGE_TITLE_TOKEN, title);
} }
function buildMetadataImages(imageUrl?: string | null) { function buildMetadataImages(imageUrl?: string | null, alt?: string) {
if (!imageUrl) { if (!imageUrl) {
return undefined; return undefined;
} }
@@ -54,22 +87,29 @@ function buildMetadataImages(imageUrl?: string | null) {
return [ return [
{ {
url: toAbsoluteUrl(imageUrl), url: toAbsoluteUrl(imageUrl),
alt,
}, },
]; ];
} }
export async function buildAppMetadata(): Promise<Metadata> { 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( export function buildAppMetadataFromConfig(
settings: SiteSettings, settings: SiteSettings,
bindings: SiteSettingsMediaBindings, bindings: SiteSettingsMediaBindings,
seo: SeoSettings = buildDefaultSeoSettings(),
): Metadata { ): Metadata {
const defaultLocaleSettings = settings.locales[settings.defaultLocale]; 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({ const siteIconUrls = buildSiteIconUrls({
siteName: defaultLocaleSettings.siteName, siteName: defaultLocaleSettings.siteName,
faviconVersion: bindings.favicon?.version, faviconVersion: bindings.favicon?.version,
@@ -81,6 +121,10 @@ export function buildAppMetadataFromConfig(
title: defaultLocaleSettings.siteName, title: defaultLocaleSettings.siteName,
description: defaultLocaleSettings.siteDescription, description: defaultLocaleSettings.siteDescription,
applicationName: defaultLocaleSettings.siteName, 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, manifest: siteIconUrls.manifestHref,
icons: { icons: {
icon: [{ url: siteIconUrls.faviconHref }], icon: [{ url: siteIconUrls.faviconHref }],
@@ -92,7 +136,8 @@ export function buildAppMetadataFromConfig(
description: defaultLocaleSettings.siteDescription, description: defaultLocaleSettings.siteDescription,
url: toAbsoluteUrl(getLocalizedPathWithDefault(settings.defaultLocale, "/", settings.defaultLocale)), url: toAbsoluteUrl(getLocalizedPathWithDefault(settings.defaultLocale, "/", settings.defaultLocale)),
siteName: defaultLocaleSettings.siteName, siteName: defaultLocaleSettings.siteName,
locale: settings.defaultLocale, locale: toOpenGraphLocale(settings.defaultLocale),
alternateLocale: appLocales.filter((locale) => locale !== settings.defaultLocale).map(toOpenGraphLocale),
type: "website", type: "website",
images: openGraphImages, images: openGraphImages,
}, },
@@ -100,12 +145,25 @@ export function buildAppMetadataFromConfig(
card: openGraphImages ? "summary_large_image" : "summary", card: openGraphImages ? "summary_large_image" : "summary",
title: defaultLocaleSettings.siteName, title: defaultLocaleSettings.siteName,
description: defaultLocaleSettings.siteDescription, description: defaultLocaleSettings.siteDescription,
site: seo.twitterHandle || undefined,
creator: seo.twitterHandle || undefined,
images: openGraphImages?.map((image) => image.url), 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; locale: string;
pathname: string; pathname: string;
title: string; title: string;
@@ -119,65 +177,190 @@ export async function buildLocalizedMetadata({
title, title,
description, description,
applyTitleTemplate, applyTitleTemplate,
...options
}: LocalizedMetadataInput): Promise<Metadata> { }: 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); const localeKey = resolveLocale(locale, settings.defaultLocale);
return buildLocalizedMetadataFromConfig({ return buildLocalizedMetadataFromConfig({
settings, settings,
bindings, bindings,
seo,
locale: localeKey, locale: localeKey,
pathname, pathname,
title, title,
description, description,
applyTitleTemplate, applyTitleTemplate,
...options,
}); });
} }
export function buildLocalizedMetadataFromConfig(input: { export function buildLocalizedMetadataFromConfig(
settings: SiteSettings; input: LocalizedMetadataOptions & {
bindings: SiteSettingsMediaBindings; settings: SiteSettings;
locale: AppLocale; bindings: SiteSettingsMediaBindings;
pathname: string; seo?: SeoSettings;
title: string; locale: AppLocale;
description?: string; pathname: string;
applyTitleTemplate?: boolean; title: string;
}): Metadata { description?: string;
applyTitleTemplate?: boolean;
},
): Metadata {
const { const {
settings, settings,
bindings, bindings,
seo = buildDefaultSeoSettings(),
locale, locale,
pathname, pathname,
title, title,
description, description,
applyTitleTemplate = true, applyTitleTemplate = true,
image,
noIndex = false,
type = "website",
publishedTime,
modifiedTime,
} = input; } = input;
const localeKey = resolveLocale(locale, settings.defaultLocale); const localeKey = resolveLocale(locale, settings.defaultLocale);
const localeSettings = settings.locales[localeKey]; const localeSettings = settings.locales[localeKey];
const resolvedDescription = description?.trim() || localeSettings.siteDescription; const resolvedDescription = (description?.trim() || localeSettings.siteDescription).slice(0, 300);
const resolvedTitle = applyTitleTemplate const resolvedTitle = applyTitleTemplate
? applyTitleTemplateFn(title, localeSettings.titleTemplate, localeSettings.siteName) ? applyTitleTemplateFn(title, localeSettings.titleTemplate, localeSettings.siteName)
: title; : title;
const openGraphImages = buildMetadataImages(bindings.defaultOgImage?.url); const openGraphImages = buildMetadataImages(image || bindings.defaultOgImage?.url, title);
const keywords = seo.locales[localeKey].keywords;
return { return {
title: resolvedTitle, title: resolvedTitle,
description: resolvedDescription, description: resolvedDescription,
keywords: keywords ? keywords.split(",").map((keyword) => keyword.trim()).filter(Boolean) : undefined,
robots: buildRobotsMetadata(seo, noIndex),
alternates: buildLocaleAlternates(pathname, settings.defaultLocale), alternates: buildLocaleAlternates(pathname, settings.defaultLocale),
openGraph: { openGraph: {
title: resolvedTitle, title: resolvedTitle,
description: resolvedDescription, description: resolvedDescription,
url: toAbsoluteUrl(getLocalizedPath(localeKey, pathname, settings.defaultLocale)), url: toAbsoluteUrl(getLocalizedPath(localeKey, pathname, settings.defaultLocale)),
siteName: localeSettings.siteName, siteName: localeSettings.siteName,
locale: localeKey, locale: toOpenGraphLocale(localeKey),
type: "website", alternateLocale: appLocales.filter((entry) => entry !== localeKey).map(toOpenGraphLocale),
images: openGraphImages, images: openGraphImages,
...(type === "article"
? {
type: "article" as const,
publishedTime: publishedTime?.toISOString(),
modifiedTime: (modifiedTime ?? publishedTime)?.toISOString(),
}
: { type: "website" as const }),
}, },
twitter: { twitter: {
card: openGraphImages ? "summary_large_image" : "summary", card: openGraphImages ? "summary_large_image" : "summary",
title: resolvedTitle, title: resolvedTitle,
description: resolvedDescription, 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");
}
+24
View File
@@ -285,6 +285,30 @@ export const getPublishedPortfolioProjectBySlug = cache(async function (slug: st
return mapProject(project); return mapProject(project);
}); });
export type ResolvedPortfolioSlug =
| { kind: "category"; category: PortfolioCategoryView }
| { kind: "project"; project: PortfolioProjectView }
| null;
/**
* Resolves a `/portfolio/[slug]` segment to either a category or a project.
* Categories take precedence so `/portfolio/web` shows the category listing;
* a project slug only wins when no active category shares that slug.
*/
export async function resolvePortfolioSlug(slug: string): Promise<ResolvedPortfolioSlug> {
const category = await getActivePortfolioCategoryBySlug(slug);
if (category) {
return { kind: "category", category };
}
const project = await getPublishedPortfolioProjectBySlug(slug);
if (project) {
return { kind: "project", project };
}
return null;
}
export async function getAdminPortfolioProjectById(id: string) { export async function getAdminPortfolioProjectById(id: string) {
const project = await db.query.portfolioProject.findFirst({ const project = await db.query.portfolioProject.findFirst({
where: eq(portfolioProject.id, id), where: eq(portfolioProject.id, id),
+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_FAVICON_PATH = "/favicon.ico";
const INTERNAL_APPLE_ICON_PATH = "/apple-icon.png"; 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 DEFAULT_ICON_VERSION = "default";
const TRANSPARENT_PNG_BASE64 = const TRANSPARENT_PNG_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9sot5WQAAAAASUVORK5CYII="; "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9sot5WQAAAAASUVORK5CYII=";
+1
View File
@@ -3,6 +3,7 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"prepare": "git config core.hooksPath .githooks || true",
"dev": "next dev -p 3014", "dev": "next dev -p 3014",
"build": "next build --webpack", "build": "next build --webpack",
"start": "next start", "start": "next start",
+2 -3
View File
@@ -16,6 +16,7 @@ import {
isLegacyAdminPath, isLegacyAdminPath,
toInternalAdminPath, toInternalAdminPath,
} from "./lib/admin-routing"; } from "./lib/admin-routing";
import { ADMIN_SESSION_COOKIE, verifyAdminSessionToken } from "./lib/admin-session-token";
import { import {
FALLBACK_LOCALE, FALLBACK_LOCALE,
getLocalizedPathWithDefault, getLocalizedPathWithDefault,
@@ -23,8 +24,6 @@ import {
stripLocalePrefix, stripLocalePrefix,
} from "./lib/locale"; } from "./lib/locale";
const ADMIN_SESSION_COOKIE = "moh_admin_session";
type SiteRuntimeState = { type SiteRuntimeState = {
defaultLocale: (typeof appLocales)[number]; defaultLocale: (typeof appLocales)[number];
maintenanceEnabled: boolean; maintenanceEnabled: boolean;
@@ -220,7 +219,7 @@ export default async function middleware(request: NextRequest) {
if ( if (
siteRuntimeState.maintenanceEnabled && siteRuntimeState.maintenanceEnabled &&
!request.cookies.has(ADMIN_SESSION_COOKIE) && !verifyAdminSessionToken(request.cookies.get(ADMIN_SESSION_COOKIE)?.value) &&
!isComingSoonPath(pathname) !isComingSoonPath(pathname)
) { ) {
const locale = getPathLocale(pathname, configuredDefaultLocale); const locale = getPathLocale(pathname, configuredDefaultLocale);
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

+1 -1
View File
@@ -27,7 +27,7 @@
- Overview dashboard - Overview dashboard
- Maintenance mode - Maintenance mode
- Media Library - Media Library
- Site Settings - Site Settings (Brand / Localization / SEO — see `docs/SEO.md`)
- Marquee Settings - Marquee Settings
- SMTP Settings - SMTP Settings
- Contact Protection - Contact Protection
@@ -35,7 +35,7 @@ describe("PortfolioCategoryFilter", () => {
expect(screen.getByRole("link", { name: "All" })).toHaveAttribute("href", "/en/portfolio"); expect(screen.getByRole("link", { name: "All" })).toHaveAttribute("href", "/en/portfolio");
expect(screen.getByRole("link", { name: "Branding" })).toHaveAttribute( expect(screen.getByRole("link", { name: "Branding" })).toHaveAttribute(
"href", "href",
"/en/portfolio/category/branding", "/en/portfolio/branding",
); );
}); });
@@ -51,7 +51,7 @@ describe("PortfolioCategoryFilter", () => {
// German locale on the default locale -> unprefixed paths and German labels // German locale on the default locale -> unprefixed paths and German labels
expect(screen.getByRole("link", { name: "Marke" })).toHaveAttribute( expect(screen.getByRole("link", { name: "Marke" })).toHaveAttribute(
"href", "href",
"/portfolio/category/branding", "/portfolio/branding",
); );
expect(screen.getByRole("link", { name: "Alle" })).toHaveAttribute("href", "/portfolio"); expect(screen.getByRole("link", { name: "Alle" })).toHaveAttribute("href", "/portfolio");
}); });
+10 -1
View File
@@ -32,7 +32,7 @@ describe("createMediaAssetAction", () => {
}); });
it.skipIf(!canManageUploads)("creates an asset from an uploaded file", async () => { 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(() => const url = await captureRedirect(() =>
createMediaAssetAction(formDataFrom({ kind: "IMAGE", label: "Pic", file })), createMediaAssetAction(formDataFrom({ kind: "IMAGE", label: "Pic", file })),
); );
@@ -43,6 +43,15 @@ describe("createMediaAssetAction", () => {
await removeManagedMediaFile(assets[0].url); 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 () => { it("redirects unauthenticated callers to the admin root", async () => {
adminAuth.authenticated = false; adminAuth.authenticated = false;
const url = await captureRedirect(() => createMediaAssetAction(formDataFrom({ kind: "IMAGE", label: "L" }))); const url = await captureRedirect(() => createMediaAssetAction(formDataFrom({ kind: "IMAGE", label: "L" })));
@@ -104,6 +104,14 @@ describe("upsertCategoryAction", () => {
expect(category?.nameEn).toBe("Renamed"); 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 () => { it("reports a unique-constraint violation on duplicate slugs", async () => {
await createCategory({ slug: "branding" }); await createCategory({ slug: "branding" });
const url = await captureRedirect(() => upsertCategoryAction(categoryForm({ slug: "branding" }))); const url = await captureRedirect(() => upsertCategoryAction(categoryForm({ slug: "branding" })));
@@ -141,6 +149,13 @@ describe("deleteCategoryAction", () => {
}); });
describe("saveProjectAction", () => { 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 () => { it("creates a published project with cover and asset media usages", async () => {
const category = await createCategory(); const category = await createCategory();
const url = await captureRedirect(() => saveProjectAction(projectForm(category.id))); const url = await captureRedirect(() => saveProjectAction(projectForm(category.id)));
@@ -11,10 +11,11 @@ vi.mock("@/lib/admin-auth", async () => {
}); });
import { import {
saveSeoSettingsAction,
saveSiteBrandSettingsAction, saveSiteBrandSettingsAction,
saveSiteLocalizationSettingsAction, saveSiteLocalizationSettingsAction,
} from "@/app/_admin/site-settings/actions"; } 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"; import { adminAuth, captureRedirect, formDataFrom, resetNextMocks } from "@/tests/helpers/next-mocks";
beforeEach(() => { beforeEach(() => {
@@ -102,3 +103,51 @@ describe("saveSiteLocalizationSettingsAction", () => {
expect(url).toBe("/"); 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)", () => { describe("resolveMediaSelection — upload mode (filesystem)", () => {
it.skipIf(!canManageUploads)("saves the file and creates an UPLOAD asset", async () => { 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({ const result = await resolveMediaSelection({
media: { mode: "upload", assetId: "", url: "", label: "Shot", kind: "IMAGE" }, media: { mode: "upload", assetId: "", url: "", label: "Shot", kind: "IMAGE" },
uploadFile: file, uploadFile: file,
+39
View File
@@ -8,6 +8,7 @@ import {
getAdminPortfolioProjects, getAdminPortfolioProjects,
getPublishedPortfolioProjectBySlug, getPublishedPortfolioProjectBySlug,
getPublishedPortfolioProjects, getPublishedPortfolioProjects,
resolvePortfolioSlug,
} from "@/lib/portfolio"; } from "@/lib/portfolio";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
@@ -124,6 +125,44 @@ describe("published projects", () => {
}); });
}); });
describe("resolvePortfolioSlug", () => {
it("resolves an active category slug to a category", async () => {
await createCategory({ slug: "web", isActive: true });
const resolved = await resolvePortfolioSlug("web");
expect(resolved?.kind).toBe("category");
expect(resolved?.kind === "category" && resolved.category.slug).toBe("web");
});
it("resolves a published project slug to a project", async () => {
const cat = await createCategory({ isActive: true });
await createProject({ categoryId: cat.id, slug: "my-project", isPublished: true });
const resolved = await resolvePortfolioSlug("my-project");
expect(resolved?.kind).toBe("project");
expect(resolved?.kind === "project" && resolved.project.slug).toBe("my-project");
});
it("prefers the category when a category and a project share a slug", async () => {
const cat = await createCategory({ slug: "shared", isActive: true });
await createProject({ categoryId: cat.id, slug: "shared", isPublished: true });
const resolved = await resolvePortfolioSlug("shared");
expect(resolved?.kind).toBe("category");
});
it("ignores an inactive category and falls back to a matching project", async () => {
// An inactive category named "hidden" must not shadow a published project "hidden".
await createCategory({ slug: "hidden", isActive: false });
const activeCat = await createCategory({ isActive: true });
await createProject({ categoryId: activeCat.id, slug: "hidden", isPublished: true });
const resolved = await resolvePortfolioSlug("hidden");
expect(resolved?.kind).toBe("project");
expect(resolved?.kind === "project" && resolved.project.slug).toBe("hidden");
});
it("returns null for an unknown slug", async () => {
expect(await resolvePortfolioSlug("does-not-exist")).toBeNull();
});
});
describe("referential integrity", () => { describe("referential integrity", () => {
it("restricts deleting a category that still has projects", async () => { it("restricts deleting a category that still has projects", async () => {
const cat = await createCategory(); const cat = await createCategory();
+1 -1
View File
@@ -42,7 +42,7 @@ describe("metadata helpers", () => {
apple: [{ url: "/apple-icon.png?v=v1" }], apple: [{ url: "/apple-icon.png?v=v1" }],
}); });
expect(metadata.openGraph).toMatchObject({ expect(metadata.openGraph).toMatchObject({
locale: "ar", locale: "ar_AR",
url: "https://mohfarawati.de/", url: "https://mohfarawati.de/",
}); });
expect(metadata.twitter).toMatchObject({ expect(metadata.twitter).toMatchObject({
+38 -2
View File
@@ -20,7 +20,7 @@ vi.mock("../lib/admin-routing", () => ({
toInternalAdminPath: (pathname: string) => pathname, toInternalAdminPath: (pathname: string) => pathname,
})); }));
function createMockRequest(url: string) { function createMockRequest(url: string, cookieValue?: string) {
const nextUrl = new URL(url) as URL & { clone: () => URL }; const nextUrl = new URL(url) as URL & { clone: () => URL };
nextUrl.clone = () => new URL(nextUrl.toString()); nextUrl.clone = () => new URL(nextUrl.toString());
@@ -31,11 +31,25 @@ function createMockRequest(url: string) {
host: nextUrl.host, host: nextUrl.host,
}), }),
cookies: { 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", () => { describe("middleware locale runtime config", () => {
beforeEach(() => { beforeEach(() => {
vi.resetModules(); vi.resetModules();
@@ -123,6 +137,28 @@ describe("middleware locale runtime config", () => {
expect(intlHandlerMock).not.toHaveBeenCalled(); 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 () => { it("prefers the configured internal runtime origin when provided", async () => {
process.env.SITE_RUNTIME_ORIGIN = "http://app:3000"; 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 minimal = { ...copy, brandSettings: undefined, localizationSettings: undefined, marquee: undefined, smtp: undefined };
const nav = getAdminNavigation(minimal, "overview"); const nav = getAdminNavigation(minimal, "overview");
const siteSettings = nav.find((item) => item.label === "Site Settings"); 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("/marquee"))?.label).toBe("Marquee");
expect(nav.find((item) => item.href.endsWith("/smtp"))?.label).toBe("SMTP"); 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, MEDIA_UPLOAD_ROOT,
getExtensionForMimeType, getExtensionForMimeType,
isManagedMediaFilePath, isManagedMediaFilePath,
isMediaContentValid,
removeManagedMediaFile, removeManagedMediaFile,
resolveMediaUploadPath, resolveMediaUploadPath,
sanitizeBaseName, sanitizeBaseName,
@@ -69,6 +70,42 @@ describe("resolveMediaUploadPath", () => {
it("throws when a traversal attempt escapes the root", () => { it("throws when a traversal attempt escapes the root", () => {
expect(() => resolveMediaUploadPath("/uploads/media/../../etc/passwd")).toThrow(/escapes/i); 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", () => { describe("removeManagedMediaFile", () => {
+5
View File
@@ -25,6 +25,11 @@ describe("mediaFieldInputSchema", () => {
expect(parsed.url).toBe("/uploads/media/x.png"); 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", () => { it("requires a url in external mode", () => {
expect(() => mediaFieldInputSchema.parse({ ...base, mode: "external" })).toThrow(/URL/i); expect(() => mediaFieldInputSchema.parse({ ...base, mode: "external" })).toThrow(/URL/i);
}); });
+106 -1
View File
@@ -1,11 +1,15 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { buildDefaultSeoSettings } from "@/lib/seo-settings";
import { buildDefaultSiteSettings } from "@/lib/site-settings"; import { buildDefaultSiteSettings } from "@/lib/site-settings";
import { import {
applyTitleTemplateFn, applyTitleTemplateFn,
buildAppMetadataFromConfig, buildAppMetadataFromConfig,
buildLocaleAlternates, buildLocaleAlternates,
buildLocalizedMetadataFromConfig, buildLocalizedMetadataFromConfig,
buildProjectJsonLd,
buildSiteJsonLd,
serializeJsonLd,
} from "@/lib/metadata"; } from "@/lib/metadata";
const noBindings = { const noBindings = {
@@ -82,7 +86,7 @@ describe("buildLocalizedMetadataFromConfig", () => {
}); });
expect(metadata.title).toBe("About | Studio"); expect(metadata.title).toBe("About | Studio");
expect(metadata.description).toBe("English description"); 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)", () => { it("can skip the title template (homepage)", () => {
@@ -111,3 +115,104 @@ describe("buildLocalizedMetadataFromConfig", () => {
expect(metadata.description).toBe("Custom desc"); 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");
});
});