Compare commits

...
46 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
moh 369c0e24c6 STYLED - Turn the project form into a step-by-step wizard journey
CI / quality (push) Canceled after 0s
Replace the flat two-column layout with a guided 4-step wizard
(Grundlagen -> Inhalte -> Abschnitte -> Medien) using the existing wizard
progress model:

- A top stepper shows each step's number/label and completion (check), and
  jumps to any step on click.
- One step is visible at a time in a single comfortable centered column; the
  others stay mounted but hidden, so every named field still submits.
- Footer navigation: Zurueck / Save as draft / Weiter, with Save Project on
  the final step.

No field-name or save-logic changes. All 375 tests pass; tsc and eslint clean.
2026-09-20 18:17:40 +02:00
moh d4b7f883e1 STYLED - Drop section cards in the project form, lay content flat on the page
Experiment: remove the AppCard wrappers around Basic Information, Localized
Content, Sections and Cover/Assets, plus the action bar, so the form content
sits directly on the page. Section headers now carry a thin underline and the
action bar a top divider to keep structure without card chrome. Two-column
layout preserved. No field-name or save-logic changes.
2026-09-20 18:01:03 +02:00
moh 19e8fa8f19 STYLED - Two-column project form, drop the redundant header card
- Remove the project summary/header card (project name, badges, progress);
  it added no value while creating a project. Move the on-submit validation
  alert down to the action bar.
- Lay the whole form out in two columns: Basic Information + Localized
  Content, then Sections + Cover/Assets. Basic Information fields cap at two
  columns and the Cover/Assets card stacks inside its column.

All 375 tests pass; tsc and eslint clean.
2026-09-20 17:40:45 +02:00
moh 077e1c5836 STYLED - Flatten media picker and use a two-column media layout
Fix the deeply nested cards in the project form (screenshot: Cover Media >
Cover > preview card, with duplicate labels):

- MediaFieldPicker no longer wraps itself in AppCards. It renders one flat
  bordered box laid out in two columns (preview | controls), dropping two
  nested card layers and the duplicate title.
- Cover Media and Assets now sit side by side in a two-column grid instead
  of stacked with a separator.

All 375 tests pass; tsc and eslint clean.
2026-09-20 17:30:44 +02:00
moh 2f8fd66812 STYLED - Simplify and compact the add-project form
Reorganize the project form for a lighter, more professional flow without
touching field names or the save action (all data preserved):

- LocaleInputs: drop the per-locale card wrappers for a tight 3-column
  labelled grid; shorter textareas.
- Sections and Assets: collapse each item into an accordion (closed by
  default) and show a proper empty state when there are none.
- Slim the header to badges + title + a compact progress indicator instead
  of a stats grid; drop the assets stats sidebar.
- Tighten section paddings/spacing (lg -> md) throughout.

All 375 tests pass; tsc and eslint clean.
2026-09-20 17:16:52 +02:00
moh 40387c19d4 STYLED - Turn the admin portfolio overview into a professional table
Replace the card grid with a compact data table (cover, project, category,
year, status, layout, actions) and a proper empty state that adapts to an
active category filter. Actions expose a public-view link plus the existing
edit/delete menu. No data-layer or save changes.
2026-09-20 17:08:20 +02:00
moh d1245204f1 STYLED - Roll out the layered card system to the admin workspace
The double-layer AppCard look showcased in the UI Kit was only applied on
the public frontend; every admin surface was pinned to layer=single and
stayed flat. Convert the admin section/container cards to the default
layered look, keeping compact metric tiles, toolbars, image tiles and
nested leaf cards single.

Add AppCard contentClassName so content utilities (space-y-*, grid, flex)
keep applying to the inner shell after the switch to double layer.
2026-09-20 17:01:20 +02:00
moh 4b535694bc ADDED - Save an unfinished project as a draft
- Add a Save as draft button that bypasses the completeness gate: the user
  can save partial work and finish later
- projectDraftInputSchema makes the user-facing copy (title, summary, service
  label, client) optional; the action auto-generates a slug from the title
  (or draft-<timestamp>), defaults the year to the current year, and forces
  the project unpublished
- Drafts drop not-yet-valid sections/assets instead of failing the save
- Pass intent via a hidden field set on click so the server reliably sees it
- Tests: draft schema accepts empty copy a strict save rejects, and still
  requires a slug and a valid year
2026-09-20 16:52:13 +02:00
moh 39e459fa09 REVERTED - Drop body suppressHydrationWarning
- The hydration warning came from a browser extension, not the app; handling
  it at the extension level instead of masking it in the layout
2026-09-20 16:44:03 +02:00
moh 80b1365ebc FIXED - Suppress body hydration warning from browser extensions
- Browser extensions (e.g. ColorZilla's cz-shortcut-listen) inject attributes
  on <body> before React hydrates, causing a benign hydration mismatch warning
- Add suppressHydrationWarning to <body>, matching the existing <html>
2026-09-20 16:42:58 +02:00
moh 3c2421fb8b STYLED - Convert the add-project form from a 4-tab wizard to one page
- Remove the tab navigation, per-step sidebar, and Previous/Next buttons;
  render Basics, Localized Content, Sections and Assets stacked on a single
  scrollable page
- Drop the now-unused wizard step machinery (currentStep state, wizardSteps,
  StepStateBadge, step stat card) and related imports
- Keep the same fields, validation, and save gating (Basics + Localized
  Content required; sections/assets optional)
2026-09-20 16:39:59 +02:00
moh 22cd3f8645 STYLED - Rework project detail into three real layouts per viewMode
- CASE_STUDY -> Web: two columns, stacked full-width screenshots with a
  browser frame plus a sticky project info panel
- GRID -> Print: mixed-size gallery grid for logos/posters/etc.
- STORY -> Editorial: full-bleed hero with title overlay, then alternating
  text/media sections
- Add a prominent title/summary intro (was missing from the header)
- Use PortfolioCover for all media so empty projects show the branded
  placeholder instead of broken images; gallery sections render even
  before an image is uploaded
2026-09-20 16:27:05 +02:00
moh 26969e25b2 FIXED - Load .env in drizzle.config so drizzle-kit targets the right DB
- drizzle-kit does not read .env like the Next.js app, so DATABASE_URL was
  undefined and fell back to the default host, causing migrate to hang and
  push to fail against the wrong database
- Load .env via native process.loadEnvFile for local dev, guarded so
  production (env already set, no .env file) is unaffected
2026-09-20 02:05:54 +02:00
moh d78450fc47 FIXED - Make adding a portfolio project simpler and actually saveable
- Fix critical bug: TabsContent unmounted inactive panels, so the project
  form only submitted the active tab's fields and could never save; add an
  opt-in forceMount that keeps panels mounted (hidden) and use it on all
  four project-form tabs
- Make Sections and Assets optional: a project saves with just Basics and
  Localized Content; new projects start with no sections/assets, and the
  last one can now be removed
- Update wizard progress so empty sections/assets steps count as complete
- Update save hint copy and step labels to reflect the optional steps
- Correct .claude/launch.json dev port to 3014
- Update portfolio-form-progress tests for the optional-steps behaviour
2026-09-20 01:56:50 +02:00
94 changed files with 4076 additions and 1390 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
"name": "dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 3000
"port": 3014
}
]
}
+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` |
| AppConfig aggregate | `lib/app-config.ts` |
| Portfolio queries | `lib/portfolio.ts` |
| Media handling | `lib/media.ts` |
| Media handling | `lib/media.ts`, `lib/media-storage.ts` |
| Contact flow | `lib/mail.ts` |
| SEO (metadata, robots, sitemap, JSON-LD) | `lib/metadata.ts`, `lib/seo-settings.ts`, `app/robots.ts`, `app/sitemap.ts` — see `docs/SEO.md` |
| Admin session token (middleware + auth) | `lib/admin-session-token.ts` |
### Documentation to read by task scope
- **Small UI/copy/style fixes**: read only the relevant files
- **Feature changes**: read `specs/<feature>.md` + `docs/ARCHITECTURE.md` if structure is affected
- **Cross-cutting/architecture changes**: read `docs/ARCHITECTURE.md`, `docs/DOMAIN_RULES.md`, `docs/FEATURES.md`, and the relevant `specs/` file
- **SEO / metadata / robots / sitemap**: read `docs/SEO.md` first
Update `docs/` and `specs/` only when the change affects feature scope, business rules, architecture, or public behavior.
@@ -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.
- 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.
## 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 { 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 { SiteHeader } from "@/components/layout/site-header";
import { ScrollSmootherProvider } from "@/components/scroll-smoother-provider";
import { JsonLd } from "@/components/seo/json-ld";
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 { buildSiteJsonLd } from "@/lib/metadata";
type SiteLayoutProps = {
children: ReactNode;
@@ -24,13 +27,14 @@ export const revalidate = 0;
export default async function SiteLayout({ children, params }: SiteLayoutProps) {
noStore();
await params;
const [maintenanceEnabled, mediaBindings, siteSettings] = await Promise.all([
const [maintenanceEnabled, siteSettings, seo, mediaBindings] = await Promise.all([
getMaintenanceMode(),
getSiteSettingsMediaBindings(),
getSiteSettings(),
getSeoSettings(),
getSiteSettingsMediaBindings(),
]);
const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale);
// Server-side decision only. The header receives just this boolean and uses
// Server-side decision only. The dock receives just this boolean and uses
// it purely to show/hide the Admin shortcut link — it grants no access by
// itself. Every admin page/action re-checks the session independently, so
// this value is not a security boundary, only a UI convenience.
@@ -42,18 +46,16 @@ export default async function SiteLayout({ children, params }: SiteLayoutProps)
return (
<>
<JsonLd data={buildSiteJsonLd({ settings: siteSettings, seo, bindings: mediaBindings, locale: localeKey })} />
<ScrollSmootherProvider />
<SiteAmbientBackdrop />
<SiteHeader
lightLogoUrl={mediaBindings.siteLogoLight?.url}
darkLogoUrl={mediaBindings.siteLogoDark?.url}
defaultLocale={siteSettings.defaultLocale}
isSuperAdmin={authenticated}
/>
<SiteDock defaultLocale={siteSettings.defaultLocale} isSuperAdmin={authenticated} />
<div id="smooth-wrapper">
<div id="smooth-content">
<div className="site-content-frame flex min-h-screen flex-col">
<main className="flex-1">{children}</main>
<main className="flex-1">
<PageTransition>{children}</PageTransition>
</main>
<SiteFooter defaultLocale={siteSettings.defaultLocale} />
</div>
</div>
+1 -86
View File
@@ -5,18 +5,11 @@ import { Container } from "@/components/layout/container";
import { HomeHero } from "@/components/layout/home-hero";
import { MotionFade } from "@/components/motion-fade";
import { MagicBentoSection } from "@/components/home/magic-bento-section";
import { CapabilitiesSection } from "@/components/home/capabilities-section";
import { ContactCtaSection } from "@/components/home/contact-cta-section";
import { MarqueeSection } from "@/components/home/marquee-section";
import { ProcessSection } from "@/components/home/process-section";
import { ProjectsSection } from "@/components/home/projects-section";
import type {
CapabilitiesSectionCopy,
ContactCtaSectionCopy,
MarqueeSectionCopy,
ProcessSectionCopy,
ProjectCardCopy,
ProjectsSectionCopy,
} from "@/components/home/types";
import { buildLocalizedMetadata } from "@/lib/metadata";
import { getLocalizedPath, resolveLocale } from "@/lib/locale";
@@ -25,10 +18,6 @@ import {
getSiteSettings,
splitMarqueeRowItems,
} from "@/lib/app-config";
import {
getLocalizedValue,
getPublishedPortfolioProjects,
} from "@/lib/portfolio";
import { type MarqueeRow } from "@/components/layout/stacked-marquee-section";
type HomePageProps = {
@@ -65,10 +54,9 @@ export default async function HomePage({ params }: HomePageProps) {
siteSettings.defaultLocale,
);
const [t, marqueeSettings, publishedProjects] = await Promise.all([
const [t, marqueeSettings] = await Promise.all([
getTranslations({ locale: localeKey, namespace: "homepage" }),
getMarqueeSettings(),
getPublishedPortfolioProjects(),
]);
const bentoCards = [
@@ -112,28 +100,6 @@ export default async function HomePage({ params }: HomePageProps) {
description: t("marquee.description"),
};
const projectsCopy: ProjectsSectionCopy = {
eyebrow: t("projects.eyebrow"),
title: t("projects.title"),
description: t("projects.description"),
stackLabel: t("projects.stackLabel"),
fallbackItems: t.raw("projects.fallbackItems") as ProjectsSectionCopy["fallbackItems"],
};
const capabilitiesCopy: CapabilitiesSectionCopy = {
eyebrow: t("capabilities.eyebrow"),
title: t("capabilities.title"),
description: t("capabilities.description"),
items: t.raw("capabilities.items") as CapabilitiesSectionCopy["items"],
};
const processCopy: ProcessSectionCopy = {
eyebrow: t("process.eyebrow"),
title: t("process.title"),
description: t("process.description"),
steps: t.raw("process.steps") as ProcessSectionCopy["steps"],
};
const contactCtaCopy: ContactCtaSectionCopy = {
eyebrow: t("contactCta.eyebrow"),
title: t("contactCta.title"),
@@ -157,45 +123,6 @@ export default async function HomePage({ params }: HomePageProps) {
const portfolioHref = getLocalizedPath(localeKey, "/portfolio", siteSettings.defaultLocale);
const contactHref = getLocalizedPath(localeKey, "/contact", siteSettings.defaultLocale);
const stackSets = t.raw("projects.stackSets") as string[][];
const selectedProjects = [
...publishedProjects.filter((project) => project.isFeatured),
...publishedProjects.filter((project) => !project.isFeatured),
].slice(0, 3);
const projectCards: ProjectCardCopy[] = selectedProjects.map((project, index) => ({
title: getLocalizedValue(project.title, localeKey),
summary: getLocalizedValue(project.summary, localeKey),
stack: stackSets[index] ?? stackSets[stackSets.length - 1] ?? [],
href: getLocalizedPath(
localeKey,
`/portfolio/${project.slug}`,
siteSettings.defaultLocale,
),
cta: t("projects.cta"),
meta: [
getLocalizedValue(project.category.name, localeKey),
getLocalizedValue(project.serviceLabel, localeKey),
String(project.projectYear),
],
featured: index === 0,
coverImagePath: project.coverImagePath,
}));
const fallbackProjectCards: ProjectCardCopy[] = projectsCopy.fallbackItems.map((item) => ({
title: item.title,
summary: item.summary,
stack: item.stack,
href: item.href === "/portfolio" ? portfolioHref : item.href === "/contact" ? contactHref : item.href,
cta: t("projects.cta"),
meta: [t("projects.fallbackMeta")],
}));
const mergedProjectCards = [
...projectCards,
...fallbackProjectCards.slice(0, Math.max(0, 3 - projectCards.length)),
].slice(0, 3);
return (
<>
@@ -226,19 +153,7 @@ export default async function HomePage({ params }: HomePageProps) {
<MarqueeSection copy={marqueeCopy} rows={marqueeRows} />
</MotionFade>
<MotionFade delay={0.08}>
<ProjectsSection copy={projectsCopy} items={mergedProjectCards} />
</MotionFade>
<MotionFade delay={0.1}>
<CapabilitiesSection copy={capabilitiesCopy} />
</MotionFade>
<MotionFade delay={0.12}>
<ProcessSection copy={processCopy} />
</MotionFade>
<MotionFade delay={0.14}>
<ContactCtaSection copy={contactCtaCopy} contactHref={contactHref} />
</MotionFade>
</Container>
+92 -20
View File
@@ -4,16 +4,21 @@ import { notFound } from "next/navigation";
import { Container } from "@/components/layout/container";
import { PageHero } from "@/components/layout/page-hero";
import { PortfolioCategoryFilter } from "@/components/site/portfolio-category-filter";
import { PortfolioProjectDetail } from "@/components/site/portfolio-project-detail";
import { getSiteSettings } from "@/lib/app-config";
import { buildLocalizedMetadata } from "@/lib/metadata";
import { PortfolioProjectGrid } from "@/components/site/portfolio-project-grid";
import { JsonLd } from "@/components/seo/json-ld";
import { getSeoSettings, getSiteSettings } from "@/lib/app-config";
import { buildLocalizedMetadata, buildProjectJsonLd } from "@/lib/metadata";
import { resolveLocale } from "@/lib/locale";
import {
getActivePortfolioCategories,
getLocalizedValue,
getPublishedPortfolioProjectBySlug,
getPublishedPortfolioProjects,
resolvePortfolioSlug,
} from "@/lib/portfolio";
type PortfolioItemPageProps = {
type PortfolioSlugPageProps = {
params: Promise<{
locale: string;
slug: string;
@@ -22,48 +27,115 @@ type PortfolioItemPageProps = {
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 siteSettings = await getSiteSettings();
const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale);
const item = await getPublishedPortfolioProjectBySlug(slug);
const resolved = await resolvePortfolioSlug(slug);
if (!item) {
if (!resolved) {
const t = await getTranslations({ locale: localeKey, namespace: "portfolioPage" });
return await buildLocalizedMetadata({
locale: localeKey,
pathname: `/portfolio/${slug}`,
title: "Portfolio",
description: "Portfolio item",
title: t("title"),
description: t("intro"),
});
}
if (resolved.kind === "category") {
const t = await getTranslations({ locale: localeKey, namespace: "portfolioPage" });
return await buildLocalizedMetadata({
locale: localeKey,
pathname: `/portfolio/${slug}`,
title: getLocalizedValue(resolved.category.name, localeKey),
description: getLocalizedValue(resolved.category.description, localeKey) || t("intro"),
});
}
return await buildLocalizedMetadata({
locale: localeKey,
pathname: `/portfolio/${slug}`,
title: getLocalizedValue(item.title, localeKey),
description: getLocalizedValue(item.summary, localeKey),
title: getLocalizedValue(resolved.project.title, localeKey),
description: getLocalizedValue(resolved.project.summary, localeKey),
image: resolved.project.coverImagePath,
type: "article",
publishedTime: resolved.project.publishedAt,
});
}
export default async function PortfolioItemPage({
params,
}: PortfolioItemPageProps) {
export default async function PortfolioSlugPage({ params }: PortfolioSlugPageProps) {
const { slug } = await params;
const siteSettings = await getSiteSettings();
const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale);
const item = await getPublishedPortfolioProjectBySlug(slug);
const resolved = await resolvePortfolioSlug(slug);
if (!item) {
if (!resolved) {
notFound();
}
const t = await getTranslations({ locale: localeKey, namespace: "portfolioDetail" });
const title = getLocalizedValue(item.title, localeKey);
const category = getLocalizedValue(item.category.name, localeKey);
const summary = getLocalizedValue(item.summary, localeKey);
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 category = getLocalizedValue(item.category.name, localeKey);
const summary = getLocalizedValue(item.summary, localeKey);
const jsonLd = buildProjectJsonLd({
settings: siteSettings,
seo,
locale: localeKey,
pathname: `/portfolio/${slug}`,
title,
description: summary,
image: item.coverImagePath,
datePublished: item.publishedAt,
genre: category,
keywords: [getLocalizedValue(item.serviceLabel, localeKey), String(item.projectYear)].filter(Boolean),
clientName: item.clientName,
});
return (
<>
<JsonLd data={jsonLd} />
<PageHero
locale={localeKey}
badge={category}
@@ -1,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 ?? "";
if (selectedCategory) {
redirect(getLocalizedPath(localeKey, `/portfolio/category/${selectedCategory}`, siteSettings.defaultLocale));
redirect(getLocalizedPath(localeKey, `/portfolio/${selectedCategory}`, siteSettings.defaultLocale));
}
const [categories, projects] = await Promise.all([
+1
View File
@@ -30,6 +30,7 @@ export async function generateMetadata({ params }: SuccessPageProps): Promise<Me
pathname: "/success",
title: t("title"),
description: t("text"),
noIndex: true,
});
}
+1
View File
@@ -34,6 +34,7 @@ export async function generateMetadata({ params }: ComingSoonPageProps): Promise
title: siteSettings.locales[localeKey].siteName,
description: t("description"),
applyTitleTemplate: false,
noIndex: true,
});
}
+1 -1
View File
@@ -60,7 +60,7 @@ export default async function AdminMaintenancePage({
headerDescription={copy.subtitle}
>
<MotionFade delay={0.1}>
<AppCard layer="single">
<AppCard>
<CardContent className="space-y-4 p-6">
<p className="text-sm text-muted-foreground">{copy.maintenanceText}</p>
<div className="flex flex-wrap items-center gap-3">
+86 -11
View File
@@ -1,6 +1,6 @@
"use server";
import { and, eq, inArray } from "drizzle-orm";
import { eq, inArray } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { isRedirectError } from "next/dist/client/components/redirect-error";
@@ -30,10 +30,21 @@ import { getSiteSettings } from "@/lib/app-config";
import {
assetInputSchema,
categoryInputSchema,
projectDraftInputSchema,
projectInputSchema,
sectionInputSchema,
} from "@/lib/portfolio-validation";
/** Build a URL-safe slug from a title, falling back to a unique draft slug. */
function slugifyForDraft(input: string): string {
const base = input
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return base || `draft-${Date.now()}`;
}
async function ensureAdmin() {
if (!(await isAdminAuthenticated())) {
await clearAdminSessionCookie();
@@ -143,6 +154,19 @@ export async function upsertCategoryAction(formData: FormData) {
isActive: normalizeCheckboxValue(formData, "isActive"),
});
// Categories and projects share the public `/portfolio/[slug]` route, so a
// slug may only exist on one side. Categories win at resolve time, which
// would silently hide a project with the same slug.
const [projectWithSlug] = await db
.select({ id: portfolioProject.id })
.from(portfolioProject)
.where(eq(portfolioProject.slug, parsed.slug))
.limit(1);
if (projectWithSlug) {
throw new Error("Kategorie Slug ist bereits als Projekt Slug vergeben.");
}
if (parsed.id) {
await db.update(category).set(parsed).where(eq(category.id, parsed.id));
} else {
@@ -161,6 +185,8 @@ export async function upsertCategoryAction(formData: FormData) {
? parseZodError(error)
: isUniqueViolation(error)
? "Kategorie Slug muss eindeutig sein."
: error instanceof Error && error.message.includes("Slug")
? error.message
: "Kategorie konnte nicht gespeichert werden.";
redirect(withFlash(redirectPath, { error: message }));
@@ -204,28 +230,67 @@ export async function saveProjectAction(formData: FormData) {
const createdMediaAssetIds: string[] = [];
try {
const sections = parseJsonArray(formData.get("sections"), "sections").map((section, index) =>
const intent = String(formData.get("intent") ?? "save");
const isDraft = intent === "draft";
const parseSection = (section: Record<string, unknown>, index: number) =>
sectionInputSchema.parse({
...section,
media: section.media ? mediaFieldInputSchema.parse(section.media) : undefined,
sortOrder: section.sortOrder ?? index,
}),
);
});
const assets = parseJsonArray(formData.get("assets"), "assets").map((asset, index) =>
const parseAsset = (asset: Record<string, unknown>, index: number) =>
assetInputSchema.parse({
...asset,
media: asset.media ? mediaFieldInputSchema.parse(asset.media) : undefined,
sortOrder: asset.sortOrder ?? index,
}),
);
});
// A draft keeps only the entries that are already valid; a full save
// validates every entry strictly.
const sections = parseJsonArray(formData.get("sections"), "sections").flatMap((section, index) => {
if (!isDraft) {
return [parseSection(section, index)];
}
try {
return [parseSection(section, index)];
} catch {
return [];
}
});
const assets = parseJsonArray(formData.get("assets"), "assets").flatMap((asset, index) => {
if (!isDraft) {
return [parseAsset(asset, index)];
}
try {
return [parseAsset(asset, index)];
} catch {
return [];
}
});
const coverMedia = parseJsonObject(formData.get("coverMedia"), "coverMedia");
const parsed = projectInputSchema.parse({
const rawSlug = String(formData.get("slug") ?? "").trim();
const slug =
isDraft && !rawSlug
? slugifyForDraft(
String(formData.get("titleDe") ?? "") ||
String(formData.get("titleEn") ?? "") ||
String(formData.get("titleAr") ?? ""),
)
: rawSlug;
const rawYear = String(formData.get("projectYear") ?? "").trim();
const projectYear = isDraft && !rawYear ? String(new Date().getFullYear()) : rawYear;
const parsed = (isDraft ? projectDraftInputSchema : projectInputSchema).parse({
id: String(formData.get("id") ?? "").trim() || undefined,
categoryId: String(formData.get("categoryId") ?? ""),
slug: String(formData.get("slug") ?? ""),
slug,
viewMode: String(formData.get("viewMode") ?? "GRID"),
titleAr: String(formData.get("titleAr") ?? ""),
titleEn: String(formData.get("titleEn") ?? ""),
@@ -234,7 +299,7 @@ export async function saveProjectAction(formData: FormData) {
summaryEn: String(formData.get("summaryEn") ?? ""),
summaryDe: String(formData.get("summaryDe") ?? ""),
clientName: String(formData.get("clientName") ?? ""),
projectYear: String(formData.get("projectYear") ?? ""),
projectYear,
serviceLabelAr: String(formData.get("serviceLabelAr") ?? ""),
serviceLabelEn: String(formData.get("serviceLabelEn") ?? ""),
serviceLabelDe: String(formData.get("serviceLabelDe") ?? ""),
@@ -243,11 +308,21 @@ export async function saveProjectAction(formData: FormData) {
coverMedia: coverMedia ? mediaFieldInputSchema.parse(coverMedia) : undefined,
sortOrder: String(formData.get("sortOrder") ?? "0"),
isFeatured: normalizeCheckboxValue(formData, "isFeatured"),
isPublished: normalizeCheckboxValue(formData, "isPublished"),
isPublished: isDraft ? false : normalizeCheckboxValue(formData, "isPublished"),
sections,
assets,
});
const [categoryWithSlug] = await db
.select({ id: category.id })
.from(category)
.where(eq(category.slug, parsed.slug))
.limit(1);
if (categoryWithSlug) {
throw new Error("Projekt Slug ist bereits als Kategorie Slug vergeben.");
}
const existingProject = parsed.id
? (
await db
+1 -1
View File
@@ -104,7 +104,7 @@ export default async function AdminPortfolioProjectPage({
</MotionFade>
<MotionFade delay={0.2}>
<AppCard level={2} layer="single">
<AppCard level={2}>
<CardContent className="flex flex-col gap-4 p-6 lg:flex-row lg:items-center lg:justify-between">
<div>
<p className="text-sm font-medium text-foreground">{copy.dangerZone}</p>
+71
View File
@@ -12,7 +12,9 @@ import {
SITE_SETTINGS_FAVICON_FIELD_KEY,
SITE_SETTINGS_LOGO_DARK_FIELD_KEY,
SITE_SETTINGS_LOGO_LIGHT_FIELD_KEY,
getSeoSettings,
getSiteSettings,
updateSeoSettings,
updateSiteSettings,
} from "@/lib/app-config";
import { getAdminAppPath, toInternalAdminPath } from "@/lib/admin-routing";
@@ -24,6 +26,15 @@ import {
type SiteSettings,
} from "@/lib/site-settings";
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
import {
normalizeKeywords,
normalizeSameAs,
normalizeStructuredDataType,
normalizeTwitterHandle,
normalizeVerificationToken,
type SeoSettings,
} from "@/lib/seo-settings";
import { isCheckedFormValue } from "@/lib/form-data";
import { replaceEntityMediaUsages } from "@/lib/media";
import { resolveMediaSelection } from "@/lib/media-service";
import { routing } from "@/i18n/routing";
@@ -329,3 +340,63 @@ export async function saveSiteLocalizationSettingsAction(formData: FormData) {
redirect(withFlash(getAdminAppPath("/site-settings/localization"), { error: message }));
}
}
export async function saveSeoSettingsAction(formData: FormData) {
await ensureAdmin();
try {
const currentSettings = await getSeoSettings();
const googleRaw = String(formData.get("googleSiteVerification") ?? "").trim();
const bingRaw = String(formData.get("bingSiteVerification") ?? "").trim();
const twitterRaw = String(formData.get("twitterHandle") ?? "").trim();
const googleSiteVerification = normalizeVerificationToken(googleRaw);
const bingSiteVerification = normalizeVerificationToken(bingRaw);
const twitterHandle = normalizeTwitterHandle(twitterRaw);
if (googleRaw && !googleSiteVerification) {
throw new Error("Google Verification Code darf nur Buchstaben, Zahlen, - und _ enthalten.");
}
if (bingRaw && !bingSiteVerification) {
throw new Error("Bing Verification Code darf nur Buchstaben, Zahlen, - und _ enthalten.");
}
if (twitterRaw && !twitterHandle) {
throw new Error("X/Twitter Handle ist ungueltig (max. 15 Zeichen, Buchstaben/Zahlen/_).");
}
const parsedSettings: SeoSettings = {
...currentSettings,
allowIndexing: isCheckedFormValue(formData.get("allowIndexing")),
googleSiteVerification,
bingSiteVerification,
twitterHandle,
structuredDataType: normalizeStructuredDataType(formData.get("structuredDataType")),
structuredDataName: String(formData.get("structuredDataName") ?? "").trim().slice(0, 120),
structuredDataJobTitle: String(formData.get("structuredDataJobTitle") ?? "").trim().slice(0, 160),
sameAs: normalizeSameAs(formData.get("sameAs")),
locales: {
ar: { keywords: normalizeKeywords(formData.get("keywordsAr")) },
en: { keywords: normalizeKeywords(formData.get("keywordsEn")) },
de: { keywords: normalizeKeywords(formData.get("keywordsDe")) },
},
};
await updateSeoSettings(parsedSettings);
const siteSettings = await getSiteSettings();
await revalidateSiteSettingsPages(siteSettings.defaultLocale);
revalidatePath("/sitemap.xml");
revalidatePath("/robots.txt");
revalidatePath(toInternalAdminPath("/site-settings/seo"));
redirect(withFlash(getAdminAppPath("/site-settings/seo"), { success: "SEO Einstellungen gespeichert." }));
} catch (error) {
if (isRedirectError(error)) {
throw error;
}
const message =
error instanceof Error ? error.message : "SEO Einstellungen konnten nicht gespeichert werden.";
redirect(withFlash(getAdminAppPath("/site-settings/seo"), { error: message }));
}
}
+104
View File
@@ -0,0 +1,104 @@
import { redirect } from "next/navigation";
import { MotionFade } from "@/components/motion-fade";
import { SeoSettingsForm } from "@/components/admin/seo-settings-form";
import { AdminDashboardShell } from "@/components/admin/admin-dashboard-shell";
import { readFlash } from "@/lib/admin-feedback";
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
import { buildSiteUrl, getAdminAppPath } from "@/lib/admin-routing";
import {
getMaintenanceMode,
getSeoSettings,
getSiteSettings,
getSiteSettingsMediaBindings,
} from "@/lib/app-config";
import { getSiteUrl } from "@/lib/metadata";
import { INTERNAL_MANIFEST_PATH } from "@/lib/site-icons";
import { getPublishedPortfolioProjects } from "@/lib/portfolio";
import { buildSeoChecklist } from "@/lib/seo-report";
import buildSitemap from "@/app/sitemap";
import { saveSeoSettingsAction } from "../actions";
export const dynamic = "force-dynamic";
const copy = {
title: "SEO",
subtitle: "Indexierung, Verifizierung, strukturierte Daten, Sitemap und robots.txt.",
overview: "Uebersicht",
maintenance: "Wartungsmodus",
uiKit: "UI Kit",
media: "Media",
siteSettings: "Settings",
brandSettings: "Brand",
localizationSettings: "Localization",
seoSettings: "SEO",
smtp: "SMTP",
portfolio: "Portfolio",
logout: "Ausloggen",
backToSite: "Zur Website",
};
export default async function AdminSeoSettingsPage({
searchParams,
}: {
searchParams?: Promise<{ success?: string; error?: string }>;
}) {
const flash = readFlash(await searchParams);
if (!(await isAdminAuthenticated())) {
redirect(getAdminAppPath("/"));
}
async function logoutAction() {
"use server";
await clearAdminSessionCookie();
redirect(getAdminAppPath("/"));
}
const [seo, siteSettings, bindings, maintenanceEnabled, projects, sitemapEntries] = await Promise.all([
getSeoSettings(),
getSiteSettings(),
getSiteSettingsMediaBindings(),
getMaintenanceMode(),
getPublishedPortfolioProjects().catch(() => []),
buildSitemap().catch(() => []),
]);
const checks = buildSeoChecklist({
seo,
settings: siteSettings,
bindings,
maintenanceEnabled,
publishedProjectCount: projects.length,
sitemapEntryCount: sitemapEntries.length,
siteUrl: getSiteUrl().origin,
});
return (
<AdminDashboardShell
copy={copy}
active="site-settings"
flash={flash}
siteSettingsChild="seo"
logoutAction={logoutAction}
headerTitle={copy.title}
headerDescription={copy.subtitle}
>
<MotionFade delay={0.16}>
<SeoSettingsForm
action={saveSeoSettingsAction}
settings={seo}
checks={checks}
sitemapEntryCount={sitemapEntries.length}
links={{
sitemap: buildSiteUrl("/sitemap.xml"),
robots: buildSiteUrl("/robots.txt"),
manifest: buildSiteUrl(INTERNAL_MANIFEST_PATH),
}}
/>
</MotionFade>
</AdminDashboardShell>
);
}
@@ -0,0 +1 @@
export { default } from "../../../_admin/site-settings/seo/page";
+217
View File
@@ -295,6 +295,223 @@ html[lang="ar"] .eyebrow {
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 {
display: inline-block;
padding-inline: 0.02em;
+37 -3
View File
@@ -1,15 +1,49 @@
import type { MetadataRoute } from "next";
import { unstable_noStore as noStore } from "next/cache";
export default function robots(): MetadataRoute.Robots {
const siteUrl = new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "https://mohfarawati.de");
import { getMaintenanceMode, getSeoSettings } from "@/lib/app-config";
import { INTERNAL_ADMIN_PREFIX } from "@/lib/admin-routing";
import { getSiteUrl } from "@/lib/metadata";
export const dynamic = "force-dynamic";
/** Paths that must never be crawled even when indexing is enabled. */
export const ROBOTS_DISALLOWED_PATHS = [
INTERNAL_ADMIN_PREFIX,
"/root",
"/api/",
"/success",
"/coming-soon",
"/*/success",
"/*/coming-soon",
];
export function buildRobots(input: { indexable: boolean }): MetadataRoute.Robots {
const siteUrl = getSiteUrl();
if (!input.indexable) {
return {
rules: [{ userAgent: "*", disallow: "/" }],
host: siteUrl.origin,
};
}
return {
rules: [
{
userAgent: "*",
disallow: ["/admin-internal"],
allow: "/",
disallow: ROBOTS_DISALLOWED_PATHS,
},
],
sitemap: new URL("/sitemap.xml", siteUrl).toString(),
host: siteUrl.origin,
};
}
export default async function robots(): Promise<MetadataRoute.Robots> {
noStore();
const [seo, maintenanceEnabled] = await Promise.all([getSeoSettings(), getMaintenanceMode()]);
return buildRobots({ indexable: seo.allowIndexing && !maintenanceEnabled });
}
+1
View File
@@ -0,0 +1 @@
export { default } from "../../../_admin/site-settings/seo/page";
+68 -29
View File
@@ -2,72 +2,111 @@ import type { MetadataRoute } from "next";
import { unstable_noStore as noStore } from "next/cache";
import { routing } from "@/i18n/routing";
import { getSiteSettings } from "@/lib/app-config";
import { getLocalizedPath } from "@/lib/locale";
import { getPublishedPortfolioProjects } from "@/lib/portfolio";
import { getMaintenanceMode, getSeoSettings, getSiteSettings } from "@/lib/app-config";
import { getLocalizedPath, type AppLocale } from "@/lib/locale";
import { toAbsoluteUrl } from "@/lib/metadata";
import { getActivePortfolioCategories, getPublishedPortfolioProjects } from "@/lib/portfolio";
function getSiteUrl(): URL {
return new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "https://mohfarawati.de");
}
export const dynamic = "force-dynamic";
function toAbsoluteUrl(pathname: string): string {
return new URL(pathname, getSiteUrl()).toString();
}
type EntryOptions = Pick<MetadataRoute.Sitemap[number], "changeFrequency" | "priority" | "lastModified">;
function buildLocalizedEntries(
/**
* One entry per locale for a path, each carrying hreflang alternates so search
* engines link the three language versions together.
*/
export function buildLocalizedEntries(
pathname: string,
defaultLocale: "de" | "en" | "ar",
options?: Pick<MetadataRoute.Sitemap[number], "changeFrequency" | "priority" | "lastModified">,
defaultLocale: AppLocale,
options?: EntryOptions,
): MetadataRoute.Sitemap {
const languages = Object.fromEntries(
routing.locales.map((locale) => [locale, toAbsoluteUrl(getLocalizedPath(locale, pathname, defaultLocale))]),
) as Record<AppLocale, string>;
return routing.locales.map((locale) => ({
url: toAbsoluteUrl(getLocalizedPath(locale, pathname, defaultLocale)),
url: languages[locale],
lastModified: options?.lastModified,
changeFrequency: options?.changeFrequency,
priority: options?.priority,
alternates: {
languages: {
...languages,
"x-default": languages[defaultLocale],
},
},
}));
}
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
noStore();
const siteSettings = await getSiteSettings();
const [siteSettings, seo, maintenanceEnabled] = await Promise.all([
getSiteSettings(),
getSeoSettings(),
getMaintenanceMode(),
]);
let projects: Awaited<ReturnType<typeof getPublishedPortfolioProjects>> = [];
try {
projects = await getPublishedPortfolioProjects();
} catch {
projects = [];
// While the site is hidden (maintenance) or indexing is off, publish an
// empty sitemap instead of advertising URLs that redirect or are noindex.
if (maintenanceEnabled || !seo.allowIndexing) {
return [];
}
const categories = Array.from(
new Map(projects.map((project) => [project.category.slug, project.category])).values(),
const defaultLocale = siteSettings.defaultLocale;
let projects: Awaited<ReturnType<typeof getPublishedPortfolioProjects>> = [];
let categories: Awaited<ReturnType<typeof getActivePortfolioCategories>> = [];
try {
[projects, categories] = await Promise.all([
getPublishedPortfolioProjects(),
getActivePortfolioCategories(),
]);
} catch {
projects = [];
categories = [];
}
// Only categories that actually have published work get a landing URL;
// an empty category page has nothing to index.
const categoriesWithProjects = categories.filter((category) =>
projects.some((project) => project.category.slug === category.slug),
);
const latestProjectDate = projects.reduce<Date | undefined>((latest, project) => {
const date = project.publishedAt ?? undefined;
return date && (!latest || date > latest) ? date : latest;
}, undefined);
return [
...buildLocalizedEntries("/", siteSettings.defaultLocale, {
...buildLocalizedEntries("/", defaultLocale, {
changeFrequency: "weekly",
priority: 1,
lastModified: latestProjectDate,
}),
...buildLocalizedEntries("/about", siteSettings.defaultLocale, {
...buildLocalizedEntries("/about", defaultLocale, {
changeFrequency: "monthly",
priority: 0.8,
}),
...buildLocalizedEntries("/portfolio", siteSettings.defaultLocale, {
...buildLocalizedEntries("/portfolio", defaultLocale, {
changeFrequency: "weekly",
priority: 0.9,
lastModified: latestProjectDate,
}),
...categories.flatMap((category) =>
buildLocalizedEntries(`/portfolio/category/${category.slug}`, siteSettings.defaultLocale, {
...categoriesWithProjects.flatMap((category) =>
buildLocalizedEntries(`/portfolio/${category.slug}`, defaultLocale, {
changeFrequency: "weekly",
priority: 0.8,
lastModified: latestProjectDate,
}),
),
...buildLocalizedEntries("/contact", siteSettings.defaultLocale, {
...buildLocalizedEntries("/contact", defaultLocale, {
changeFrequency: "monthly",
priority: 0.7,
}),
...projects.flatMap((project) =>
buildLocalizedEntries(`/portfolio/${project.slug}`, siteSettings.defaultLocale, {
buildLocalizedEntries(`/portfolio/${project.slug}`, defaultLocale, {
lastModified: project.publishedAt ?? undefined,
changeFrequency: "monthly",
priority: 0.8,
+22 -6
View File
@@ -14,6 +14,7 @@ export const dynamic = "force-dynamic";
const CONTENT_TYPES: Record<string, string> = {
".ico": "image/x-icon",
".gif": "image/gif",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
@@ -30,15 +31,30 @@ export async function GET(_: Request, { params }: MediaFileRouteProps) {
try {
const absolutePath = resolveMediaUploadPath(publicPath);
const fileBuffer = await readFile(absolutePath);
const contentType = CONTENT_TYPES[path.extname(absolutePath).toLowerCase()] ?? "application/octet-stream";
const extension = path.extname(absolutePath).toLowerCase();
const contentType = CONTENT_TYPES[extension];
return new NextResponse(fileBuffer, {
status: 200,
headers: {
if (!contentType) {
return new NextResponse("Not Found", { status: 404 });
}
const headers: Record<string, string> = {
"Content-Type": contentType,
"Cache-Control": "public, max-age=31536000, immutable",
},
});
"X-Content-Type-Options": "nosniff",
};
// SVG is an active document type: sandbox it so an uploaded file can never
// run script or reach our origin even if it is opened directly.
if (extension === ".svg") {
headers["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
}
if (extension === ".pdf") {
headers["Content-Disposition"] = "inline";
}
return new NextResponse(new Uint8Array(fileBuffer), { status: 200, headers });
} catch {
return new NextResponse("Not Found", {
status: 404,
+5 -1
View File
@@ -8,6 +8,7 @@ import {
LogOut,
Palette,
PlusSquare,
Search,
ShieldAlert,
SwatchBook,
Tags,
@@ -40,6 +41,7 @@ type AdminDashboardCopy = {
siteSettings: string;
brandSettings?: string;
localizationSettings?: string;
seoSettings?: string;
marquee?: string;
smtp?: string;
logout: string;
@@ -50,7 +52,7 @@ type AdminDashboardShellProps = {
copy: AdminDashboardCopy;
active: "overview" | "maintenance" | "ui-kit" | "portfolio" | "media" | "site-settings" | "smtp" | "marquee";
portfolioChild?: "overview" | "projects" | "new-project" | "categories";
siteSettingsChild?: "brand" | "localization";
siteSettingsChild?: "brand" | "localization" | "seo";
flash?: FlashMessages;
logoutAction: () => Promise<void>;
headerTitle: string;
@@ -100,6 +102,8 @@ export async function AdminDashboardShell({
: active === "site-settings"
? siteSettingsChild === "localization"
? Languages
: siteSettingsChild === "seo"
? Search
: siteSettingsChild === "brand"
? Palette
: Globe2
+1 -1
View File
@@ -29,7 +29,7 @@ export function MarqueeSettingsForm({
<div className="grid gap-4 xl:grid-cols-4">
{rowMeta.map((row) => (
<AppCard key={`de-${row.key}`} level={2} layer="single" padding="sm" className="space-y-2">
<AppCard key={`de-${row.key}`} level={2} padding="sm" contentClassName="space-y-2">
<Label htmlFor={`${row.key}-de`} className="text-sm font-semibold text-foreground">{row.label}</Label>
<Textarea
id={`${row.key}-de`}
+31 -37
View File
@@ -6,7 +6,6 @@ import type { MediaKind } from "@/lib/db/enums";
import { Check, ImageIcon, Search, Trash2 } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { AppCard } from "@/components/ui/app-card";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -17,7 +16,6 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { MediaOption } from "@/lib/media";
import { cn } from "@/lib/utils";
@@ -89,29 +87,45 @@ export function MediaFieldPicker({
}, [serializedValue]);
return (
<AppCard level={2} layer="single" padding="sm" className="space-y-4">
<div className="space-y-2">
<input ref={hiddenInputRef} type="hidden" name={inputName} value={serializedValue} />
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<Label className="text-sm font-semibold text-foreground">{title}</Label>
<p className="text-sm text-muted-foreground">
Media must be selected from the
{" "}
Media Library
.
</p>
<div className="flex flex-col gap-3 rounded-nested border border-border/70 bg-background p-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-center gap-3">
{selectedOption ? (
<>
<img
src={selectedOption.url}
alt={selectedOption.label}
className="h-14 w-14 shrink-0 rounded-nested border border-border/60 object-cover"
/>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">{selectedOption.label}</p>
<p className="truncate text-xs text-muted-foreground">{selectedOption.source}</p>
</div>
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={() => setOpen(true)}>
</>
) : (
<div className="flex items-center gap-3 text-muted-foreground">
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-nested border border-dashed border-border/70">
<ImageIcon className="h-5 w-5" />
</div>
<span className="text-sm">Kein Medium ausgewählt</span>
</div>
)}
</div>
<div className="flex shrink-0 gap-2">
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(true)}>
<ImageIcon className="h-4 w-4" />
Select from Media
{selectedOption ? "Ändern" : "Auswählen"}
</Button>
{canClear ? (
<Button
type="button"
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
title={clearLabel}
onClick={() =>
onChange({
...value,
@@ -124,32 +138,12 @@ export function MediaFieldPicker({
}
>
<Trash2 className="h-4 w-4" />
{clearLabel}
<span className="sr-only">{clearLabel}</span>
</Button>
) : null}
</div>
</div>
<AppCard layer="single" padding="sm" className="rounded-nested border-border/70">
{selectedOption ? (
<div className="flex items-center gap-4">
<img
src={selectedOption.url}
alt={selectedOption.label}
className="h-16 w-16 rounded-nested object-cover"
/>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">{selectedOption.label}</p>
<p className="truncate text-xs text-muted-foreground">{selectedOption.source}</p>
</div>
</div>
) : (
<div className="rounded-nested border border-dashed border-border/70 px-4 py-6 text-sm text-muted-foreground">
No media selected.
</div>
)}
</AppCard>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-4xl">
<DialogHeader>
@@ -215,6 +209,6 @@ export function MediaFieldPicker({
</DialogFooter>
</DialogContent>
</Dialog>
</AppCard>
</div>
);
}
+1 -1
View File
@@ -486,7 +486,7 @@ export function MediaLibraryManager({
)
) : (
<MotionFade delay={0.18}>
<AppCard layer="single">
<AppCard>
<CardContent className="p-6 text-sm text-muted-foreground">
{copy.empty}
</CardContent>
@@ -110,7 +110,7 @@ function CategoryLocaleFields({
const descriptionKey = `description${locale.key}` as const;
return (
<AppCard key={`${idPrefix}-${locale.key}`} level={2} layer="single" padding="sm" className="space-y-4 rounded-nested">
<AppCard key={`${idPrefix}-${locale.key}`} level={2} padding="sm" className="rounded-nested" contentClassName="space-y-4">
<p className="text-sm font-medium text-foreground">{locale.label}</p>
<div className="space-y-2">
@@ -168,7 +168,7 @@ function CategoryStatusFields({
</div>
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_220px]">
<AppCard level={2} layer="single" padding="sm" className="rounded-nested">
<AppCard level={2} padding="sm" className="rounded-nested">
<div className="flex items-start gap-3">
{isActive ? (
<ShieldCheck className="mt-0.5 h-4 w-4 text-status-success" />
@@ -395,7 +395,7 @@ export function PortfolioCategoriesManager({
<DialogDescription>{copy.modalDescription}</DialogDescription>
</DialogHeader>
<AppCard level={2} layer="single" padding="sm" className="rounded-nested">
<AppCard level={2} padding="sm" className="rounded-nested">
<div className="grid gap-3 sm:grid-cols-3">
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">1. Basics</p>
@@ -426,7 +426,7 @@ export function PortfolioCategoriesManager({
</Dialog>
</div>
<AppCard level={3} layer="single">
<AppCard level={3}>
<CardContent className="space-y-4 p-6">
<div className="flex items-center gap-3">
<Layers3 className="h-5 w-5 text-brand-primary" />
+298 -380
View File
@@ -3,16 +3,14 @@
import type { PortfolioProjectViewMode, PortfolioSectionType } from "@/lib/db/enums";
import {
ArrowDown,
ArrowLeft,
ArrowRight,
ArrowUp,
BookOpenText,
BriefcaseBusiness,
CalendarDays,
CheckCircle2,
ChevronLeft,
ChevronRight,
Check,
CircleAlert,
CircleDashed,
Files,
FolderTree,
Grid2x2,
ImagePlus,
@@ -24,18 +22,22 @@ import {
UserRound,
} from "lucide-react";
import Link from "next/link";
import { type ReactNode, useEffect, useRef, useState } from "react";
import { Fragment, type ReactNode, useEffect, useRef, useState } from "react";
import { useFormStatus } from "react-dom";
import { MediaFieldPicker, type MediaFieldState } from "@/components/admin/media-field-picker";
import { StatsCard } from "@/components/dashboard/stats-card";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { AppCard } from "@/components/ui/app-card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import {
Select,
SelectContent,
@@ -43,7 +45,6 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { moveArrayItem } from "@/lib/array";
import { getAdminAppPath } from "@/lib/admin-routing";
@@ -53,6 +54,7 @@ import {
isPortfolioAssetReady,
isPortfolioSectionReady,
type PortfolioWizardStep,
type PortfolioWizardStepState,
} from "@/lib/portfolio-form-progress";
import type { MediaOption } from "@/lib/media";
import type { PortfolioCategoryView, PortfolioProjectView } from "@/lib/portfolio";
@@ -148,33 +150,65 @@ const viewModeOptions: Array<{
{ value: "CASE_STUDY", label: "Case Study", description: "Structured challenge/solution/outcome view. Sections 13 become the lead panels.", icon: BriefcaseBusiness },
];
const wizardSteps: Array<{
key: PortfolioWizardStep;
label: string;
description: string;
}> = [
{
key: "basics",
label: "Basics",
description: "Category, slug, client, year, and status.",
},
{
key: "content",
label: "Localized Content",
description: "Title, service label, and summary in all locales.",
},
{
key: "sections",
label: "Sections",
description: "Build the project story with ordered content blocks.",
},
{
key: "assets",
label: "Assets & Media",
description: "Cover and gallery assets from the media library.",
},
const WIZARD_STEPS: Array<{ key: PortfolioWizardStep; label: string; description: string }> = [
{ key: "basics", label: "Grundlagen", description: "Kategorie & Metadaten" },
{ key: "content", label: "Inhalte", description: "Lokalisierte Texte" },
{ key: "sections", label: "Abschnitte", description: "Projektgeschichte" },
{ key: "assets", label: "Medien", description: "Cover & Galerie" },
];
function WizardStepper({
activeKey,
progress,
onSelect,
}: {
activeKey: PortfolioWizardStep;
progress: PortfolioWizardStepState[];
onSelect: (key: PortfolioWizardStep) => void;
}) {
return (
<div className="flex items-center gap-1 overflow-x-auto pb-1">
{WIZARD_STEPS.map((step, index) => {
const complete = progress.find((item) => item.key === step.key)?.complete ?? false;
const active = step.key === activeKey;
return (
<Fragment key={step.key}>
<button
type="button"
onClick={() => onSelect(step.key)}
className={cn(
"flex shrink-0 items-center gap-2.5 rounded-pill px-3 py-2 text-left transition-colors",
active ? "bg-primary/10 text-foreground" : "text-muted-foreground hover:bg-accent/40",
)}
>
<span
className={cn(
"flex h-7 w-7 items-center justify-center rounded-pill border text-xs font-semibold",
active
? "border-primary bg-primary text-primary-foreground"
: complete
? "border-status-success/40 bg-status-success/15 text-status-success"
: "border-border/70 text-muted-foreground",
)}
>
{complete && !active ? <Check className="h-4 w-4" /> : index + 1}
</span>
<span className="hidden sm:block">
<span className="block text-sm font-semibold leading-none">{step.label}</span>
<span className="mt-0.5 block text-xs text-muted-foreground">{step.description}</span>
</span>
</button>
{index < WIZARD_STEPS.length - 1 ? (
<div className="h-px w-5 shrink-0 bg-border/60 sm:w-8" />
) : null}
</Fragment>
);
})}
</div>
);
}
function createMediaFieldState(params: {
kind: "IMAGE";
assetId?: string | null;
@@ -233,7 +267,8 @@ function createInitialState(
function createInitialSections(project: PortfolioProjectView | null | undefined): SectionFormValue[] {
if (!project?.sections.length) {
return [createEmptySection(0)];
// Sections are optional — start empty so a project can be saved without them.
return [];
}
return project.sections.map((section, index) => ({
@@ -259,7 +294,8 @@ function createInitialSections(project: PortfolioProjectView | null | undefined)
function createInitialAssets(project: PortfolioProjectView | null | undefined): AssetFormValue[] {
if (!project?.assets.length) {
return [createEmptyAsset(0)];
// Gallery assets are optional — start empty so a project can be saved without them.
return [];
}
return project.assets.map((asset, index) => ({
@@ -323,34 +359,32 @@ function LocaleInputs({
multiline?: boolean;
}) {
return (
<div className="space-y-3">
<div className="grid gap-3 xl:grid-cols-3">
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-[0.14em] text-muted-foreground">{title}</p>
<div className="grid gap-3 md:grid-cols-3">
{locales.map((locale) => (
<AppCard
key={`${title}-${locale.suffix}`}
level={2}
layer="single"
padding="sm"
className="space-y-2 rounded-nested"
>
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">{locale.label}</p>
<div key={`${title}-${locale.suffix}`} className="space-y-1.5">
<span className="block text-[11px] font-medium uppercase tracking-wide text-muted-foreground/70">
{locale.label}
</span>
{multiline ? (
<Textarea
name={namePrefix ? `${namePrefix}${locale.suffix}` : undefined}
rows={5}
placeholder={`${title} ${locale.label}`}
rows={3}
placeholder={locale.label}
value={values[locale.suffix]}
onChange={(event) => onChange(locale.suffix, event.target.value)}
className="resize-y"
/>
) : (
<Input
name={namePrefix ? `${namePrefix}${locale.suffix}` : undefined}
placeholder={`${title} ${locale.label}`}
placeholder={locale.label}
value={values[locale.suffix]}
onChange={(event) => onChange(locale.suffix, event.target.value)}
/>
)}
</AppCard>
</div>
))}
</div>
</div>
@@ -367,7 +401,7 @@ function SectionHeader({
action?: ReactNode;
}) {
return (
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="flex flex-col gap-4 border-b border-border/60 pb-3 lg:flex-row lg:items-end lg:justify-between">
<div className="space-y-1">
<h3 className="text-lg font-semibold text-foreground">{title}</h3>
<p className="text-sm text-muted-foreground">{description}</p>
@@ -377,40 +411,51 @@ function SectionHeader({
);
}
function StepStateBadge({
complete,
active,
showValidation,
function EmptyPanel({
icon: Icon,
title,
description,
action,
}: {
complete: boolean;
active: boolean;
showValidation: boolean;
icon: typeof Grid2x2;
title: string;
description: string;
action?: ReactNode;
}) {
if (complete) {
return <Badge variant="success">Complete</Badge>;
}
if (showValidation) {
return <Badge variant="warning">Needs Attention</Badge>;
}
if (active) {
return <Badge variant="outline">Current</Badge>;
}
return <Badge variant="outline">Incomplete</Badge>;
return (
<div className="flex flex-col items-center gap-3 rounded-surface border border-dashed border-border/70 bg-surface-2/40 px-6 py-10 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-pill border border-border/70 bg-surface-2 text-muted-foreground">
<Icon className="h-5 w-5" />
</div>
<div className="space-y-1">
<p className="text-sm font-semibold text-foreground">{title}</p>
<p className="max-w-sm text-sm text-muted-foreground">{description}</p>
</div>
{action}
</div>
);
}
function SubmitButton() {
function SubmitButton({ onSelect }: { onSelect: () => void }) {
const { pending } = useFormStatus();
return (
<Button type="submit" disabled={pending}>
<Button type="submit" onClick={onSelect} disabled={pending}>
{pending ? "Saving..." : "Save Project"}
</Button>
);
}
function DraftButton({ onSelect }: { onSelect: () => void }) {
const { pending } = useFormStatus();
return (
<Button type="submit" onClick={onSelect} variant="outline" disabled={pending}>
Save as draft
</Button>
);
}
function ViewModeCard({
active,
label,
@@ -506,27 +551,6 @@ export function PortfolioProjectForm({
const initialProjectState = createInitialState(project, availableCategories);
const initialSections = createInitialSections(project);
const initialAssets = createInitialAssets(project);
const initialProgress = getPortfolioWizardProgress({
basics: initialProjectState,
content: initialProjectState,
sections: initialSections.map((section) => ({
type: section.type,
titleAr: section.titleAr,
titleEn: section.titleEn,
titleDe: section.titleDe,
bodyAr: section.bodyAr,
bodyEn: section.bodyEn,
bodyDe: section.bodyDe,
linkUrl: section.linkUrl,
mediaAssetId: section.media.assetId,
})),
assets: initialAssets.map((asset) => ({
mediaAssetId: asset.media.assetId,
altAr: asset.altAr,
altEn: asset.altEn,
altDe: asset.altDe,
})),
});
const [projectState, setProjectState] = useState<ProjectFormState>(initialProjectState);
const [coverMedia, setCoverMedia] = useState<MediaFieldState>(
createMediaFieldState({
@@ -538,12 +562,16 @@ export function PortfolioProjectForm({
);
const [sections, setSections] = useState<SectionFormValue[]>(initialSections);
const [assets, setAssets] = useState<AssetFormValue[]>(initialAssets);
const [currentStep, setCurrentStep] = useState<PortfolioWizardStep>(
getFirstIncompleteWizardStep(initialProgress) ?? "basics",
);
const [showValidation, setShowValidation] = useState(false);
const [activeStep, setActiveStep] = useState<PortfolioWizardStep>("basics");
const sectionsInputRef = useRef<HTMLInputElement | null>(null);
const assetsInputRef = useRef<HTMLInputElement | null>(null);
const intentRef = useRef<HTMLInputElement | null>(null);
const setIntent = (value: "save" | "draft") => {
if (intentRef.current) {
intentRef.current.value = value;
}
};
const sectionsPayload = JSON.stringify(sections.map((section, index) => ({ ...section, sortOrder: index })));
const assetsPayload = JSON.stringify(assets.map((asset, index) => ({ ...asset, sortOrder: index })));
@@ -569,15 +597,12 @@ export function PortfolioProjectForm({
})),
});
const firstIncompleteStep = getFirstIncompleteWizardStep(progress);
const currentStepIndex = wizardSteps.findIndex((step) => step.key === currentStep);
const selectedCategory =
availableCategories.find((category) => category.id === projectState.categoryId) ?? null;
const projectLabel =
projectState.titleDe.trim() ||
projectState.titleEn.trim() ||
projectState.titleAr.trim() ||
projectState.slug.trim() ||
"Untitled Project";
const activeStepIndex = WIZARD_STEPS.findIndex((step) => step.key === activeStep);
const isLastStep = activeStepIndex === WIZARD_STEPS.length - 1;
const goToStep = (index: number) => {
const target = WIZARD_STEPS[Math.min(Math.max(index, 0), WIZARD_STEPS.length - 1)];
setActiveStep(target.key);
};
useEffect(() => {
sectionsInputRef.current?.dispatchEvent(new Event("input", { bubbles: true }));
@@ -591,7 +616,7 @@ export function PortfolioProjectForm({
if (!project && availableCategories.length === 0) {
return (
<AppCard level={3} layer="single" padding="lg" className="rounded-surface">
<AppCard level={3} padding="lg" className="rounded-surface">
<div className="space-y-4">
<div className="space-y-2">
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">Portfolio Setup</p>
@@ -619,13 +644,17 @@ export function PortfolioProjectForm({
action={action}
className="space-y-8"
onSubmit={(event) => {
// A draft save skips the completeness gate entirely.
if (intentRef.current?.value === "draft") {
return;
}
if (!firstIncompleteStep) {
return;
}
event.preventDefault();
setShowValidation(true);
setCurrentStep(firstIncompleteStep);
}}
>
<input type="hidden" name="id" value={project?.id ?? ""} />
@@ -634,113 +663,19 @@ export function PortfolioProjectForm({
<input type="hidden" name="viewMode" value={projectState.viewMode} />
<input ref={sectionsInputRef} type="hidden" name="sections" value={sectionsPayload} />
<input ref={assetsInputRef} type="hidden" name="assets" value={assetsPayload} />
<input ref={intentRef} type="hidden" name="intent" defaultValue="save" />
<AppCard level={3} layer="single" padding="lg" className="space-y-6">
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={projectState.isPublished ? "success" : "warning"}>
{projectState.isPublished ? "Published" : "Draft"}
</Badge>
{projectState.isFeatured ? <Badge variant="outline">Featured</Badge> : null}
{!selectedCategory?.isActive ? <Badge variant="warning">Inactive Category</Badge> : null}
</div>
<div>
<h2 className="text-2xl font-semibold text-foreground">{projectLabel}</h2>
<p className="mt-1 text-sm text-muted-foreground">
{selectedCategory ? `${selectedCategory.name.de || selectedCategory.name.en || selectedCategory.name.ar}` : "No category selected"}
</p>
</div>
</div>
<WizardStepper activeKey={activeStep} progress={progress} onSelect={setActiveStep} />
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<StatsCard
title="Step"
value={wizardSteps[currentStepIndex]?.label ?? "Basics"}
icon={Layers3}
/>
<StatsCard title="Sections" value={String(sections.length)} icon={Files} />
<StatsCard title="Assets" value={String(assets.length)} icon={ImagePlus} />
<StatsCard
title="Progress"
value={`${progress.filter((step) => step.complete).length}/${progress.length}`}
icon={CheckCircle2}
/>
</div>
</div>
{showValidation && firstIncompleteStep ? (
<div className="rounded-nested border border-status-warning/30 bg-status-warning-soft px-4 py-3">
<div className="flex items-start gap-3">
<CircleAlert className="mt-0.5 h-4 w-4 text-status-warning" />
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">Complete the highlighted step before saving.</p>
<p className="text-sm text-muted-foreground">
The wizard moved to the first incomplete step so the missing fields are easier to find.
</p>
</div>
</div>
</div>
) : null}
</div>
</AppCard>
<Tabs value={currentStep} onValueChange={(value) => setCurrentStep(value as PortfolioWizardStep)}>
<div className="grid gap-6 xl:grid-cols-[300px_minmax(0,1fr)] xl:items-start">
<AppCard level={2} layer="single" padding="sm" className="xl:sticky xl:top-4">
<TabsList className="grid h-auto w-full gap-3 rounded-none bg-transparent p-0">
{wizardSteps.map((step, index) => {
const stepProgress = progress.find((entry) => entry.key === step.key);
const isActive = currentStep === step.key;
return (
<TabsTrigger
key={step.key}
value={step.key}
className={cn(
"w-full rounded-nested border border-border/80 bg-background p-0 text-left hover:bg-accent/30",
isActive && "border-input bg-accent/20 text-foreground shadow-xs",
)}
>
<div className="flex w-full flex-col gap-3 p-4">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<span className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-border/80 bg-surface-2 text-sm font-semibold text-foreground">
{index + 1}
</span>
<div>
<p className="text-sm font-semibold text-foreground">{step.label}</p>
</div>
</div>
{stepProgress?.complete ? (
<CheckCircle2 className="h-4 w-4 text-status-success" />
) : (
<CircleDashed className="h-4 w-4 text-muted-foreground" />
)}
</div>
<StepStateBadge
complete={Boolean(stepProgress?.complete)}
active={isActive}
showValidation={showValidation && !stepProgress?.complete}
/>
</div>
</TabsTrigger>
);
})}
</TabsList>
</AppCard>
<div className="space-y-6">
<TabsContent value="basics" className="mt-0">
<AppCard level={3} layer="single" padding="lg">
<section className="space-y-6">
<div className="mx-auto w-full max-w-3xl">
<div className={cn(activeStep !== "basics" && "hidden")}>
<section className="space-y-5">
<SectionHeader
title="Basic Information"
description="Choose the category and the stable project metadata first."
/>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="categoryId" className="sr-only">Category</Label>
<div className="relative">
@@ -891,12 +826,10 @@ export function PortfolioProjectForm({
/>
</div>
</section>
</AppCard>
</TabsContent>
</div>
<TabsContent value="content" className="mt-0">
<AppCard level={3} layer="single" padding="lg">
<section className="space-y-6">
<div className={cn(activeStep !== "content" && "hidden")}>
<section className="space-y-5">
<SectionHeader
title="Localized Content"
description="Finish the user-facing copy before building sections and media."
@@ -942,15 +875,13 @@ export function PortfolioProjectForm({
multiline
/>
</section>
</AppCard>
</TabsContent>
</div>
<TabsContent value="sections" className="mt-0">
<AppCard level={3} layer="single" padding="lg">
<section className="space-y-6">
<div className={cn(activeStep !== "sections" && "hidden")}>
<section className="space-y-5">
<SectionHeader
title="Sections"
description="Each section keeps one clear role in the project story."
description="Optional — baue die Projektgeschichte aus einzelnen Abschnitten."
action={(
<Button
type="button"
@@ -963,46 +894,57 @@ export function PortfolioProjectForm({
)}
/>
<div className="space-y-4">
{sections.map((section, index) => (
<div
key={section.id ?? `section-${index}`}
className="space-y-4 rounded-surface border border-border/70 bg-background p-4"
>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Badge variant={isPortfolioSectionReady({
type: section.type,
titleAr: section.titleAr,
titleEn: section.titleEn,
titleDe: section.titleDe,
bodyAr: section.bodyAr,
bodyEn: section.bodyEn,
bodyDe: section.bodyDe,
linkUrl: section.linkUrl,
mediaAssetId: section.media.assetId,
}) ? "success" : "outline"}>
{isPortfolioSectionReady({
type: section.type,
titleAr: section.titleAr,
titleEn: section.titleEn,
titleDe: section.titleDe,
bodyAr: section.bodyAr,
bodyEn: section.bodyEn,
bodyDe: section.bodyDe,
linkUrl: section.linkUrl,
mediaAssetId: section.media.assetId,
})
? "Ready"
: "Open"}
</Badge>
<p className="text-sm font-medium text-foreground">{`Section ${index + 1}`}</p>
</div>
<div className="flex gap-2">
{sections.length === 0 ? (
<EmptyPanel
icon={Layers3}
title="Noch keine Abschnitte"
description="Abschnitte sind optional. Rich-Text, Galerie, Stats, Deliverables oder Links frei kombinierbar."
action={(
<Button
type="button"
variant="outline"
onClick={() => setSections((current) => [...current, createEmptySection(current.length)])}
>
<Plus className="h-4 w-4" />
Add Section
</Button>
)}
/>
) : (
<Accordion type="multiple" className="space-y-3">
{sections.map((section, index) => {
const ready = isPortfolioSectionReady({
type: section.type,
titleAr: section.titleAr,
titleEn: section.titleEn,
titleDe: section.titleDe,
bodyAr: section.bodyAr,
bodyEn: section.bodyEn,
bodyDe: section.bodyDe,
linkUrl: section.linkUrl,
mediaAssetId: section.media.assetId,
});
return (
<AccordionItem
key={section.id ?? `section-${index}`}
value={section.id ?? `section-${index}`}
className="border-border/70 bg-surface-2"
>
<AccordionTrigger className="hover:no-underline">
<span className="flex flex-wrap items-center gap-2.5">
<Badge variant={ready ? "success" : "outline"}>{ready ? "Ready" : "Open"}</Badge>
<span className="text-sm font-medium text-foreground">{`Section ${index + 1}`}</span>
<span className="text-xs text-muted-foreground">{sectionTypeLabels[section.type]}</span>
</span>
</AccordionTrigger>
<AccordionContent>
<div className="space-y-4">
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
size="icon"
onClick={() => setSections((current) => moveArrayItem(current, index, index - 1))}
disabled={index === 0}
>
@@ -1011,6 +953,7 @@ export function PortfolioProjectForm({
<Button
type="button"
variant="outline"
size="icon"
onClick={() => setSections((current) => moveArrayItem(current, index, index + 1))}
disabled={index === sections.length - 1}
>
@@ -1019,19 +962,17 @@ export function PortfolioProjectForm({
<Button
type="button"
variant="ghost"
size="icon"
className="text-destructive"
onClick={() =>
sections.length > 1 &&
setSections((current) =>
current.filter((_, currentIndex) => currentIndex !== index),
)
}
disabled={sections.length === 1}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
@@ -1131,21 +1072,21 @@ export function PortfolioProjectForm({
/>
) : null}
</div>
))}
</div>
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
)}
</section>
</AppCard>
</TabsContent>
</div>
<TabsContent value="assets" className="mt-0">
<AppCard level={3} layer="single" padding="lg">
<div className="space-y-8">
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_280px]">
<div className={cn(activeStep !== "assets" && "hidden")}>
<div className="space-y-6">
<section className="space-y-6">
<section className="space-y-4">
<SectionHeader
title="Cover Media"
description="Choose the primary cover that represents the project in listings."
description="Wähle das Titelbild, das das Projekt in Listen repräsentiert."
/>
<MediaFieldPicker
@@ -1162,12 +1103,10 @@ export function PortfolioProjectForm({
/>
</section>
<Separator />
<section className="space-y-6">
<section className="space-y-4">
<SectionHeader
title="Assets"
description="Assets stay ordered and ready for localized alt text."
description="Optional — Galeriebilder mit lokalisiertem Alt-Text."
action={(
<Button
type="button"
@@ -1180,36 +1119,51 @@ export function PortfolioProjectForm({
)}
/>
<div className="grid gap-4 md:grid-cols-2">
{assets.map((asset, index) => (
<div
key={asset.id ?? `asset-${index}`}
className="space-y-4 rounded-surface border border-border/70 bg-background p-4"
>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Badge variant={isPortfolioAssetReady({
mediaAssetId: asset.media.assetId,
altAr: asset.altAr,
altEn: asset.altEn,
altDe: asset.altDe,
}) ? "success" : "outline"}>
{isPortfolioAssetReady({
mediaAssetId: asset.media.assetId,
altAr: asset.altAr,
altEn: asset.altEn,
altDe: asset.altDe,
})
? "Ready"
: "Open"}
</Badge>
<p className="text-sm font-medium text-foreground">{`Asset ${index + 1}`}</p>
</div>
<div className="flex gap-2">
{assets.length === 0 ? (
<EmptyPanel
icon={ImagePlus}
title="Noch keine Assets"
description="Assets sind optional. Füge Galeriebilder hinzu jedes braucht ein Medium und lokalisierten Alt-Text."
action={(
<Button
type="button"
variant="outline"
onClick={() => setAssets((current) => [...current, createEmptyAsset(current.length)])}
>
<Plus className="h-4 w-4" />
Add Asset
</Button>
)}
/>
) : (
<Accordion type="multiple" className="space-y-3">
{assets.map((asset, index) => {
const ready = isPortfolioAssetReady({
mediaAssetId: asset.media.assetId,
altAr: asset.altAr,
altEn: asset.altEn,
altDe: asset.altDe,
});
return (
<AccordionItem
key={asset.id ?? `asset-${index}`}
value={asset.id ?? `asset-${index}`}
className="border-border/70 bg-surface-2"
>
<AccordionTrigger className="hover:no-underline">
<span className="flex flex-wrap items-center gap-2.5">
<Badge variant={ready ? "success" : "outline"}>{ready ? "Ready" : "Open"}</Badge>
<span className="text-sm font-medium text-foreground">{`Asset ${index + 1}`}</span>
</span>
</AccordionTrigger>
<AccordionContent>
<div className="space-y-4">
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
size="icon"
onClick={() => setAssets((current) => moveArrayItem(current, index, index - 1))}
disabled={index === 0}
>
@@ -1218,6 +1172,7 @@ export function PortfolioProjectForm({
<Button
type="button"
variant="outline"
size="icon"
onClick={() => setAssets((current) => moveArrayItem(current, index, index + 1))}
disabled={index === assets.length - 1}
>
@@ -1226,19 +1181,17 @@ export function PortfolioProjectForm({
<Button
type="button"
variant="ghost"
size="icon"
className="text-destructive"
onClick={() =>
assets.length > 1 &&
setAssets((current) =>
current.filter((_, currentIndex) => currentIndex !== index),
)
}
disabled={assets.length === 1}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<MediaFieldPicker
title="Asset Image"
@@ -1273,91 +1226,56 @@ export function PortfolioProjectForm({
}
/>
</div>
))}
</div>
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
)}
</section>
</div>
</div>
</div>
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4 border-t border-border/60 pt-6">
{showValidation && firstIncompleteStep ? (
<div className="rounded-nested border border-status-warning/30 bg-status-warning-soft px-4 py-3">
<div className="flex items-start gap-3">
<CircleAlert className="mt-0.5 h-4 w-4 text-status-warning" />
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">Some required fields are still missing.</p>
<p className="text-sm text-muted-foreground">
Fill the Basics and Localized Content fields, then save.
</p>
</div>
</div>
</div>
) : null}
<div className="flex items-center justify-between gap-3">
<Button
type="button"
variant="outline"
onClick={() => goToStep(activeStepIndex - 1)}
disabled={activeStepIndex === 0}
>
<ArrowLeft className="h-4 w-4" />
Zurück
</Button>
<div className="space-y-4 lg:sticky lg:top-4 lg:self-start">
<AppCard level={2} layer="single" padding="sm" className="space-y-4">
<div className="flex items-center gap-2">
<ImagePlus className="h-4 w-4 text-brand-primary" />
<p className="text-sm font-semibold text-foreground">Assets Overview</p>
</div>
<div className="grid gap-3">
<StatsCard title="Cover" value={coverMedia.assetId ? "Selected" : "Missing"} icon={ImagePlus} />
<StatsCard title="Assets" value={String(assets.length)} icon={Files} />
<StatsCard
title="Ready"
value={String(
assets.filter((asset) =>
isPortfolioAssetReady({
mediaAssetId: asset.media.assetId,
altAr: asset.altAr,
altEn: asset.altEn,
altDe: asset.altDe,
}),
).length,
)}
icon={CheckCircle2}
/>
</div>
<div className="rounded-nested border border-dashed border-border/70 bg-background px-4 py-3 text-sm text-muted-foreground">
Start with the cover, then add gallery assets. Each asset needs media plus localized alt text.
</div>
</AppCard>
</div>
</div>
</div>
</AppCard>
</TabsContent>
</div>
</div>
</Tabs>
<AppCard level={3} layer="single" padding="lg">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
onClick={() => setCurrentStep(wizardSteps[Math.max(currentStepIndex - 1, 0)]!.key)}
disabled={currentStepIndex === 0}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<Button
type="button"
variant="outline"
onClick={() =>
setCurrentStep(
wizardSteps[Math.min(currentStepIndex + 1, wizardSteps.length - 1)]!.key,
)
}
disabled={currentStepIndex === wizardSteps.length - 1}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<div className="flex flex-wrap items-center gap-3">
{firstIncompleteStep ? (
<p className="text-sm text-muted-foreground">
Save becomes available after all steps are complete.
</p>
<DraftButton onSelect={() => setIntent("draft")} />
{isLastStep ? (
<SubmitButton onSelect={() => setIntent("save")} />
) : (
<p className="text-sm text-muted-foreground">
All steps are ready. You can save now.
</p>
<Button type="button" onClick={() => goToStep(activeStepIndex + 1)}>
Weiter
<ArrowRight className="h-4 w-4" />
</Button>
)}
<SubmitButton />
</div>
</div>
</AppCard>
</div>
</form>
);
}
+142 -45
View File
@@ -1,13 +1,22 @@
import { ExternalLink, FolderKanban, Plus, Tags, CheckCircle2 } from "lucide-react";
/* eslint-disable @next/next/no-img-element */
import { CheckCircle2, ExternalLink, FolderKanban, ImageOff, Plus, Star, Tags } from "lucide-react";
import Link from "next/link";
import { StatsCard } from "@/components/dashboard/stats-card";
import { MotionFade } from "@/components/motion-fade";
import { PortfolioProjectActions } from "@/components/admin/portfolio-project-actions";
import { AppCard } from "@/components/ui/app-card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { CardContent } from "@/components/ui/card";
import { Card } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { getAdminAppPath } from "@/lib/admin-routing";
import { getSiteSettings } from "@/lib/app-config";
import { getLocalizedPath } from "@/lib/locale";
@@ -24,17 +33,34 @@ const copy = {
all: "Alle",
newProject: "Neues Projekt",
newCategory: "Neues Kategorie",
openProject: "Ansehen",
view: "Auf der Website ansehen",
untitled: "Unbenanntes Projekt",
empty: "Noch keine Projekte vorhanden.",
emptyFiltered: "Keine Projekte in dieser Auswahl.",
emptyHint: "Lege dein erstes Projekt an, um es hier zu verwalten.",
colProject: "Projekt",
colCategory: "Kategorie",
colYear: "Jahr",
colStatus: "Status",
colMode: "Layout",
colActions: "Aktionen",
published: "Published",
draft: "Draft",
featured: "Featured",
};
function categoryLabel(name: PortfolioCategoryView["name"]) {
return name.de || name.en || name.ar;
}
export async function PortfolioProjectsOverview({
categories,
projects,
selectedCategory,
}: PortfolioProjectsOverviewProps) {
const siteSettings = await getSiteSettings();
const publishedCount = projects.filter((project) => project.isPublished).length;
const isFiltered = selectedCategory !== "";
return (
<div className="space-y-6">
@@ -42,11 +68,7 @@ export async function PortfolioProjectsOverview({
<div className="grid gap-4 md:grid-cols-3 xl:min-w-[620px]">
<StatsCard title="Projects" value={String(projects.length)} icon={FolderKanban} />
<StatsCard title="Categories" value={String(categories.length)} icon={Tags} />
<StatsCard
title="Published"
value={String(projects.filter((project) => project.isPublished).length)}
icon={CheckCircle2}
/>
<StatsCard title="Published" value={String(publishedCount)} icon={CheckCircle2} />
</div>
<div className="flex flex-wrap gap-2">
@@ -76,59 +98,134 @@ export async function PortfolioProjectsOverview({
variant={selectedCategory === category.id ? "default" : "outline"}
>
<Link href={`${getAdminAppPath("/portfolio")}?category=${category.id}`}>
{category.name.de || category.name.en || category.name.ar}
{categoryLabel(category.name)}
</Link>
</Button>
))}
</div>
<div className="grid gap-4 xl:grid-cols-2">
{projects.map((project, index) => (
<MotionFade key={project.id} delay={0.06 + index * 0.03}>
<AppCard interactive layer="single" className="h-full">
<CardContent className="flex flex-col gap-4 p-5 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<p className="text-xl font-semibold text-foreground">
{getLocalizedValue(project.title, "de") || copy.untitled}
{projects.length === 0 ? (
<AppCard level={3} padding="lg">
<div className="flex flex-col items-center gap-4 py-12 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-pill border border-border/70 bg-surface-2 text-muted-foreground">
<FolderKanban className="h-6 w-6" />
</div>
<div className="space-y-1">
<p className="text-base font-semibold text-foreground">
{isFiltered ? copy.emptyFiltered : copy.empty}
</p>
<Badge variant={project.isPublished ? "success" : "warning"}>
{project.isPublished ? "Published" : "Draft"}
</Badge>
<Badge variant="outline">{project.viewMode}</Badge>
<p className="text-sm text-muted-foreground">{copy.emptyHint}</p>
</div>
<div className="flex flex-wrap gap-2 text-sm text-muted-foreground">
<span>{project.category.name.de || project.category.name.en || project.category.name.ar}</span>
<span>{project.projectYear}</span>
</div>
</div>
<div className="flex flex-wrap gap-3">
{isFiltered ? (
<Button asChild variant="outline">
<Link href={getAdminAppPath("/portfolio")}>{copy.all}</Link>
</Button>
) : (
<Button asChild>
<Link href={getAdminAppPath("/portfolio/projects/new")}>
<Plus className="h-4 w-4" />
{copy.newProject}
</Link>
</Button>
)}
</div>
</AppCard>
) : (
<Card className="overflow-hidden">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-[72px]">
<span className="sr-only">Cover</span>
</TableHead>
<TableHead>{copy.colProject}</TableHead>
<TableHead className="hidden md:table-cell">{copy.colCategory}</TableHead>
<TableHead className="hidden w-[80px] sm:table-cell">{copy.colYear}</TableHead>
<TableHead className="w-[130px]">{copy.colStatus}</TableHead>
<TableHead className="hidden w-[120px] lg:table-cell">{copy.colMode}</TableHead>
<TableHead className="w-[96px] text-right">{copy.colActions}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{projects.map((project) => {
const title = getLocalizedValue(project.title, "de") || copy.untitled;
return (
<TableRow key={project.id}>
<TableCell>
<div className="flex h-11 w-14 items-center justify-center overflow-hidden rounded-nested border border-border/70 bg-surface-2 text-muted-foreground">
{project.coverImagePath ? (
<img
src={project.coverImagePath}
alt={title}
className="h-full w-full object-cover"
/>
) : (
<ImageOff className="h-4 w-4" />
)}
</div>
</TableCell>
<TableCell>
<div className="flex min-w-0 flex-col">
<span className="inline-flex items-center gap-1.5 font-medium text-foreground">
<span className="truncate">{title}</span>
{project.isFeatured ? (
<Star
className="h-3.5 w-3.5 shrink-0 text-brand-primary"
fill="currentColor"
aria-label={copy.featured}
/>
) : null}
</span>
<span className="truncate text-xs text-muted-foreground">/{project.slug}</span>
</div>
</TableCell>
<TableCell className="hidden text-sm text-muted-foreground md:table-cell">
{categoryLabel(project.category.name)}
</TableCell>
<TableCell className="hidden text-sm text-muted-foreground sm:table-cell">
{project.projectYear}
</TableCell>
<TableCell>
<Badge variant={project.isPublished ? "success" : "warning"}>
{project.isPublished ? copy.published : copy.draft}
</Badge>
</TableCell>
<TableCell className="hidden lg:table-cell">
<Badge variant="outline">{project.viewMode}</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1.5">
<Button asChild variant="ghost" size="icon" title={copy.view}>
<Link
href={getLocalizedPath("de", `/portfolio/${project.slug}`, siteSettings.defaultLocale)}
href={getLocalizedPath(
"de",
`/portfolio/${project.slug}`,
siteSettings.defaultLocale,
)}
target="_blank"
rel="noreferrer"
>
<ExternalLink className="h-4 w-4" />
{copy.openProject}
<span className="sr-only">{copy.view}</span>
</Link>
</Button>
<PortfolioProjectActions projectId={project.id} />
</div>
</CardContent>
</AppCard>
</MotionFade>
))}
{projects.length === 0 ? (
<AppCard layer="single" className="xl:col-span-2">
<CardContent className="p-6 text-sm text-muted-foreground">
{copy.empty}
</CardContent>
</AppCard>
) : null}
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</Card>
)}
</div>
);
}
+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>
);
}
+9 -9
View File
@@ -270,7 +270,7 @@ function SiteSettingsMediaRow({
return (
<>
<AppCard level={2} layer="single" padding="sm">
<AppCard level={2} padding="sm">
<div className="grid gap-4 lg:grid-cols-[180px_minmax(0,1fr)]">
<div className="space-y-2">
<p className="text-sm font-semibold text-foreground">{title}</p>
@@ -393,7 +393,7 @@ function LocalizedFieldsSection({
<div className="grid gap-5 lg:grid-cols-3">
{localeFields.map((locale) => (
<AppCard key={locale.key} level={2} layer="single" padding="sm" className="space-y-4">
<AppCard key={locale.key} level={2} padding="sm" contentClassName="space-y-4">
<p className="text-sm font-semibold text-foreground">{locale.label}</p>
<div className="space-y-2">
@@ -558,7 +558,7 @@ export function SiteSettingsForm({
</p>
</div>
<AppCard level={2} layer="single" padding="sm" className="space-y-4">
<AppCard level={2} padding="sm" contentClassName="space-y-4">
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_220px]">
<div className="space-y-2">
<Label htmlFor="primaryColor" className="sr-only">Primary Color</Label>
@@ -679,7 +679,7 @@ export function SiteSettingsForm({
</p>
</div>
<AppCard level={2} layer="single" padding="sm" className="space-y-4">
<AppCard level={2} padding="sm" contentClassName="space-y-4">
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<div className="space-y-2">
<Label htmlFor="defaultLocaleTrigger" className="sr-only">Default Locale</Label>
@@ -729,7 +729,7 @@ export function SiteSettingsForm({
<section className="space-y-4">
{mode === "brand" ? (
<>
<AppCard level={2} layer="single" padding="sm" className="space-y-2">
<AppCard level={2} padding="sm" contentClassName="space-y-2">
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Search Result</p>
<div className="rounded-nested border border-border/70 bg-background p-3">
<div className="flex items-center gap-3">
@@ -750,7 +750,7 @@ export function SiteSettingsForm({
</div>
</AppCard>
<AppCard level={2} layer="single" padding="sm" className="space-y-2">
<AppCard level={2} padding="sm" contentClassName="space-y-2">
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Brand Assets</p>
<div className="grid gap-3 sm:grid-cols-2">
<AppCard level={1} layer="single" padding="sm">
@@ -773,7 +773,7 @@ export function SiteSettingsForm({
</div>
</AppCard>
<AppCard level={2} layer="single" padding="sm" className="space-y-2">
<AppCard level={2} padding="sm" contentClassName="space-y-2">
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Social Preview</p>
<div className="overflow-hidden rounded-nested border border-border/70 bg-background">
<div className="h-1.5" style={{ backgroundColor: settings.brand.primaryColor }} />
@@ -797,7 +797,7 @@ export function SiteSettingsForm({
</>
) : (
<>
<AppCard level={2} layer="single" padding="sm" className="space-y-2">
<AppCard level={2} padding="sm" contentClassName="space-y-2">
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Visitor Routing</p>
<div className="rounded-nested border border-border/70 bg-background p-3">
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
@@ -810,7 +810,7 @@ export function SiteSettingsForm({
</div>
</AppCard>
<AppCard level={2} layer="single" padding="sm" className="space-y-2">
<AppCard level={2} padding="sm" contentClassName="space-y-2">
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Locale Summary</p>
<div className="rounded-nested border border-border/70 bg-background">
<Table>
+2 -2
View File
@@ -25,7 +25,7 @@ export function SMTPSettingsForm({
return (
<form id="smtp-settings-form" action={action} className="space-y-6">
<div className="grid gap-6 xl:grid-cols-2">
<AppCard layer="single">
<AppCard>
<CardHeader>
<CardTitle>SMTP Connection</CardTitle>
<CardDescription>Server und Login.</CardDescription>
@@ -109,7 +109,7 @@ export function SMTPSettingsForm({
</CardContent>
</AppCard>
<AppCard layer="single">
<AppCard>
<CardHeader>
<CardTitle>Sender And Recipients</CardTitle>
<CardDescription>Absender und Ziele.</CardDescription>
+3 -3
View File
@@ -17,7 +17,7 @@ export function WorkspaceHero({
aside?: ReactNode;
}) {
return (
<AppCard level={3} layer="single">
<AppCard level={3}>
<CardContent className="flex flex-col gap-4 p-6 lg:flex-row lg:items-end lg:justify-between lg:p-8">
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
@@ -40,7 +40,7 @@ export function WorkspaceSidebarPanel({
children: ReactNode;
}) {
return (
<AppCard level={2} layer="single">
<AppCard level={2}>
<CardHeader className="pb-4">
<CardTitle className="text-base">{title}</CardTitle>
</CardHeader>
@@ -100,7 +100,7 @@ export function WorkspaceLocaleCard({
children: ReactNode;
}) {
return (
<AppCard level={2} layer="single">
<AppCard level={2}>
<CardHeader className="pb-4">
<CardTitle className="text-lg">{title}</CardTitle>
<CardDescription>{hint}</CardDescription>
+4 -5
View File
@@ -1,5 +1,4 @@
import { StackedMarqueeSection, type MarqueeRow } from "@/components/layout/stacked-marquee-section";
import { AppCard } from "@/components/ui/app-card";
import { SectionHeading } from "./section-heading";
import type { MarqueeSectionCopy } from "./types";
@@ -10,18 +9,18 @@ type MarqueeSectionProps = {
export function MarqueeSection({ copy, rows }: MarqueeSectionProps) {
return (
<section className="space-y-8 overflow-hidden">
<section className="space-y-8">
<SectionHeading
eyebrow={copy.eyebrow}
title={copy.title}
description={copy.description}
/>
<AppCard className="overflow-hidden">
<div className="rounded-nested border border-border/70 bg-background py-6">
{/* Full-bleed band: breaks out of the centered container to span the
entire site width instead of sitting inside a card. */}
<div className="relative left-1/2 w-screen -translate-x-1/2 overflow-hidden border-y border-border/60 bg-background/40 py-8">
<StackedMarqueeSection rows={rows} className="py-0" />
</div>
</AppCard>
</section>
);
}
@@ -0,0 +1,25 @@
"use client";
import { ShieldCheck } from "lucide-react";
import { useLocale } from "next-intl";
import { buildAdminUrl } from "@/lib/admin-routing";
import { cn } from "@/lib/utils";
export function FloatingAdminButton() {
const locale = useLocale();
const isRtl = locale === "ar";
return (
<a
href={buildAdminUrl("/")}
aria-label="Admin"
className={cn(
"fixed bottom-5 z-50 flex h-11 w-11 items-center justify-center rounded-full border border-border/70 bg-background/90 text-foreground/70 shadow-lg backdrop-blur-chrome transition-all hover:scale-105 hover:bg-accent hover:text-foreground hover:shadow-xl",
isRtl ? "left-5" : "right-5",
)}
>
<ShieldCheck className="h-5 w-5" />
</a>
);
}
+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 = {
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 (
<div className="hero-sheet pointer-events-none absolute inset-0 overflow-hidden">
<div className="hero-sheet-base" />
<div className="hero-sheet-veil" />
<div className="hero-sheet-fade" />
<div
aria-hidden="true"
className={cn(
"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>
);
}
+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 { ArrowDown } from "lucide-react";
import { motion } from "framer-motion";
import { Container } from "@/components/layout/container";
import { HeroMotionBackdrop } from "@/components/layout/hero-motion-backdrop";
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 HeroLineAlign = "start" | "center" | "end";
@@ -128,6 +109,14 @@ export function HeroShell({
{children}
</div>
</Container>
{variant === "home" ? (
<HeroScrollLink
href="#home-content"
label="Scroll to content"
className="hero-scroll-anchor hidden sm:inline-flex"
/>
) : null}
</section>
);
}
@@ -136,27 +125,14 @@ export function HeroContentMotion({
children,
className,
}: HeroContentMotionProps) {
return (
<motion.div
className={className}
initial="hidden"
animate="visible"
variants={heroContainerVariants}
>
{children}
</motion.div>
);
return <div className={cn("hero-content", className)}>{children}</div>;
}
export function HeroMotionItem({
children,
className,
}: HeroContentMotionProps) {
return (
<motion.div className={className} variants={heroItemVariants}>
{children}
</motion.div>
);
return <div className={cn("hero-rise", className)}>{children}</div>;
}
export function HeroTitle({
@@ -167,7 +143,7 @@ export function HeroTitle({
}: HeroTitleProps) {
const isArabic = locale === "ar";
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"
? 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]"
@@ -177,7 +153,7 @@ export function HeroTitle({
);
return (
<motion.h1 className={titleClassName} variants={heroItemVariants}>
<h1 className={titleClassName}>
{lines.map((line, index) => (
<span
key={`${index}-${line.text}`}
@@ -202,7 +178,7 @@ export function HeroTitle({
)}
</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) => (
<CategoryLink
key={category.id}
href={getLocalizedPath(locale, `/portfolio/category/${category.slug}`, defaultLocale)}
href={getLocalizedPath(locale, `/portfolio/${category.slug}`, defaultLocale)}
label={getLocalizedValue(category.name, locale)}
active={activeCategorySlug === category.slug}
/>
+321 -315
View File
@@ -5,10 +5,10 @@ import {
Tag,
UserRound,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { MotionFade } from "@/components/motion-fade";
import { PortfolioCover } from "@/components/site/portfolio-cover";
import { AppCard } from "@/components/ui/app-card";
import { Button } from "@/components/ui/button";
import { CardContent } from "@/components/ui/card";
@@ -16,7 +16,6 @@ import { getLocalizedPath, type AppLocale } from "@/lib/locale";
import {
getLocalizedValue,
resolvePortfolioProjectViewMode,
type PortfolioAssetView,
type PortfolioProjectView,
type PortfolioSectionView,
} from "@/lib/portfolio";
@@ -28,27 +27,76 @@ type PortfolioProjectDetailProps = {
t: (key: "back" | "preview" | "openLink" | "gallery" | "download") => string;
};
function PortfolioImage({
src,
alt,
className,
width,
height,
type ProjectImage = {
key: string;
src: string | null;
label: string;
};
/**
* All image sources for a project, in reading order: cover, gallery-section
* images, then gallery assets. `src` may be null — PortfolioCover then renders
* the branded placeholder, so layouts look intentional even without artwork.
*/
function collectImages(item: PortfolioProjectView, locale: AppLocale): ProjectImage[] {
const images: ProjectImage[] = [];
const coverTitle = getLocalizedValue(item.title, locale);
images.push({ key: "cover", src: item.coverImagePath ?? null, label: coverTitle });
for (const section of item.sections) {
if (section.type === "GALLERY") {
images.push({
key: `section-${section.id}`,
src: section.imagePath ?? null,
label: getLocalizedValue(section.title, locale),
});
}
}
for (const asset of item.assets) {
if (asset.kind === "IMAGE") {
images.push({
key: `asset-${asset.id}`,
src: asset.filePath,
label: getLocalizedValue(asset.alt, locale),
});
}
}
return images;
}
/** Text-bearing sections (everything that is not a gallery image). */
function textSections(item: PortfolioProjectView): PortfolioSectionView[] {
return item.sections.filter((section) => section.type !== "GALLERY");
}
function MediaFrame({
image,
aspect = "aspect-[16/10]",
chrome = false,
priority = false,
}: {
src: string;
alt: string;
className: string;
width: number;
height: number;
image: ProjectImage;
aspect?: string;
chrome?: boolean;
priority?: boolean;
}) {
return (
<Image
src={src}
alt={alt}
width={width}
height={height}
className={className}
/>
<figure className="overflow-hidden rounded-surface border border-border bg-surface-2 shadow-card">
{chrome ? (
<div className="flex items-center gap-2 border-b border-border bg-surface-3 px-4 py-3">
<span className="h-2.5 w-2.5 rounded-full bg-border-strong" />
<span className="h-2.5 w-2.5 rounded-full bg-border-strong" />
<span className="h-2.5 w-2.5 rounded-full bg-border-strong" />
<span className="ms-3 h-5 flex-1 rounded-md bg-surface-1" />
</div>
) : null}
<div className={`group relative ${aspect}`}>
<PortfolioCover src={image.src} title={image.label} priority={priority} />
</div>
</figure>
);
}
@@ -60,22 +108,10 @@ function ProjectMeta({
locale: AppLocale;
}) {
const metadata = [
{
icon: Tag,
label: getLocalizedValue(item.category.name, locale),
},
{
icon: CalendarDays,
label: String(item.projectYear),
},
{
icon: FolderKanban,
label: getLocalizedValue(item.serviceLabel, locale),
},
{
icon: UserRound,
label: item.clientName,
},
{ icon: Tag, label: getLocalizedValue(item.category.name, locale) },
{ icon: CalendarDays, label: String(item.projectYear) },
{ icon: FolderKanban, label: getLocalizedValue(item.serviceLabel, locale) },
{ icon: UserRound, label: item.clientName },
].filter((entry) => entry.label);
return (
@@ -84,19 +120,111 @@ function ProjectMeta({
const Icon = meta.icon;
return (
<AppCard key={`${meta.label}-${Icon.name}`} level={2}>
<CardContent className="flex items-center gap-2 p-3">
<span
key={`${meta.label}-${Icon.name}`}
className="inline-flex items-center gap-2 rounded-pill border border-border/70 bg-surface-2 px-3.5 py-1.5"
>
<Icon className="h-4 w-4 text-brand-primary" />
{meta.label}
</CardContent>
</AppCard>
</span>
);
})}
</div>
);
}
function SectionBlock({
function BackLink({
locale,
defaultLocale,
t,
}: {
locale: AppLocale;
defaultLocale: AppLocale;
t: PortfolioProjectDetailProps["t"];
}) {
return (
<Button asChild variant="ghost" className="h-auto px-0 py-0 text-sm text-muted-foreground">
<Link href={getLocalizedPath(locale, "/portfolio", defaultLocale)}>{t("back")}</Link>
</Button>
);
}
function PreviewButton({
item,
t,
full = false,
}: {
item: PortfolioProjectView;
t: PortfolioProjectDetailProps["t"];
full?: boolean;
}) {
if (!item.previewUrl) {
return null;
}
return (
<Button asChild className={full ? "w-full justify-center" : undefined}>
<Link href={item.previewUrl} target="_blank" rel="noreferrer">
{t("preview")}
<ArrowUpRight className="h-4 w-4" />
</Link>
</Button>
);
}
function ProjectIntro({ item, locale }: { item: PortfolioProjectView; locale: AppLocale }) {
return (
<div className="space-y-4">
<p className="text-overline uppercase tracking-[0.18em] text-brand-primary">
{getLocalizedValue(item.category.name, locale)} · {item.projectYear}
</p>
<h1 className="text-h1 text-foreground">{getLocalizedValue(item.title, locale)}</h1>
<p className="max-w-2xl text-body-lg text-muted-foreground">
{getLocalizedValue(item.summary, locale)}
</p>
</div>
);
}
function InfoPanel({
item,
locale,
t,
}: {
item: PortfolioProjectView;
locale: AppLocale;
t: PortfolioProjectDetailProps["t"];
}) {
const rows = [
{ label: "Client", value: item.clientName },
{ label: "Year", value: String(item.projectYear) },
{ label: "Service", value: getLocalizedValue(item.serviceLabel, locale) },
{ label: "Category", value: getLocalizedValue(item.category.name, locale) },
].filter((row) => row.value);
return (
<AppCard level={2} className="lg:sticky lg:top-24">
<CardContent className="space-y-5 p-6">
<dl className="space-y-0">
{rows.map((row, index) => (
<div
key={row.label}
className={`flex items-center justify-between gap-4 py-2.5 text-sm ${
index < rows.length - 1 ? "border-b border-dashed border-border" : ""
}`}
>
<dt className="text-muted-foreground">{row.label}</dt>
<dd className="font-semibold text-foreground">{row.value}</dd>
</div>
))}
</dl>
<PreviewButton item={item} t={t} full />
</CardContent>
</AppCard>
);
}
function TextSection({
section,
locale,
t,
@@ -108,29 +236,13 @@ function SectionBlock({
const title = getLocalizedValue(section.title, locale);
const body = getLocalizedValue(section.body, locale);
if (section.type === "GALLERY") {
return (
<div className="space-y-4">
<h3 className="text-xl font-semibold text-foreground">{title}</h3>
{section.imagePath ? (
<PortfolioImage
src={section.imagePath}
alt={title}
width={1400}
height={880}
className="h-72 w-full rounded-surface object-cover"
/>
<div className="space-y-3">
<h3 className="text-h3 text-foreground">{title}</h3>
{body ? (
<p className="whitespace-pre-line text-body leading-7 text-muted-foreground">{body}</p>
) : null}
</div>
);
}
if (section.type === "LINK") {
return (
<div className="space-y-4">
<h3 className="text-xl font-semibold text-foreground">{title}</h3>
{body ? <p className="whitespace-pre-line text-sm leading-7 text-muted-foreground">{body}</p> : null}
{section.linkUrl ? (
{section.type === "LINK" && section.linkUrl ? (
<Button asChild variant="outline">
<Link href={section.linkUrl} target="_blank" rel="noreferrer">
{t("openLink")}
@@ -140,294 +252,188 @@ function SectionBlock({
) : null}
</div>
);
}
return (
<div className="space-y-4">
<h3 className="text-xl font-semibold text-foreground">{title}</h3>
<p className="whitespace-pre-line text-sm leading-7 text-muted-foreground">{body}</p>
</div>
);
}
function AssetGallery({
assets,
locale,
t,
}: {
assets: PortfolioAssetView[];
locale: AppLocale;
t: PortfolioProjectDetailProps["t"];
}) {
if (assets.length === 0) {
return null;
}
/* ============================================================
WEB (viewMode: CASE_STUDY)
Two columns: stacked full screenshots + sticky info panel.
============================================================ */
function WebTemplate({ item, locale, defaultLocale, t }: PortfolioProjectDetailProps) {
const images = collectImages(item, locale);
const texts = textSections(item);
return (
<MotionFade delay={0.12}>
<AppCard>
<CardContent className="p-6 lg:p-8">
<div className="flex items-end justify-between gap-4">
<div>
<p className="text-sm uppercase tracking-[0.18em] text-muted-foreground">Assets</p>
<h2 className="mt-2 text-2xl font-semibold text-foreground">{t("gallery")}</h2>
</div>
<BadgeCount count={assets.length} />
</div>
<div className="mt-6 grid gap-4 md:grid-cols-2">
{assets.map((asset) => (
<div key={asset.id} className="overflow-hidden rounded-surface border border-border bg-card">
{asset.kind === "IMAGE" ? (
<PortfolioImage
src={asset.filePath}
alt={getLocalizedValue(asset.alt, locale)}
width={1200}
height={760}
className="h-72 w-full object-cover"
/>
) : (
<div className="flex min-h-72 items-center justify-center bg-muted/30 p-6 text-center">
<div className="space-y-3">
<p className="text-sm font-medium text-foreground">
{getLocalizedValue(asset.alt, locale)}
</p>
<Button asChild variant="outline">
<Link href={asset.filePath} target="_blank" rel="noreferrer">
{t("download")}
</Link>
</Button>
</div>
</div>
)}
</div>
))}
</div>
</CardContent>
</AppCard>
</MotionFade>
);
}
function BadgeCount({ count }: { count: number }) {
return (
<div className="rounded-pill border border-border/70 bg-background px-4 py-2 text-sm text-muted-foreground">
{count} {count === 1 ? "file" : "files"}
</div>
);
}
function ProjectHeader({
item,
locale,
defaultLocale,
t,
}: {
item: PortfolioProjectView;
locale: AppLocale;
defaultLocale: AppLocale;
t: PortfolioProjectDetailProps["t"];
}) {
return (
<div className="space-y-8">
<MotionFade>
<AppCard level={3}>
<CardContent className="p-6 lg:p-10">
<Button asChild variant="ghost" className="h-auto px-0 py-0 text-sm">
<Link href={getLocalizedPath(locale, "/portfolio", defaultLocale)}>{t("back")}</Link>
</Button>
<div className="mt-6">
<ProjectMeta item={item} locale={locale} />
</div>
{item.previewUrl ? (
<div className="mt-8">
<Button asChild>
<Link href={item.previewUrl} target="_blank" rel="noreferrer">
{t("preview")}
<ArrowUpRight className="h-4 w-4" />
</Link>
</Button>
</div>
) : null}
</CardContent>
</AppCard>
</MotionFade>
);
}
function GridTemplate({
item,
locale,
defaultLocale,
t,
}: PortfolioProjectDetailProps) {
return (
<div className="space-y-6">
<ProjectHeader item={item} locale={locale} defaultLocale={defaultLocale} t={t} />
{item.coverImagePath ? (
<MotionFade delay={0.04}>
<AppCard>
<CardContent className="p-3 lg:p-4">
<PortfolioImage
src={item.coverImagePath}
alt={getLocalizedValue(item.title, locale)}
width={1800}
height={1000}
className="h-auto w-full rounded-surface object-cover"
/>
</CardContent>
</AppCard>
<BackLink locale={locale} defaultLocale={defaultLocale} t={t} />
<ProjectIntro item={item} locale={locale} />
</div>
</MotionFade>
) : null}
<div className="grid gap-4 lg:grid-cols-2">
{item.sections.map((section, index) => (
<MotionFade key={section.id} delay={0.06 * (index + 1)}>
<AppCard>
<div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_340px] lg:items-start">
<div className="order-2 space-y-6 lg:order-1">
{images.map((image, index) => (
<MotionFade key={image.key} delay={0.04 * index}>
<MediaFrame image={image} chrome aspect="aspect-[16/11]" priority={index === 0} />
</MotionFade>
))}
{texts.length > 0 ? (
<div className="grid gap-4 sm:grid-cols-2">
{texts.map((section) => (
<AppCard key={section.id}>
<CardContent className="p-5 lg:p-6">
<SectionBlock section={section} locale={locale} t={t} />
<TextSection section={section} locale={locale} t={t} />
</CardContent>
</AppCard>
</MotionFade>
))}
</div>
<AssetGallery assets={item.assets} locale={locale} t={t} />
</div>
);
}
function StoryTemplate({
item,
locale,
defaultLocale,
t,
}: PortfolioProjectDetailProps) {
return (
<div className="space-y-8">
<ProjectHeader item={item} locale={locale} defaultLocale={defaultLocale} t={t} />
{item.coverImagePath ? (
<MotionFade delay={0.04}>
<div className="overflow-hidden rounded-[32px] border border-border/70 bg-card p-3 shadow-card">
<PortfolioImage
src={item.coverImagePath}
alt={getLocalizedValue(item.title, locale)}
width={1800}
height={1100}
className="h-[440px] w-full rounded-[28px] object-cover"
/>
</div>
</MotionFade>
) : null}
<div className="space-y-4">
{item.sections.map((section, index) => (
<MotionFade key={section.id} delay={0.06 * (index + 1)}>
<div className="grid gap-4 lg:grid-cols-[120px_minmax(0,1fr)]">
<div className="pt-4">
<div className="inline-flex rounded-pill border border-border/70 bg-background px-4 py-2 text-xs uppercase tracking-[0.2em] text-muted-foreground">
{String(index + 1).padStart(2, "0")}
</div>
</div>
<AppCard className="overflow-hidden">
<CardContent className="p-6 lg:p-8">
<SectionBlock section={section} locale={locale} t={t} />
</CardContent>
</AppCard>
</div>
</MotionFade>
))}
</div>
<AssetGallery assets={item.assets} locale={locale} t={t} />
<aside className="order-1 lg:order-2">
<InfoPanel item={item} locale={locale} t={t} />
</aside>
</div>
</div>
);
}
function CaseStudyTemplate({
item,
locale,
defaultLocale,
t,
}: PortfolioProjectDetailProps) {
const [challenge, solution, outcome, ...restSections] = item.sections;
const leadSections = [challenge, solution, outcome].filter(
(section): section is PortfolioSectionView => Boolean(section),
);
/* ============================================================
PRINT (viewMode: GRID)
Gallery: mixed-size grid of images (logos, posters, ...).
============================================================ */
function PrintTemplate({ item, locale, defaultLocale, t }: PortfolioProjectDetailProps) {
const images = collectImages(item, locale);
const texts = textSections(item);
return (
<div className="space-y-8">
<ProjectHeader item={item} locale={locale} defaultLocale={defaultLocale} t={t} />
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_420px]">
<div className="space-y-10">
<MotionFade>
<div className="space-y-6">
{item.coverImagePath ? (
<MotionFade delay={0.04}>
<AppCard className="overflow-hidden">
<CardContent className="p-3">
<PortfolioImage
src={item.coverImagePath}
alt={getLocalizedValue(item.title, locale)}
width={1800}
height={1100}
className="h-auto w-full rounded-surface object-cover"
/>
<BackLink locale={locale} defaultLocale={defaultLocale} t={t} />
<ProjectIntro item={item} locale={locale} />
<div className="flex flex-wrap items-center gap-3">
<ProjectMeta item={item} locale={locale} />
<PreviewButton item={item} t={t} />
</div>
</div>
</MotionFade>
<MotionFade delay={0.06}>
<div className="grid auto-rows-[minmax(0,1fr)] grid-cols-2 gap-4 md:grid-cols-3">
{images.map((image, index) => {
// First image spans a large feature tile; the rest alternate size.
const span =
index === 0
? "col-span-2 row-span-2"
: index % 4 === 0
? "col-span-2"
: "";
const aspect = index === 0 ? "aspect-square" : "aspect-[4/3]";
return (
<div key={image.key} className={span}>
<MediaFrame image={image} aspect={index === 0 ? "aspect-square" : aspect} priority={index === 0} />
</div>
);
})}
</div>
</MotionFade>
{texts.length > 0 ? (
<div className="grid gap-4 md:grid-cols-2">
{texts.map((section) => (
<MotionFade key={section.id} delay={0.04}>
<AppCard>
<CardContent className="p-6">
<TextSection section={section} locale={locale} t={t} />
</CardContent>
</AppCard>
</MotionFade>
))}
</div>
) : null}
</div>
);
}
{leadSections.map((section, index) => (
<MotionFade key={section.id} delay={0.06 * (index + 1)}>
<AppCard level={index === 1 ? 3 : 1}>
<CardContent className="space-y-4 p-6 lg:p-8">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
{index === 0 ? "Challenge" : index === 1 ? "Solution" : "Outcome"}
/* ============================================================
EDITORIAL (viewMode: STORY)
Full-bleed hero + alternating text / media sections.
============================================================ */
function EditorialTemplate({ item, locale, defaultLocale, t }: PortfolioProjectDetailProps) {
const cover = collectImages(item, locale)[0]!;
const sections = item.sections;
const galleryImages = collectImages(item, locale).slice(1);
return (
<div className="space-y-6">
<MotionFade>
<BackLink locale={locale} defaultLocale={defaultLocale} t={t} />
</MotionFade>
<MotionFade delay={0.04}>
<div className="relative flex min-h-[420px] items-end overflow-hidden rounded-surface border border-border">
<div className="absolute inset-0">
<PortfolioCover src={cover.src} title={cover.label} priority sizes="100vw" />
</div>
<div className="absolute inset-0 bg-gradient-to-t from-background/90 via-background/40 to-transparent" />
<div className="relative z-10 max-w-3xl space-y-4 p-8 lg:p-12">
<p className="text-overline uppercase tracking-[0.18em] text-brand-primary">
{getLocalizedValue(item.category.name, locale)} · {item.projectYear}
</p>
<SectionBlock section={section} locale={locale} t={t} />
</CardContent>
</AppCard>
<h1 className="text-display text-foreground">{getLocalizedValue(item.title, locale)}</h1>
</div>
</div>
</MotionFade>
))}
<div className="flex flex-wrap items-center gap-3">
<ProjectMeta item={item} locale={locale} />
<PreviewButton item={item} t={t} />
</div>
<div className="space-y-6">
<MotionFade delay={0.08}>
<AppCard level={2} className="sticky top-24">
<CardContent className="space-y-5 p-6">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">Case Study Snapshot</p>
<h2 className="mt-2 text-2xl font-semibold text-foreground">
{getLocalizedValue(item.title, locale)}
</h2>
</div>
<p className="text-sm leading-7 text-muted-foreground">
<MotionFade delay={0.06}>
<p className="max-w-3xl py-6 text-h3 font-medium leading-snug text-foreground/90">
{getLocalizedValue(item.summary, locale)}
</p>
<ProjectMeta item={item} locale={locale} />
</CardContent>
</AppCard>
</MotionFade>
</div>
</div>
{restSections.length > 0 ? (
<div className="grid gap-4 lg:grid-cols-2">
{restSections.map((section, index) => (
<MotionFade key={section.id} delay={0.1 + index * 0.04}>
<AppCard>
<CardContent className="p-5 lg:p-6">
<SectionBlock section={section} locale={locale} t={t} />
</CardContent>
</AppCard>
<div className="space-y-16 py-4">
{sections.map((section, index) => {
if (section.type === "GALLERY") {
return (
<MotionFade key={section.id} delay={0.04}>
<MediaFrame
image={{
key: section.id,
src: section.imagePath ?? null,
label: getLocalizedValue(section.title, locale),
}}
aspect="aspect-[16/8]"
/>
</MotionFade>
))}
</div>
) : null}
);
}
<AssetGallery assets={item.assets} locale={locale} t={t} />
return (
<MotionFade key={section.id} delay={0.04}>
<div
className={`grid items-center gap-8 lg:grid-cols-2 ${
index % 2 === 1 ? "lg:[&>*:first-child]:order-2" : ""
}`}
>
<TextSection section={section} locale={locale} t={t} />
<MediaFrame
image={galleryImages[index % Math.max(galleryImages.length, 1)] ?? cover}
aspect="aspect-[4/3]"
/>
</div>
</MotionFade>
);
})}
</div>
</div>
);
}
@@ -441,12 +447,12 @@ export function PortfolioProjectDetail({
const viewMode = resolvePortfolioProjectViewMode(item.viewMode);
if (viewMode === "STORY") {
return <StoryTemplate item={item} locale={locale} defaultLocale={defaultLocale} t={t} />;
return <EditorialTemplate item={item} locale={locale} defaultLocale={defaultLocale} t={t} />;
}
if (viewMode === "CASE_STUDY") {
return <CaseStudyTemplate item={item} locale={locale} defaultLocale={defaultLocale} t={t} />;
return <WebTemplate item={item} locale={locale} defaultLocale={defaultLocale} t={t} />;
}
return <GridTemplate item={item} locale={locale} defaultLocale={defaultLocale} t={t} />;
return <PrintTemplate item={item} locale={locale} defaultLocale={defaultLocale} t={t} />;
}
+14 -1
View File
@@ -87,10 +87,21 @@ export interface AppCardProps
VariantProps<typeof appCardVariants>,
VariantProps<typeof appCardInnerVariants> {
layer?: "double" | "single";
/**
* Classes applied to the element that directly wraps `children` (the inner
* shell in `double` layer, the single shell in `single` layer). Use it for
* content utilities such as `space-y-*` / `grid` / `flex` so they keep
* working after switching a card to the layered (double) look, where
* `className` lands on the outer shell instead.
*/
contentClassName?: string;
}
const AppCard = React.forwardRef<HTMLDivElement, AppCardProps>(
({ className, level, padding, interactive, layer = "double", children, ...props }, ref) => {
(
{ className, contentClassName, level, padding, interactive, layer = "double", children, ...props },
ref,
) => {
if (layer === "single") {
return (
<Card
@@ -102,6 +113,7 @@ const AppCard = React.forwardRef<HTMLDivElement, AppCardProps>(
interactive,
}),
className,
contentClassName,
)}
{...props}
>
@@ -129,6 +141,7 @@ const AppCard = React.forwardRef<HTMLDivElement, AppCardProps>(
padding,
interactive,
}),
contentClassName,
)}
>
{children}
+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 };
+7 -1
View File
@@ -98,22 +98,28 @@ function TabsTrigger({
type TabsContentProps = React.HTMLAttributes<HTMLDivElement> & {
value: string;
// Keep the panel mounted while inactive (hidden via `hidden`) so its form
// fields still submit. Use for tabbed forms that share one submit button.
forceMount?: boolean;
};
function TabsContent({
className,
value,
children,
forceMount = false,
...props
}: TabsContentProps) {
const { value: activeValue } = useTabsContext();
const isActive = activeValue === value;
if (activeValue !== value) {
if (!isActive && !forceMount) {
return null;
}
return (
<div
hidden={!isActive}
className={cn(
"mt-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0",
className,
+9 -3
View File
@@ -181,8 +181,8 @@ i18n/routing.ts
Admin routing
lib/admin-routing.ts
Prisma access
lib/prisma.ts
Database access (Drizzle)
lib/db/index.ts, lib/db/schema.ts
Application configuration
lib/app-config.ts
@@ -191,7 +191,13 @@ Portfolio logic
lib/portfolio.ts
Media handling
lib/media.ts
lib/media.ts (DB), lib/media-storage.ts (filesystem + content validation), lib/media-service.ts
SEO / metadata
lib/metadata.ts, lib/seo-settings.ts, lib/seo-report.ts, app/robots.ts, app/sitemap.ts (docs/SEO.md)
Admin session token (shared by middleware and server auth)
lib/admin-session-token.ts
---
+11 -4
View File
@@ -14,8 +14,11 @@
- Contact form with:
- validation
- email delivery
- Success page after contact submission
- Maintenance redirect flow
- Success page after contact submission (noindex)
- Maintenance redirect flow (bypass requires a *signed* admin session cookie)
- SEO: localized metadata with canonical + hreflang, OG/Twitter cards (project
cover as share image), JSON-LD (WebSite + Person/Organization, CreativeWork per
project), dynamic `robots.txt` and hreflang `sitemap.xml` — see `docs/SEO.md`
### Admin
@@ -24,8 +27,12 @@
- Portfolio category management
- Portfolio project creation and editing
- Section and asset management inside each project
- Media library with usage bindings
- Site settings management
- Media library with usage bindings (uploads are magic-byte checked, SVGs are
sanitized and served sandboxed)
- Site settings management (Brand, Localization, SEO)
- SEO page: indexing switch, Search Console/Bing verification, X handle,
structured-data identity, per-locale keywords, readiness checklist and links
to sitemap/robots/manifest
- SMTP settings and test email
- Marquee settings
- Maintenance toggle
+83
View File
@@ -0,0 +1,83 @@
# SEO
How search visibility works in this project and where each piece is controlled.
Everything is data-driven from the admin; no code change is needed to adjust
titles, descriptions, indexing, verification, or structured data.
## Admin: Settings → SEO (`/site-settings/seo`)
Canonical page: `app/_admin/site-settings/seo/page.tsx` (mirrored under
`app/admin-internal/` and `app/root/`). Form: `components/admin/seo-settings-form.tsx`.
Action: `saveSeoSettingsAction` in `app/_admin/site-settings/actions.ts`.
Stored as one JSON blob in `app_config` under key `seo_settings`
(`lib/seo-settings.ts` parses/normalizes; `lib/app-config.ts` exposes
`getSeoSettings` / `updateSeoSettings`).
| Field | Effect |
|---|---|
| Indexierung erlauben | Off → `noindex,nofollow` meta on every page, `robots.txt` disallows `/`, `sitemap.xml` becomes empty. Maintenance mode forces the same automatically. |
| Google / Bing Verification | `<meta name="google-site-verification">` and `<meta name="msvalidate.01">` on all pages. Tokens are restricted to `[A-Za-z0-9_-]`. |
| X / Twitter Handle | `twitter:site` + `twitter:creator`. |
| Strukturierte Daten | Type (`Person` / `Organization`), name, job title/slogan, `sameAs` profile URLs → JSON-LD publisher on every public page. |
| Keywords (per locale) | `<meta name="keywords">` per language. |
The page also shows a **checklist** (`lib/seo-report.ts`) — indexing state, public
URL, meta description length per locale, OG image, favicon, verification,
structured data, published projects, sitemap URL count — and **open buttons** for
`/sitemap.xml`, `/robots.txt`, `/manifest.webmanifest`.
Titles, descriptions and the title template per locale live under
**Settings → Localization**; logos, favicon and the default OG image under
**Settings → Brand**.
## Generated files
- `app/robots.ts``/robots.txt`. Indexable: allow `/`, disallow admin
(`/admin-internal`, `/root`), `/api/`, `/success`, `/coming-soon` (+ locale
variants), plus the sitemap URL. Not indexable (setting off or maintenance):
disallow everything.
- `app/sitemap.ts``/sitemap.xml`. One entry per locale for home, about,
portfolio, contact, every category that has published projects, and every
published project — each with `xhtml:link hreflang` alternates and `x-default`.
Empty while maintenance mode is on or indexing is disabled.
- `app/manifest.ts``/manifest.webmanifest` (icons from Brand settings).
## Per-page metadata (`lib/metadata.ts`)
- `buildAppMetadata()` — root layout: `metadataBase`, robots, verification,
keywords, icons, manifest, OG (`og:locale` as `de_DE`/`en_US`/`ar_AR` +
`alternateLocale`), Twitter.
- `buildLocalizedMetadata({...})` — every public page: templated title,
description (≤300 chars), canonical + hreflang alternates, robots, OG, Twitter.
Options: `image` (page-specific share image), `noIndex`, `type: "article"`,
`publishedTime`.
- Portfolio project pages pass the project **cover** as OG image and `article`
type. This is independent of the project's view mode (`GRID` / `STORY` /
`CASE_STUDY`), so new view modes inherit full SEO automatically.
- `/success` and `/coming-soon` are `noindex`.
## Structured data (JSON-LD)
Rendered via `components/seo/json-ld.tsx` (server component; `<` is escaped).
- Site layout: `WebSite` + publisher (`Person` or `Organization`) graph linked by
`@id` (`buildSiteJsonLd`).
- Project page: `CreativeWork` with url, headline, description, image, genre
(category), keywords (service label, year), `datePublished`, author `@id`,
client as `sourceOrganization` (`buildProjectJsonLd`).
## Slugs
Categories and projects share `/portfolio/[slug]`; categories win at resolve
time. The admin therefore rejects a project slug that equals an existing
category slug and vice versa (`app/_admin/portfolio/actions.ts`).
## Operational checklist before launch
1. `NEXT_PUBLIC_SITE_URL` must be the public `https://` origin (canonical base).
2. Settings → Localization: site name + 50160 char description in DE/EN/AR.
3. Settings → Brand: default OG image (1200×630) + favicon.
4. Settings → SEO: indexing on, verification codes, Person/Organization data.
5. Maintenance mode off. Verify `/robots.txt` and `/sitemap.xml` from the SEO page.
6. Submit the sitemap in Google Search Console / Bing Webmaster.
+10
View File
@@ -1,5 +1,15 @@
import { existsSync } from "node:fs";
import { defineConfig } from "drizzle-kit";
// drizzle-kit (unlike the Next.js app) does not load .env automatically, so
// DATABASE_URL would be undefined and fall back to the wrong host. Load .env
// for local dev. In production the env is already provided (docker-compose)
// and no .env file exists, so this is skipped.
if (!process.env.DATABASE_URL && existsSync(".env")) {
process.loadEnvFile(".env");
}
export default defineConfig({
schema: "./lib/db/schema.ts",
out: "./lib/db/migrations",
+1 -1
View File
@@ -17,7 +17,7 @@ const config = [
},
},
{
ignores: ["prisma/seed.js"],
ignores: ["prisma/seed.js", "scripts/legacy-prisma-seed.cjs"],
},
];
+9 -27
View File
@@ -1,4 +1,4 @@
import { createHash, createHmac, timingSafeEqual } from "crypto";
import { createHash, timingSafeEqual } from "crypto";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
@@ -7,9 +7,13 @@ import { and, eq, like, lt } from "drizzle-orm";
import { db } from "./db";
import { appConfig } from "./db/schema";
import { getAdminAppPath } from "./admin-routing";
import {
ADMIN_SESSION_COOKIE as SHARED_ADMIN_SESSION_COOKIE,
buildAdminSessionToken,
verifyAdminSessionToken,
} from "./admin-session-token";
export const ADMIN_SESSION_COOKIE = "moh_admin_session";
const ADMIN_SESSION_VALUE = "superadmin";
export const ADMIN_SESSION_COOKIE = SHARED_ADMIN_SESSION_COOKIE;
const MAX_FAILED_ATTEMPTS = 5;
const LOCKOUT_SECONDS = 15 * 60;
const ADMIN_LOCKOUT_KEY_PREFIX = "admin_lockout";
@@ -91,34 +95,12 @@ function getAdminCookieDomain(): string | undefined {
return hostname ? `.${hostname}` : undefined;
}
function signValue(value: string): string {
return createHmac("sha256", getSecret()).update(value).digest("hex");
}
function buildToken(): string {
return `${ADMIN_SESSION_VALUE}.${signValue(ADMIN_SESSION_VALUE)}`;
return buildAdminSessionToken();
}
function verifyToken(token: string): boolean {
const parts = token.split(".");
if (parts.length !== 2) {
return false;
}
const [value, signature] = parts;
if (value !== ADMIN_SESSION_VALUE) {
return false;
}
const expected = signValue(value);
const left = Buffer.from(signature);
const right = Buffer.from(expected);
if (left.length !== right.length) {
return false;
}
return timingSafeEqual(left, right);
return verifyAdminSessionToken(token);
}
async function getClientIp(): Promise<string> {
+9 -1
View File
@@ -7,6 +7,7 @@ import {
Mail,
Palette,
PlusSquare,
Search,
ShieldAlert,
SwatchBook,
Tags,
@@ -24,6 +25,7 @@ type AdminNavigationCopy = {
siteSettings: string;
brandSettings?: string;
localizationSettings?: string;
seoSettings?: string;
marquee?: string;
smtp?: string;
};
@@ -41,7 +43,7 @@ export function getAdminNavigation(
copy: AdminNavigationCopy,
active: "overview" | "maintenance" | "ui-kit" | "portfolio" | "media" | "site-settings" | "smtp" | "marquee",
portfolioChild?: "overview" | "projects" | "new-project" | "categories",
siteSettingsChild?: "brand" | "localization",
siteSettingsChild?: "brand" | "localization" | "seo",
): AdminNavItem[] {
return [
{
@@ -87,6 +89,12 @@ export function getAdminNavigation(
icon: Languages,
active: siteSettingsChild === "localization",
},
{
label: copy.seoSettings ?? "SEO",
href: getAdminAppPath("/site-settings/seo"),
icon: Search,
active: siteSettingsChild === "seo",
},
],
},
{
+47
View File
@@ -0,0 +1,47 @@
import { createHmac, timingSafeEqual } from "crypto";
/**
* Pure helpers for the admin session cookie token. Kept free of `next/headers`
* and the database so the middleware (`proxy.ts`) can verify a session without
* pulling the server-only auth module into the edge/middleware bundle.
*/
export const ADMIN_SESSION_COOKIE = "moh_admin_session";
export const ADMIN_SESSION_VALUE = "superadmin";
function getSecret(): string {
return process.env.ADMIN_AUTH_SECRET ?? "";
}
function signValue(value: string): string {
return createHmac("sha256", getSecret()).update(value).digest("hex");
}
export function buildAdminSessionToken(): string {
return `${ADMIN_SESSION_VALUE}.${signValue(ADMIN_SESSION_VALUE)}`;
}
export function verifyAdminSessionToken(token: string | undefined | null): boolean {
if (!token || !getSecret()) {
return false;
}
const parts = token.split(".");
if (parts.length !== 2) {
return false;
}
const [value, signature] = parts;
if (value !== ADMIN_SESSION_VALUE) {
return false;
}
const expected = signValue(value);
const left = Buffer.from(signature);
const right = Buffer.from(expected);
if (left.length !== right.length) {
return false;
}
return timingSafeEqual(left, right);
}
+19
View File
@@ -67,6 +67,13 @@ import {
syncMarqueeSettingsToGermanSource,
type MarqueeSettings,
} from "./marquee-settings";
import { SEO_SETTINGS_KEY, buildDefaultSeoSettings, parseSeoSettingsValue, type SeoSettings } from "./seo-settings";
export {
SEO_SETTINGS_KEY,
buildDefaultSeoSettings,
parseSeoSettingsValue,
type SeoSettings,
} from "./seo-settings";
// Small helpers over the app_config key/value table (Drizzle).
async function readConfigValue(key: string): Promise<string | undefined> {
@@ -150,6 +157,18 @@ export async function updateMarqueeSettings(settings: MarqueeSettings): Promise<
await upsertConfig(MARQUEE_SETTINGS_KEY, JSON.stringify(normalizedSettings));
}
export async function getSeoSettings(): Promise<SeoSettings> {
try {
return parseSeoSettingsValue(await readConfigValue(SEO_SETTINGS_KEY));
} catch {
return buildDefaultSeoSettings();
}
}
export async function updateSeoSettings(settings: SeoSettings): Promise<void> {
await upsertConfig(SEO_SETTINGS_KEY, JSON.stringify(settings));
}
export async function getSiteSettingsMediaBindings(): Promise<SiteSettingsMediaBindings> {
try {
const usages = await db
+3 -2
View File
@@ -125,7 +125,8 @@ export async function createStandaloneMediaAsset(input: {
uploadFile: FormDataEntryValue | null;
}) {
if (input.uploadFile instanceof File && input.uploadFile.size > 0) {
const savedFile = await saveMediaUpload(input.uploadFile, input.kind.toLowerCase());
const kind = getKindFromUploadFile(input.uploadFile);
const savedFile = await saveMediaUpload(input.uploadFile, kind.toLowerCase());
const derivedLabel = input.uploadFile.name.replace(/\.[^.]+$/, "").trim();
const trimmedLabel = input.label.trim() || derivedLabel || "Media asset";
@@ -135,7 +136,7 @@ export async function createStandaloneMediaAsset(input: {
return createMediaAsset({
source: MediaSource.UPLOAD,
kind: input.kind,
kind,
url: savedFile.url,
fileName: savedFile.fileName,
label: trimmedLabel,
+61 -5
View File
@@ -37,16 +37,65 @@ export function resolveMediaUploadPath(filePath: string) {
throw new Error("Only managed media uploads can be resolved.");
}
const relativePath = filePath.replace("/uploads/media/", "");
const relativePath = filePath.slice("/uploads/media/".length);
if (!relativePath || relativePath.includes("\0")) {
throw new Error("Resolved media upload path escapes the upload root.");
}
const absolutePath = path.resolve(MEDIA_UPLOAD_ROOT, relativePath);
if (!absolutePath.startsWith(MEDIA_UPLOAD_ROOT)) {
// `startsWith(root)` alone would accept a sibling directory such as
// `.../uploads/media-evil/...`; require the separator so only true children pass.
if (absolutePath !== MEDIA_UPLOAD_ROOT && !absolutePath.startsWith(MEDIA_UPLOAD_ROOT + path.sep)) {
throw new Error("Resolved media upload path escapes the upload root.");
}
if (absolutePath === MEDIA_UPLOAD_ROOT) {
throw new Error("Resolved media upload path escapes the upload root.");
}
return absolutePath;
}
const MAGIC_SIGNATURES: Record<string, Array<{ offset: number; bytes: number[] }>> = {
".png": [{ offset: 0, bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] }],
".jpg": [{ offset: 0, bytes: [0xff, 0xd8, 0xff] }],
".gif": [{ offset: 0, bytes: [0x47, 0x49, 0x46, 0x38] }],
".webp": [
{ offset: 0, bytes: [0x52, 0x49, 0x46, 0x46] },
{ offset: 8, bytes: [0x57, 0x45, 0x42, 0x50] },
],
".pdf": [{ offset: 0, bytes: [0x25, 0x50, 0x44, 0x46] }],
".ico": [{ offset: 0, bytes: [0x00, 0x00, 0x01, 0x00] }],
};
const SVG_FORBIDDEN_PATTERN = /<script[\s>]|javascript:|on[a-z]+\s*=|<foreignObject|<iframe|<embed|<object|xlink:href\s*=\s*["']\s*(?!#|data:image\/)/i;
/**
* Verify that the file bytes match the extension derived from the declared
* MIME type. The browser-supplied `file.type` is untrusted: without this check
* an HTML/JS payload could be stored as `.png` and served from our origin.
*/
export function isMediaContentValid(extension: string, buffer: Buffer): boolean {
if (extension === ".svg") {
const head = buffer.subarray(0, 4096).toString("utf8").trimStart();
const looksLikeSvg = head.startsWith("<svg") || (head.startsWith("<?xml") && /<svg[\s>]/i.test(head));
return looksLikeSvg && !SVG_FORBIDDEN_PATTERN.test(buffer.toString("utf8"));
}
const signatures = MAGIC_SIGNATURES[extension];
if (!signatures) {
return false;
}
return signatures.every(({ offset, bytes }) =>
bytes.every((byte, index) => buffer[offset + index] === byte),
);
}
export async function removeManagedMediaFile(filePath: string | null | undefined) {
if (!isManagedMediaFilePath(filePath)) {
return false;
@@ -76,16 +125,23 @@ export async function saveMediaUpload(file: File, folder: string) {
throw new Error("File is too large.");
}
const buffer = Buffer.from(await file.arrayBuffer());
if (!isMediaContentValid(extension, buffer)) {
throw new Error("File content does not match its declared type.");
}
const safeFolder = sanitizeBaseName(folder) || "misc";
const safeBaseName = sanitizeBaseName(file.name.replace(/\.[^.]+$/, "")) || "asset";
const finalName = `${safeBaseName}-${randomUUID().slice(0, 8)}${extension}`;
const targetDir = path.join(MEDIA_UPLOAD_ROOT, folder);
const targetDir = path.join(MEDIA_UPLOAD_ROOT, safeFolder);
const targetPath = path.join(targetDir, finalName);
await mkdir(targetDir, { recursive: true });
await writeFile(targetPath, Buffer.from(await file.arrayBuffer()));
await writeFile(targetPath, buffer);
return {
url: `/uploads/media/${folder}/${finalName}`,
url: `/uploads/media/${safeFolder}/${finalName}`,
fileName: finalName,
mimeType: file.type,
size: file.size,
+3 -1
View File
@@ -30,7 +30,9 @@ export const mediaFieldInputSchema = z
});
}
if (value.url && !/^https?:\/\//.test(value.url) && !value.url.startsWith("/")) {
const isRootRelative = value.url.startsWith("/") && !value.url.startsWith("//");
if (value.url && !/^https?:\/\//i.test(value.url) && !isRootRelative) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ["url"],
+201 -18
View File
@@ -11,17 +11,50 @@ import {
getSiteSettings,
getSiteSettingsMediaBindings,
} from "./app-config";
import { AppLocale, getLocalizedPath, getLocalizedPathWithDefault, resolveLocale } from "./locale";
import { buildSiteIconUrls } from "./site-icons";
function getSiteUrl(): URL {
export function getSiteUrl(): URL {
return new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "https://mohfarawati.de");
}
function toAbsoluteUrl(pathname: string): string {
export function toAbsoluteUrl(pathname: string): string {
return new URL(pathname, getSiteUrl()).toString();
}
const NOINDEX_ROBOTS: Metadata["robots"] = {
index: false,
follow: false,
nocache: true,
googleBot: { index: false, follow: false, noimageindex: true },
};
const INDEX_ROBOTS: Metadata["robots"] = {
index: true,
follow: true,
googleBot: { index: true, follow: true, "max-image-preview": "large", "max-snippet": -1, "max-video-preview": -1 },
};
export function buildRobotsMetadata(seo: SeoSettings, noIndex = false): Metadata["robots"] {
return seo.allowIndexing && !noIndex ? INDEX_ROBOTS : NOINDEX_ROBOTS;
}
function buildVerification(seo: SeoSettings): Metadata["verification"] {
const verification: NonNullable<Metadata["verification"]> = {};
if (seo.googleSiteVerification) {
verification.google = seo.googleSiteVerification;
}
if (seo.bingSiteVerification) {
verification.other = { "msvalidate.01": seo.bingSiteVerification };
}
return Object.keys(verification).length > 0 ? verification : undefined;
}
import { AppLocale, getLocalizedPath, getLocalizedPathWithDefault, resolveLocale } from "./locale";
import { getSeoSettings } from "./app-config";
import { buildDefaultSeoSettings, toOpenGraphLocale, type SeoSettings } from "./seo-settings";
import { buildSiteIconUrls } from "./site-icons";
export function buildLocaleAlternates(pathname: string, defaultLocale: AppLocale) {
const languages = Object.fromEntries(
appLocales.map((locale) => [locale, toAbsoluteUrl(getLocalizedPathWithDefault(locale, pathname, defaultLocale))]),
@@ -46,7 +79,7 @@ export function applyTitleTemplateFn(title: string, template: string, siteName:
.replace(PAGE_TITLE_TOKEN, title);
}
function buildMetadataImages(imageUrl?: string | null) {
function buildMetadataImages(imageUrl?: string | null, alt?: string) {
if (!imageUrl) {
return undefined;
}
@@ -54,22 +87,29 @@ function buildMetadataImages(imageUrl?: string | null) {
return [
{
url: toAbsoluteUrl(imageUrl),
alt,
},
];
}
export async function buildAppMetadata(): Promise<Metadata> {
const [settings, bindings] = await Promise.all([getSiteSettings(), getSiteSettingsMediaBindings()]);
const [settings, bindings, seo] = await Promise.all([
getSiteSettings(),
getSiteSettingsMediaBindings(),
getSeoSettings(),
]);
return buildAppMetadataFromConfig(settings, bindings);
return buildAppMetadataFromConfig(settings, bindings, seo);
}
export function buildAppMetadataFromConfig(
settings: SiteSettings,
bindings: SiteSettingsMediaBindings,
seo: SeoSettings = buildDefaultSeoSettings(),
): Metadata {
const defaultLocaleSettings = settings.locales[settings.defaultLocale];
const openGraphImages = buildMetadataImages(bindings.defaultOgImage?.url);
const openGraphImages = buildMetadataImages(bindings.defaultOgImage?.url, defaultLocaleSettings.siteName);
const keywords = seo.locales[settings.defaultLocale].keywords;
const siteIconUrls = buildSiteIconUrls({
siteName: defaultLocaleSettings.siteName,
faviconVersion: bindings.favicon?.version,
@@ -81,6 +121,10 @@ export function buildAppMetadataFromConfig(
title: defaultLocaleSettings.siteName,
description: defaultLocaleSettings.siteDescription,
applicationName: defaultLocaleSettings.siteName,
keywords: keywords ? keywords.split(",").map((keyword) => keyword.trim()).filter(Boolean) : undefined,
robots: buildRobotsMetadata(seo),
verification: buildVerification(seo),
formatDetection: { telephone: false },
manifest: siteIconUrls.manifestHref,
icons: {
icon: [{ url: siteIconUrls.faviconHref }],
@@ -92,7 +136,8 @@ export function buildAppMetadataFromConfig(
description: defaultLocaleSettings.siteDescription,
url: toAbsoluteUrl(getLocalizedPathWithDefault(settings.defaultLocale, "/", settings.defaultLocale)),
siteName: defaultLocaleSettings.siteName,
locale: settings.defaultLocale,
locale: toOpenGraphLocale(settings.defaultLocale),
alternateLocale: appLocales.filter((locale) => locale !== settings.defaultLocale).map(toOpenGraphLocale),
type: "website",
images: openGraphImages,
},
@@ -100,12 +145,25 @@ export function buildAppMetadataFromConfig(
card: openGraphImages ? "summary_large_image" : "summary",
title: defaultLocaleSettings.siteName,
description: defaultLocaleSettings.siteDescription,
site: seo.twitterHandle || undefined,
creator: seo.twitterHandle || undefined,
images: openGraphImages?.map((image) => image.url),
},
};
}
type LocalizedMetadataInput = {
type LocalizedMetadataOptions = {
/** Page-specific share image (e.g. a project cover). Falls back to the default OG image. */
image?: string | null;
/** Force `noindex` (thank-you pages, coming-soon, etc.). */
noIndex?: boolean;
/** Open Graph object type. Portfolio projects use `article`. */
type?: "website" | "article";
publishedTime?: Date | null;
modifiedTime?: Date | null;
};
type LocalizedMetadataInput = LocalizedMetadataOptions & {
locale: string;
pathname: string;
title: string;
@@ -119,65 +177,190 @@ export async function buildLocalizedMetadata({
title,
description,
applyTitleTemplate,
...options
}: LocalizedMetadataInput): Promise<Metadata> {
const [settings, bindings] = await Promise.all([getSiteSettings(), getSiteSettingsMediaBindings()]);
const [settings, bindings, seo] = await Promise.all([
getSiteSettings(),
getSiteSettingsMediaBindings(),
getSeoSettings(),
]);
const localeKey = resolveLocale(locale, settings.defaultLocale);
return buildLocalizedMetadataFromConfig({
settings,
bindings,
seo,
locale: localeKey,
pathname,
title,
description,
applyTitleTemplate,
...options,
});
}
export function buildLocalizedMetadataFromConfig(input: {
export function buildLocalizedMetadataFromConfig(
input: LocalizedMetadataOptions & {
settings: SiteSettings;
bindings: SiteSettingsMediaBindings;
seo?: SeoSettings;
locale: AppLocale;
pathname: string;
title: string;
description?: string;
applyTitleTemplate?: boolean;
}): Metadata {
},
): Metadata {
const {
settings,
bindings,
seo = buildDefaultSeoSettings(),
locale,
pathname,
title,
description,
applyTitleTemplate = true,
image,
noIndex = false,
type = "website",
publishedTime,
modifiedTime,
} = input;
const localeKey = resolveLocale(locale, settings.defaultLocale);
const localeSettings = settings.locales[localeKey];
const resolvedDescription = description?.trim() || localeSettings.siteDescription;
const resolvedDescription = (description?.trim() || localeSettings.siteDescription).slice(0, 300);
const resolvedTitle = applyTitleTemplate
? applyTitleTemplateFn(title, localeSettings.titleTemplate, localeSettings.siteName)
: title;
const openGraphImages = buildMetadataImages(bindings.defaultOgImage?.url);
const openGraphImages = buildMetadataImages(image || bindings.defaultOgImage?.url, title);
const keywords = seo.locales[localeKey].keywords;
return {
title: resolvedTitle,
description: resolvedDescription,
keywords: keywords ? keywords.split(",").map((keyword) => keyword.trim()).filter(Boolean) : undefined,
robots: buildRobotsMetadata(seo, noIndex),
alternates: buildLocaleAlternates(pathname, settings.defaultLocale),
openGraph: {
title: resolvedTitle,
description: resolvedDescription,
url: toAbsoluteUrl(getLocalizedPath(localeKey, pathname, settings.defaultLocale)),
siteName: localeSettings.siteName,
locale: localeKey,
type: "website",
locale: toOpenGraphLocale(localeKey),
alternateLocale: appLocales.filter((entry) => entry !== localeKey).map(toOpenGraphLocale),
images: openGraphImages,
...(type === "article"
? {
type: "article" as const,
publishedTime: publishedTime?.toISOString(),
modifiedTime: (modifiedTime ?? publishedTime)?.toISOString(),
}
: { type: "website" as const }),
},
twitter: {
card: openGraphImages ? "summary_large_image" : "summary",
title: resolvedTitle,
description: resolvedDescription,
images: openGraphImages?.map((image) => image.url),
site: seo.twitterHandle || undefined,
creator: seo.twitterHandle || undefined,
images: openGraphImages?.map((entry) => entry.url),
},
};
}
// --- JSON-LD ----------------------------------------------------------------
type JsonLd = Record<string, unknown>;
/** WebSite + publisher (Person/Organization) graph for the home page. */
export function buildSiteJsonLd(input: {
settings: SiteSettings;
seo: SeoSettings;
bindings: SiteSettingsMediaBindings;
locale: AppLocale;
}): JsonLd {
const { settings, seo, bindings, locale } = input;
const localeSettings = settings.locales[locale];
const siteUrl = getSiteUrl().toString();
const publisherName = seo.structuredDataName || localeSettings.siteName;
const logoUrl = bindings.siteLogoLight?.url ?? bindings.defaultOgImage?.url ?? null;
const publisher: JsonLd = {
"@type": seo.structuredDataType,
"@id": `${siteUrl}#${seo.structuredDataType.toLowerCase()}`,
name: publisherName,
url: siteUrl,
};
if (seo.structuredDataJobTitle) {
publisher[seo.structuredDataType === "Person" ? "jobTitle" : "slogan"] = seo.structuredDataJobTitle;
}
if (logoUrl) {
publisher[seo.structuredDataType === "Person" ? "image" : "logo"] = toAbsoluteUrl(logoUrl);
}
if (seo.sameAs.length > 0) {
publisher.sameAs = seo.sameAs;
}
return {
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebSite",
"@id": `${siteUrl}#website`,
url: siteUrl,
name: localeSettings.siteName,
description: localeSettings.siteDescription || undefined,
inLanguage: appLocales,
publisher: { "@id": publisher["@id"] },
},
publisher,
],
};
}
/** CreativeWork for a single portfolio project (any view mode). */
export function buildProjectJsonLd(input: {
settings: SiteSettings;
seo: SeoSettings;
locale: AppLocale;
pathname: string;
title: string;
description: string;
image?: string | null;
datePublished?: Date | null;
dateModified?: Date | null;
genre?: string;
keywords?: string[];
clientName?: string;
}): JsonLd {
const { settings, seo, locale, pathname } = input;
const siteUrl = getSiteUrl().toString();
const url = toAbsoluteUrl(getLocalizedPath(locale, pathname, settings.defaultLocale));
return {
"@context": "https://schema.org",
"@type": "CreativeWork",
"@id": `${url}#work`,
url,
name: input.title,
headline: input.title,
description: input.description || undefined,
image: input.image ? toAbsoluteUrl(input.image) : undefined,
inLanguage: locale,
genre: input.genre || undefined,
keywords: input.keywords && input.keywords.length > 0 ? input.keywords.join(", ") : undefined,
datePublished: input.datePublished?.toISOString(),
dateModified: (input.dateModified ?? input.datePublished)?.toISOString(),
author: { "@id": `${siteUrl}#${seo.structuredDataType.toLowerCase()}` },
sourceOrganization: input.clientName ? { "@type": "Organization", name: input.clientName } : undefined,
isPartOf: { "@id": `${siteUrl}#website` },
};
}
/** Serialize JSON-LD safely for a `<script type="application/ld+json">` tag. */
export function serializeJsonLd(data: JsonLd): string {
return JSON.stringify(data).replace(/</g, "\\u003c");
}
+12 -4
View File
@@ -136,13 +136,21 @@ export function getPortfolioWizardProgress(
},
{
key: "sections",
complete: input.sections.length > 0 && completedSections === input.sections.length,
summary: `${completedSections}/${input.sections.length} sections ready.`,
// Optional: no sections is valid. If sections were added, each must be ready.
complete: completedSections === input.sections.length,
summary:
input.sections.length === 0
? "Optional — no sections added."
: `${completedSections}/${input.sections.length} sections ready.`,
},
{
key: "assets",
complete: input.assets.length > 0 && completedAssets === input.assets.length,
summary: `${completedAssets}/${input.assets.length} assets ready.`,
// Optional: no gallery assets is valid. If assets were added, each must be ready.
complete: completedAssets === input.assets.length,
summary:
input.assets.length === 0
? "Optional — no gallery assets added."
: `${completedAssets}/${input.assets.length} assets ready.`,
},
];
}
+18
View File
@@ -123,3 +123,21 @@ export const projectInputSchema = z.object({
sections: z.array(sectionInputSchema),
assets: z.array(assetInputSchema),
});
/**
* Draft variant: the user-facing copy fields are optional so an unfinished
* project can be saved and completed later. The action still guarantees a
* slug, a category, and a year (auto-filled), and forces the project unpublished.
*/
export const projectDraftInputSchema = projectInputSchema.extend({
titleAr: optionalTrimmedText,
titleEn: optionalTrimmedText,
titleDe: optionalTrimmedText,
summaryAr: optionalTrimmedText,
summaryEn: optionalTrimmedText,
summaryDe: optionalTrimmedText,
serviceLabelAr: optionalTrimmedText,
serviceLabelEn: optionalTrimmedText,
serviceLabelDe: optionalTrimmedText,
clientName: optionalTrimmedText,
});
+24
View File
@@ -285,6 +285,30 @@ export const getPublishedPortfolioProjectBySlug = cache(async function (slug: st
return mapProject(project);
});
export type ResolvedPortfolioSlug =
| { kind: "category"; category: PortfolioCategoryView }
| { kind: "project"; project: PortfolioProjectView }
| null;
/**
* Resolves a `/portfolio/[slug]` segment to either a category or a project.
* Categories take precedence so `/portfolio/web` shows the category listing;
* a project slug only wins when no active category shares that slug.
*/
export async function resolvePortfolioSlug(slug: string): Promise<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) {
const project = await db.query.portfolioProject.findFirst({
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_APPLE_ICON_PATH = "/apple-icon.png";
const INTERNAL_MANIFEST_PATH = "/manifest.webmanifest";
export const INTERNAL_MANIFEST_PATH = "/manifest.webmanifest";
const DEFAULT_ICON_VERSION = "default";
const TRANSPARENT_PNG_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9sot5WQAAAAASUVORK5CYII=";
+1
View File
@@ -3,6 +3,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
"prepare": "git config core.hooksPath .githooks || true",
"dev": "next dev -p 3014",
"build": "next build --webpack",
"start": "next start",
+2 -3
View File
@@ -16,6 +16,7 @@ import {
isLegacyAdminPath,
toInternalAdminPath,
} from "./lib/admin-routing";
import { ADMIN_SESSION_COOKIE, verifyAdminSessionToken } from "./lib/admin-session-token";
import {
FALLBACK_LOCALE,
getLocalizedPathWithDefault,
@@ -23,8 +24,6 @@ import {
stripLocalePrefix,
} from "./lib/locale";
const ADMIN_SESSION_COOKIE = "moh_admin_session";
type SiteRuntimeState = {
defaultLocale: (typeof appLocales)[number];
maintenanceEnabled: boolean;
@@ -220,7 +219,7 @@ export default async function middleware(request: NextRequest) {
if (
siteRuntimeState.maintenanceEnabled &&
!request.cookies.has(ADMIN_SESSION_COOKIE) &&
!verifyAdminSessionToken(request.cookies.get(ADMIN_SESSION_COOKIE)?.value) &&
!isComingSoonPath(pathname)
) {
const locale = getPathLocale(pathname, configuredDefaultLocale);
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
- Maintenance mode
- Media Library
- Site Settings
- Site Settings (Brand / Localization / SEO — see `docs/SEO.md`)
- Marquee Settings
- SMTP Settings
- Contact Protection
@@ -35,7 +35,7 @@ describe("PortfolioCategoryFilter", () => {
expect(screen.getByRole("link", { name: "All" })).toHaveAttribute("href", "/en/portfolio");
expect(screen.getByRole("link", { name: "Branding" })).toHaveAttribute(
"href",
"/en/portfolio/category/branding",
"/en/portfolio/branding",
);
});
@@ -51,7 +51,7 @@ describe("PortfolioCategoryFilter", () => {
// German locale on the default locale -> unprefixed paths and German labels
expect(screen.getByRole("link", { name: "Marke" })).toHaveAttribute(
"href",
"/portfolio/category/branding",
"/portfolio/branding",
);
expect(screen.getByRole("link", { name: "Alle" })).toHaveAttribute("href", "/portfolio");
});
+10 -1
View File
@@ -32,7 +32,7 @@ describe("createMediaAssetAction", () => {
});
it.skipIf(!canManageUploads)("creates an asset from an uploaded file", async () => {
const file = new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], "pic.png", { type: "image/png" });
const file = new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])], "pic.png", { type: "image/png" });
const url = await captureRedirect(() =>
createMediaAssetAction(formDataFrom({ kind: "IMAGE", label: "Pic", file })),
);
@@ -43,6 +43,15 @@ describe("createMediaAssetAction", () => {
await removeManagedMediaFile(assets[0].url);
});
it("rejects an upload whose bytes do not match the declared image type", async () => {
const file = new File(["<html><script>alert(1)</script></html>"], "evil.png", { type: "image/png" });
const url = await captureRedirect(() =>
createMediaAssetAction(formDataFrom({ kind: "IMAGE", label: "Evil", file })),
);
expect(url).toContain("error=");
expect((await db.select().from(mediaAsset)).length).toBe(0);
});
it("redirects unauthenticated callers to the admin root", async () => {
adminAuth.authenticated = false;
const url = await captureRedirect(() => createMediaAssetAction(formDataFrom({ kind: "IMAGE", label: "L" })));
@@ -104,6 +104,14 @@ describe("upsertCategoryAction", () => {
expect(category?.nameEn).toBe("Renamed");
});
it("rejects a category slug that already belongs to a project", async () => {
const other = await createCategory({ slug: "other" });
await createProject({ categoryId: other.id, slug: "taken" });
const url = await captureRedirect(() => upsertCategoryAction(categoryForm({ slug: "taken" })));
expect(new URL(url, "http://test").searchParams.get("error")).toContain("Projekt Slug vergeben");
expect(await db.query.category.findFirst({ where: eq(categoryTable.slug, "taken") })).toBeUndefined();
});
it("reports a unique-constraint violation on duplicate slugs", async () => {
await createCategory({ slug: "branding" });
const url = await captureRedirect(() => upsertCategoryAction(categoryForm({ slug: "branding" })));
@@ -141,6 +149,13 @@ describe("deleteCategoryAction", () => {
});
describe("saveProjectAction", () => {
it("rejects a project slug that already belongs to a category", async () => {
const category = await createCategory({ slug: "branding" });
const url = await captureRedirect(() => saveProjectAction(projectForm(category.id, { slug: "branding" })));
expect(new URL(url, "http://test").searchParams.get("error")).toContain("Kategorie Slug vergeben");
expect(await db.query.portfolioProject.findFirst({ where: eq(portfolioProjectTable.slug, "branding") })).toBeUndefined();
});
it("creates a published project with cover and asset media usages", async () => {
const category = await createCategory();
const url = await captureRedirect(() => saveProjectAction(projectForm(category.id)));
@@ -11,10 +11,11 @@ vi.mock("@/lib/admin-auth", async () => {
});
import {
saveSeoSettingsAction,
saveSiteBrandSettingsAction,
saveSiteLocalizationSettingsAction,
} from "@/app/_admin/site-settings/actions";
import { getSiteSettings, getSiteSettingsMediaBindings } from "@/lib/app-config";
import { getSeoSettings, getSiteSettings, getSiteSettingsMediaBindings } from "@/lib/app-config";
import { adminAuth, captureRedirect, formDataFrom, resetNextMocks } from "@/tests/helpers/next-mocks";
beforeEach(() => {
@@ -102,3 +103,51 @@ describe("saveSiteLocalizationSettingsAction", () => {
expect(url).toBe("/");
});
});
describe("saveSeoSettingsAction", () => {
it("persists normalized seo settings", async () => {
const url = await captureRedirect(() =>
saveSeoSettingsAction(
formDataFrom({
allowIndexing: "on",
googleSiteVerification: "g-1",
twitterHandle: "moh",
structuredDataType: "Organization",
structuredDataName: "Studio",
sameAs: "https://a.com\nhttps://b.com",
keywordsDe: "a, b",
}),
),
);
expect(url).toContain("success=");
const seo = await getSeoSettings();
expect(seo).toMatchObject({
allowIndexing: true,
googleSiteVerification: "g-1",
twitterHandle: "@moh",
structuredDataType: "Organization",
sameAs: ["https://a.com", "https://b.com"],
});
expect(seo.locales.de.keywords).toBe("a, b");
});
it("turns indexing off when the checkbox is missing", async () => {
await captureRedirect(() => saveSeoSettingsAction(formDataFrom({})));
expect((await getSeoSettings()).allowIndexing).toBe(false);
});
it("rejects an invalid verification token without saving", async () => {
await captureRedirect(() => saveSeoSettingsAction(formDataFrom({ allowIndexing: "on", googleSiteVerification: "ok" })));
const url = await captureRedirect(() =>
saveSeoSettingsAction(formDataFrom({ allowIndexing: "on", googleSiteVerification: "<bad>" })),
);
expect(url).toContain("error=");
expect((await getSeoSettings()).googleSiteVerification).toBe("ok");
});
it("redirects unauthenticated users", async () => {
adminAuth.authenticated = false;
const url = await captureRedirect(() => saveSeoSettingsAction(formDataFrom({})));
expect(url).not.toContain("success=");
});
});
+1 -1
View File
@@ -102,7 +102,7 @@ describe("resolveMediaSelection — missing configuration", () => {
describe("resolveMediaSelection — upload mode (filesystem)", () => {
it.skipIf(!canManageUploads)("saves the file and creates an UPLOAD asset", async () => {
const file = new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], "shot.png", { type: "image/png" });
const file = new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])], "shot.png", { type: "image/png" });
const result = await resolveMediaSelection({
media: { mode: "upload", assetId: "", url: "", label: "Shot", kind: "IMAGE" },
uploadFile: file,
+39
View File
@@ -8,6 +8,7 @@ import {
getAdminPortfolioProjects,
getPublishedPortfolioProjectBySlug,
getPublishedPortfolioProjects,
resolvePortfolioSlug,
} from "@/lib/portfolio";
import { eq } from "drizzle-orm";
@@ -124,6 +125,44 @@ describe("published projects", () => {
});
});
describe("resolvePortfolioSlug", () => {
it("resolves an active category slug to a category", async () => {
await createCategory({ slug: "web", isActive: true });
const resolved = await resolvePortfolioSlug("web");
expect(resolved?.kind).toBe("category");
expect(resolved?.kind === "category" && resolved.category.slug).toBe("web");
});
it("resolves a published project slug to a project", async () => {
const cat = await createCategory({ isActive: true });
await createProject({ categoryId: cat.id, slug: "my-project", isPublished: true });
const resolved = await resolvePortfolioSlug("my-project");
expect(resolved?.kind).toBe("project");
expect(resolved?.kind === "project" && resolved.project.slug).toBe("my-project");
});
it("prefers the category when a category and a project share a slug", async () => {
const cat = await createCategory({ slug: "shared", isActive: true });
await createProject({ categoryId: cat.id, slug: "shared", isPublished: true });
const resolved = await resolvePortfolioSlug("shared");
expect(resolved?.kind).toBe("category");
});
it("ignores an inactive category and falls back to a matching project", async () => {
// An inactive category named "hidden" must not shadow a published project "hidden".
await createCategory({ slug: "hidden", isActive: false });
const activeCat = await createCategory({ isActive: true });
await createProject({ categoryId: activeCat.id, slug: "hidden", isPublished: true });
const resolved = await resolvePortfolioSlug("hidden");
expect(resolved?.kind).toBe("project");
expect(resolved?.kind === "project" && resolved.project.slug).toBe("hidden");
});
it("returns null for an unknown slug", async () => {
expect(await resolvePortfolioSlug("does-not-exist")).toBeNull();
});
});
describe("referential integrity", () => {
it("restricts deleting a category that still has projects", async () => {
const cat = await createCategory();
+1 -1
View File
@@ -42,7 +42,7 @@ describe("metadata helpers", () => {
apple: [{ url: "/apple-icon.png?v=v1" }],
});
expect(metadata.openGraph).toMatchObject({
locale: "ar",
locale: "ar_AR",
url: "https://mohfarawati.de/",
});
expect(metadata.twitter).toMatchObject({
+38 -2
View File
@@ -20,7 +20,7 @@ vi.mock("../lib/admin-routing", () => ({
toInternalAdminPath: (pathname: string) => pathname,
}));
function createMockRequest(url: string) {
function createMockRequest(url: string, cookieValue?: string) {
const nextUrl = new URL(url) as URL & { clone: () => URL };
nextUrl.clone = () => new URL(nextUrl.toString());
@@ -31,11 +31,25 @@ function createMockRequest(url: string) {
host: nextUrl.host,
}),
cookies: {
has: vi.fn(() => false),
has: vi.fn(() => cookieValue !== undefined),
get: vi.fn(() => (cookieValue !== undefined ? { name: "moh_admin_session", value: cookieValue } : undefined)),
},
};
}
function stubMaintenanceRuntime() {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
ok: true,
json: async () => ({
defaultLocale: "de",
maintenanceEnabled: true,
}),
})),
);
}
describe("middleware locale runtime config", () => {
beforeEach(() => {
vi.resetModules();
@@ -123,6 +137,28 @@ describe("middleware locale runtime config", () => {
expect(intlHandlerMock).not.toHaveBeenCalled();
});
it("does not let a forged admin cookie bypass maintenance mode", async () => {
vi.stubEnv("ADMIN_AUTH_SECRET", "test-secret");
stubMaintenanceRuntime();
const { default: middleware } = await import("../proxy");
const response = await middleware(createMockRequest("https://example.com/about", "superadmin.forged") as never);
expect(response.headers.get("location")).toBe("https://example.com/coming-soon");
expect(intlHandlerMock).not.toHaveBeenCalled();
});
it("lets a correctly signed admin cookie through maintenance mode", async () => {
vi.stubEnv("ADMIN_AUTH_SECRET", "test-secret");
stubMaintenanceRuntime();
const { buildAdminSessionToken } = await import("../lib/admin-session-token");
const { default: middleware } = await import("../proxy");
await middleware(createMockRequest("https://example.com/about", buildAdminSessionToken()) as never);
expect(intlHandlerMock).toHaveBeenCalledTimes(1);
});
it("prefers the configured internal runtime origin when provided", async () => {
process.env.SITE_RUNTIME_ORIGIN = "http://app:3000";
+59
View File
@@ -9,6 +9,7 @@ import {
import {
assetInputSchema,
categoryInputSchema,
projectDraftInputSchema,
projectInputSchema,
sectionInputSchema,
} from "../lib/portfolio-validation";
@@ -65,6 +66,64 @@ describe("portfolio validation", () => {
).toThrow(/slug/i);
});
it("draft schema accepts empty user-facing copy that a strict save rejects", () => {
const draftPayload = {
categoryId: "cat_1",
slug: "draft-123",
viewMode: "GRID" as const,
titleAr: "",
titleEn: "",
titleDe: "",
summaryAr: "",
summaryEn: "",
summaryDe: "",
clientName: "",
projectYear: 2026,
serviceLabelAr: "",
serviceLabelEn: "",
serviceLabelDe: "",
previewUrl: "",
currentCoverImagePath: "",
sortOrder: 0,
isFeatured: false,
isPublished: false,
sections: [],
assets: [],
};
expect(projectDraftInputSchema.parse(draftPayload).slug).toBe("draft-123");
expect(() => projectInputSchema.parse(draftPayload)).toThrow();
});
it("draft schema still requires a slug and a valid year", () => {
const base = {
categoryId: "cat_1",
slug: "draft-1",
viewMode: "GRID" as const,
titleAr: "",
titleEn: "",
titleDe: "",
summaryAr: "",
summaryEn: "",
summaryDe: "",
clientName: "",
projectYear: 2026,
serviceLabelAr: "",
serviceLabelEn: "",
serviceLabelDe: "",
previewUrl: "",
currentCoverImagePath: "",
sortOrder: 0,
isFeatured: false,
isPublished: false,
sections: [],
assets: [],
};
expect(() => projectDraftInputSchema.parse({ ...base, slug: "" })).toThrow(/slug/i);
expect(() => projectDraftInputSchema.parse({ ...base, projectYear: 1999 })).toThrow();
});
it("accepts valid section and asset payloads", () => {
expect(
sectionInputSchema.parse({
+1 -1
View File
@@ -73,7 +73,7 @@ describe("getAdminNavigation", () => {
const minimal = { ...copy, brandSettings: undefined, localizationSettings: undefined, marquee: undefined, smtp: undefined };
const nav = getAdminNavigation(minimal, "overview");
const siteSettings = nav.find((item) => item.label === "Site Settings");
expect(siteSettings?.children?.map((c) => c.label)).toEqual(["Brand", "Localization"]);
expect(siteSettings?.children?.map((c) => c.label)).toEqual(["Brand", "Localization", "SEO"]);
expect(nav.find((item) => item.href.endsWith("/marquee"))?.label).toBe("Marquee");
expect(nav.find((item) => item.href.endsWith("/smtp"))?.label).toBe("SMTP");
});
+39
View File
@@ -0,0 +1,39 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { buildAdminSessionToken, verifyAdminSessionToken } from "@/lib/admin-session-token";
const originalSecret = process.env.ADMIN_AUTH_SECRET;
beforeEach(() => {
process.env.ADMIN_AUTH_SECRET = "unit-test-secret";
});
afterEach(() => {
process.env.ADMIN_AUTH_SECRET = originalSecret;
});
describe("admin session token", () => {
it("round-trips a signed token", () => {
expect(verifyAdminSessionToken(buildAdminSessionToken())).toBe(true);
});
it("rejects forged, malformed, or missing tokens", () => {
expect(verifyAdminSessionToken("superadmin")).toBe(false);
expect(verifyAdminSessionToken("superadmin.deadbeef")).toBe(false);
expect(verifyAdminSessionToken("other." + buildAdminSessionToken().split(".")[1])).toBe(false);
expect(verifyAdminSessionToken("")).toBe(false);
expect(verifyAdminSessionToken(undefined)).toBe(false);
expect(verifyAdminSessionToken("1")).toBe(false);
});
it("rejects a token signed with a different secret", () => {
const token = buildAdminSessionToken();
process.env.ADMIN_AUTH_SECRET = "rotated";
expect(verifyAdminSessionToken(token)).toBe(false);
});
it("never validates when no secret is configured", () => {
process.env.ADMIN_AUTH_SECRET = "";
expect(verifyAdminSessionToken("superadmin.anything")).toBe(false);
});
});
+37
View File
@@ -7,6 +7,7 @@ import {
MEDIA_UPLOAD_ROOT,
getExtensionForMimeType,
isManagedMediaFilePath,
isMediaContentValid,
removeManagedMediaFile,
resolveMediaUploadPath,
sanitizeBaseName,
@@ -69,6 +70,42 @@ describe("resolveMediaUploadPath", () => {
it("throws when a traversal attempt escapes the root", () => {
expect(() => resolveMediaUploadPath("/uploads/media/../../etc/passwd")).toThrow(/escapes/i);
});
it("rejects a sibling directory that merely shares the root prefix", () => {
// `.../uploads/media-evil` starts with `.../uploads/media` as a string.
expect(() => resolveMediaUploadPath("/uploads/media/../media-evil/x.png")).toThrow(/escapes/i);
});
it("rejects the root itself, empty paths and null bytes", () => {
expect(() => resolveMediaUploadPath("/uploads/media/")).toThrow(/escapes/i);
expect(() => resolveMediaUploadPath("/uploads/media/./")).toThrow(/escapes/i);
expect(() => resolveMediaUploadPath("/uploads/media/a\0.png")).toThrow(/escapes/i);
});
});
describe("isMediaContentValid", () => {
it("accepts files whose magic bytes match the extension", () => {
expect(isMediaContentValid(".png", Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2]))).toBe(true);
expect(isMediaContentValid(".jpg", Buffer.from([0xff, 0xd8, 0xff, 0xe0]))).toBe(true);
expect(isMediaContentValid(".gif", Buffer.from("GIF89a"))).toBe(true);
expect(isMediaContentValid(".pdf", Buffer.from("%PDF-1.7"))).toBe(true);
expect(isMediaContentValid(".webp", Buffer.from("RIFF\0\0\0\0WEBPVP8 "))).toBe(true);
});
it("rejects mismatched bytes (e.g. HTML disguised as an image)", () => {
expect(isMediaContentValid(".png", Buffer.from("<html><script>alert(1)</script>"))).toBe(false);
expect(isMediaContentValid(".jpg", Buffer.from("GIF89a"))).toBe(false);
expect(isMediaContentValid(".exe", Buffer.from("MZ"))).toBe(false);
});
it("accepts plain svg and rejects active content", () => {
expect(isMediaContentValid(".svg", Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>'))).toBe(true);
expect(isMediaContentValid(".svg", Buffer.from('<?xml version="1.0"?>\n<svg><circle/></svg>'))).toBe(true);
expect(isMediaContentValid(".svg", Buffer.from("<svg><script>alert(1)</script></svg>"))).toBe(false);
expect(isMediaContentValid(".svg", Buffer.from('<svg onload="alert(1)"></svg>'))).toBe(false);
expect(isMediaContentValid(".svg", Buffer.from('<svg><a xlink:href="javascript:x"/></svg>'))).toBe(false);
expect(isMediaContentValid(".svg", Buffer.from("<html><svg/></html>"))).toBe(false);
});
});
describe("removeManagedMediaFile", () => {
+5
View File
@@ -25,6 +25,11 @@ describe("mediaFieldInputSchema", () => {
expect(parsed.url).toBe("/uploads/media/x.png");
});
it("rejects protocol-relative and javascript urls", () => {
expect(() => mediaFieldInputSchema.parse({ ...base, mode: "external", url: "//evil.com/x.png" })).toThrow(/URL/i);
expect(() => mediaFieldInputSchema.parse({ ...base, mode: "external", url: "javascript:alert(1)" })).toThrow(/URL/i);
});
it("requires a url in external mode", () => {
expect(() => mediaFieldInputSchema.parse({ ...base, mode: "external" })).toThrow(/URL/i);
});
+106 -1
View File
@@ -1,11 +1,15 @@
import { describe, expect, it } from "vitest";
import { buildDefaultSeoSettings } from "@/lib/seo-settings";
import { buildDefaultSiteSettings } from "@/lib/site-settings";
import {
applyTitleTemplateFn,
buildAppMetadataFromConfig,
buildLocaleAlternates,
buildLocalizedMetadataFromConfig,
buildProjectJsonLd,
buildSiteJsonLd,
serializeJsonLd,
} from "@/lib/metadata";
const noBindings = {
@@ -82,7 +86,7 @@ describe("buildLocalizedMetadataFromConfig", () => {
});
expect(metadata.title).toBe("About | Studio");
expect(metadata.description).toBe("English description");
expect(metadata.openGraph?.locale).toBe("en");
expect(metadata.openGraph?.locale).toBe("en_US");
});
it("can skip the title template (homepage)", () => {
@@ -111,3 +115,104 @@ describe("buildLocalizedMetadataFromConfig", () => {
expect(metadata.description).toBe("Custom desc");
});
});
describe("seo-aware metadata", () => {
const settings = buildDefaultSiteSettings("Studio");
it("indexes by default and emits verification + twitter handle when configured", () => {
const seo = {
...buildDefaultSeoSettings(),
googleSiteVerification: "g123",
bingSiteVerification: "b456",
twitterHandle: "@moh",
};
const metadata = buildAppMetadataFromConfig(settings, noBindings, seo);
expect(metadata.robots).toMatchObject({ index: true, follow: true });
expect(metadata.verification).toEqual({ google: "g123", other: { "msvalidate.01": "b456" } });
expect(metadata.twitter).toMatchObject({ site: "@moh", creator: "@moh" });
expect(metadata.openGraph).toMatchObject({ locale: "de_DE", alternateLocale: ["en_US", "ar_AR"] });
});
it("emits noindex everywhere when indexing is disabled", () => {
const seo = { ...buildDefaultSeoSettings(), allowIndexing: false };
expect(buildAppMetadataFromConfig(settings, noBindings, seo).robots).toMatchObject({ index: false });
const page = buildLocalizedMetadataFromConfig({ settings, bindings: noBindings, seo, locale: "en", pathname: "/about", title: "About" });
expect(page.robots).toMatchObject({ index: false, follow: false });
});
it("supports per-page noindex, article type and a page-specific image", () => {
const published = new Date("2026-01-02T00:00:00Z");
const page = buildLocalizedMetadataFromConfig({
settings,
bindings: { ...noBindings, defaultOgImage: { assetId: "a", url: "/og.png", version: "1" } },
locale: "en",
pathname: "/portfolio/x",
title: "X",
image: "/uploads/media/covers/x.png",
type: "article",
publishedTime: published,
});
expect(page.robots).toMatchObject({ index: true });
expect(page.openGraph).toMatchObject({
type: "article",
publishedTime: published.toISOString(),
images: [{ url: "https://mohfarawati.de/uploads/media/covers/x.png", alt: "X" }],
});
const thanks = buildLocalizedMetadataFromConfig({ settings, bindings: noBindings, locale: "en", pathname: "/success", title: "Thanks", noIndex: true });
expect(thanks.robots).toMatchObject({ index: false });
});
it("falls back to the default og image when no page image is given", () => {
const page = buildLocalizedMetadataFromConfig({
settings,
bindings: { ...noBindings, defaultOgImage: { assetId: "a", url: "/og.png", version: "1" } },
locale: "de",
pathname: "/",
title: "Home",
});
expect(page.openGraph).toMatchObject({ images: [{ url: "https://mohfarawati.de/og.png", alt: "Home" }] });
});
});
describe("json-ld", () => {
const settings = buildDefaultSiteSettings("Studio");
it("builds a WebSite + Person graph linked by @id", () => {
const seo = { ...buildDefaultSeoSettings(), structuredDataName: "Moh", structuredDataJobTitle: "Designer", sameAs: ["https://x.com/moh"] };
const graph = buildSiteJsonLd({ settings, seo, bindings: noBindings, locale: "de" })["@graph"] as Array<Record<string, unknown>>;
expect(graph[0]).toMatchObject({ "@type": "WebSite", publisher: { "@id": "https://mohfarawati.de/#person" } });
expect(graph[1]).toMatchObject({ "@type": "Person", name: "Moh", jobTitle: "Designer", sameAs: ["https://x.com/moh"] });
});
it("uses Organization shape when configured", () => {
const seo = { ...buildDefaultSeoSettings(), structuredDataType: "Organization" as const, structuredDataJobTitle: "Studio" };
const graph = buildSiteJsonLd({ settings, seo, bindings: noBindings, locale: "en" })["@graph"] as Array<Record<string, unknown>>;
expect(graph[1]).toMatchObject({ "@type": "Organization", slogan: "Studio" });
});
it("builds a CreativeWork per project with localized url", () => {
const work = buildProjectJsonLd({
settings,
seo: buildDefaultSeoSettings(),
locale: "en",
pathname: "/portfolio/x",
title: "X",
description: "D",
image: "/c.png",
datePublished: new Date("2026-01-01T00:00:00Z"),
clientName: "ACME",
});
expect(work).toMatchObject({
"@type": "CreativeWork",
url: "https://mohfarawati.de/en/portfolio/x",
image: "https://mohfarawati.de/c.png",
sourceOrganization: { "@type": "Organization", name: "ACME" },
author: { "@id": "https://mohfarawati.de/#person" },
});
});
it("escapes < so the payload cannot close the script tag", () => {
expect(serializeJsonLd({ name: "</script><script>alert(1)</script>" })).not.toContain("</script>");
});
});
+22 -3
View File
@@ -117,10 +117,29 @@ describe("getPortfolioWizardProgress", () => {
expect(getFirstIncompleteWizardStep(progress)).toBe("content");
});
it("sections/assets steps require at least one ready entry", () => {
it("treats sections/assets as optional: empty steps are complete and the project is saveable", () => {
const noEntries = getPortfolioWizardProgress({ ...completeInput, sections: [], assets: [] });
expect(noEntries.find((s) => s.key === "sections")?.complete).toBe(false);
expect(noEntries.find((s) => s.key === "assets")?.complete).toBe(false);
expect(noEntries.find((s) => s.key === "sections")?.complete).toBe(true);
expect(noEntries.find((s) => s.key === "assets")?.complete).toBe(true);
// Basics + content only is enough to save.
expect(getFirstIncompleteWizardStep(noEntries)).toBeNull();
});
it("shows an optional summary when no sections/assets are added", () => {
const noEntries = getPortfolioWizardProgress({ ...completeInput, sections: [], assets: [] });
expect(noEntries.find((s) => s.key === "sections")?.summary).toBe("Optional — no sections added.");
expect(noEntries.find((s) => s.key === "assets")?.summary).toBe(
"Optional — no gallery assets added.",
);
});
it("blocks saving when an added section is incomplete", () => {
const progress = getPortfolioWizardProgress({
...completeInput,
sections: [section({ titleEn: "" })],
});
expect(progress.find((s) => s.key === "sections")?.complete).toBe(false);
expect(getFirstIncompleteWizardStep(progress)).toBe("sections");
});
it("summarizes section/asset readiness counts", () => {
+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");
});
});