Compare commits

..
1 Commits
Author SHA1 Message Date
Moh ba63f75ea8 REFACTORED - Replace Prisma with Drizzle ORM
CI / quality (push) Canceled after 0s
- Add lib/db (schema, postgres.js client, enums, seed, migrations) on Drizzle
- Rewrite all lib and admin action queries from Prisma to Drizzle
- Keep existing table/column names so no data migration is needed
- Preserve signed-cookie admin auth unchanged
- Map unique-violation handling from Prisma P2002 to SQLSTATE 23505
- Swap deps, scripts, Makefile, and Dockerfile from Prisma to Drizzle
2026-08-08 02:09:03 +02:00
154 changed files with 3028 additions and 12100 deletions
+1 -1
View File
@@ -39,7 +39,7 @@ For new features, architecture changes, domain rule changes, or public behavior
Update documentation only when implementation changes affect: Update documentation only when implementation changes affect:
- feature scope - feature scope
- business rules - business rules
- architecture - architectureڑڑ
- public behavior - public behavior
Do not update documentation for small isolated fixes or purely visual changes. Do not update documentation for small isolated fixes or purely visual changes.
+1 -1
View File
@@ -5,7 +5,7 @@
"name": "dev", "name": "dev",
"runtimeExecutable": "npm", "runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"], "runtimeArgs": ["run", "dev"],
"port": 3014 "port": 3000
} }
] ]
} }
+4 -26
View File
@@ -1,31 +1,9 @@
# Local dev: app runs on host (npm run dev), only Postgres runs in Docker. DATABASE_URL="postgresql://USER:PASSWORD@HOST:5432/moh_sass?schema=public"
# Production: docker-compose.yml runs the full stack (deploy via `make deploy`). NEXT_PUBLIC_APP_URL="https://mohfarawati.de"
NEXT_PUBLIC_SITE_URL="https://mohfarawati.de"
# --- Database --- NEXT_PUBLIC_ADMIN_URL="https://root.mohfarawati.de"
# Local: localhost:5432 (exposed from Docker). Server: db:5432 (Compose internal).
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/moh_sass"
# --- URLs ---
NEXT_PUBLIC_APP_URL="http://localhost:3014"
NEXT_PUBLIC_SITE_URL="http://localhost:3014"
NEXT_PUBLIC_ADMIN_URL="http://rootmohfarawati.localhost:3014"
ADMIN_HOST="rootmohfarawati.localhost"
NEXT_TELEMETRY_DISABLED="1" NEXT_TELEMETRY_DISABLED="1"
# --- Admin auth ---
ADMIN_PASSWORD="change-me" ADMIN_PASSWORD="change-me"
ADMIN_AUTH_SECRET="replace-with-a-long-random-secret" ADMIN_AUTH_SECRET="replace-with-a-long-random-secret"
ADMIN_BASIC_AUTH_USER="change-me" ADMIN_BASIC_AUTH_USER="change-me"
ADMIN_BASIC_AUTH_PASS="change-me" ADMIN_BASIC_AUTH_PASS="change-me"
# --- Production only (server .env) ---
# The full stack runs via docker-compose.yml behind Traefik. On the server set:
# NEXT_PUBLIC_SITE_URL="https://mohfarawati.de"
# NEXT_PUBLIC_ADMIN_URL="https://root.mohfarawati.de"
# ADMIN_HOST="root.mohfarawati.de"
# Traefik wiring (override only if your server differs from the defaults):
# TRAEFIK_NETWORK="proxy" # the external network Traefik is on
# TRAEFIK_ENTRYPOINT="websecure" # HTTPS entrypoint name
# TRAEFIK_CERTRESOLVER="cf" # cert resolver that covers *.mohfarawati.de
# DATABASE_URL is set by docker-compose.yml to the internal db service — do not set it here.
-52
View File
@@ -1,52 +0,0 @@
#!/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
-21
View File
@@ -1,21 +0,0 @@
#!/usr/bin/env bash
#
# Pre-push test gate. The deploy server only ever PULLS, so the real place to
# stop broken code is right here — before anything leaves this machine.
#
# The suite needs no external database: integration tests spin up an in-process
# PGlite database per worker (see tests/helpers/integration-setup.ts), so
# `npm test` runs standalone. A real failure blocks the push; the run ends with a
# compact copy-pasteable summary (scripts/test-summary.mjs).
#
# Install once: git config core.hooksPath .githooks
# Emergency skip: git push --no-verify
#
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
echo "▶ pre-push: running the full test suite…"
node scripts/test-summary.mjs
echo "✓ all tests green — pushing."
+1 -6
View File
@@ -8,7 +8,7 @@ jobs:
quality: quality:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/moh_sass DATABASE_URL: postgresql://postgres:postgres@localhost:5432/moh_sass?schema=public
NEXT_TELEMETRY_DISABLED: "1" NEXT_TELEMETRY_DISABLED: "1"
steps: steps:
- name: Checkout - name: Checkout
@@ -29,10 +29,5 @@ jobs:
- name: Lint - name: Lint
run: npm run lint run: npm run lint
- name: Test
# Integration tests use an in-process PGlite database, so no service
# container is needed — the suite runs standalone.
run: npm test
- name: Build - name: Build
run: npm run build run: npm run build
+22 -57
View File
@@ -12,30 +12,29 @@ npm run lint # Run ESLint
npm run test # Run all tests with Vitest npm run test # Run all tests with Vitest
npx vitest run tests/some-file.test.ts # Run a single test file npx vitest run tests/some-file.test.ts # Run a single test file
# Database (Drizzle ORM + drizzle-kit) # Database
npm run db:generate # Generate a migration from schema changes npm run prisma:generate # Regenerate Prisma client after schema changes
npm run db:migrate # Apply migrations npm run db:migrate # Apply migrations (production)
npm run db:push # Push schema to DB directly (dev shortcut, no migration) npm run db:migrate:dev # Create and apply dev migration
npm run db:studio # Open Drizzle Studio to browse the database npm run db:seed # Seed the database
``` ```
### Docker / task runner (see Makefile) ### Docker (production/staging)
```bash ```bash
make start # Start DB container + dev server (http://localhost:3014) make start # Build and start all containers
make stop # Stop containers make stop # Stop containers
make db-up # Start only the database container make deploy # Pull + rebuild + restart
make migrate # Apply Drizzle migrations make logs # Follow container logs
make studio # Open Drizzle Studio make db-init # Generate client, apply migrations, and seed (first run)
make psql # Open a psql shell on the database make db-shell # Open psql shell
make test # Run the whole test suite + copy-paste summary make app-shell # Open shell in app container
make deploy # (on server) Pull + rebuild + restart
make health # Hit /api/health via public URL make health # Hit /api/health via public URL
``` ```
## Architecture ## Architecture
This is a multilingual Next.js (App Router) portfolio site with an admin workspace. Stack: TypeScript, next-intl, Drizzle ORM + PostgreSQL, Tailwind CSS, Radix UI, framer-motion, nodemailer. This is a multilingual Next.js (App Router) portfolio site with an admin workspace. Stack: TypeScript, next-intl, Prisma + PostgreSQL, Tailwind CSS, Radix UI, framer-motion, nodemailer.
### Routing overview ### Routing overview
@@ -47,7 +46,7 @@ Localized routes for `de`, `en`, `ar`. Default locale is dynamic (stored in `App
**Admin workspace**`app/_admin/` (canonical source) **Admin workspace**`app/_admin/` (canonical source)
Accessed via a dedicated subdomain (`root.mohfarawati.de`) in production, or via the `/root` path prefix in development. The middleware rewrites both to `app/admin-internal/`. The `app/root/` and `app/admin-internal/` directories mirror `app/_admin/` — treat `app/_admin/` as the source of truth. Accessed via a dedicated subdomain (`root.mohfarawati.de`) in production, or via the `/root` path prefix in development. The middleware rewrites both to `app/admin-internal/`. The `app/root/` and `app/admin-internal/` directories mirror `app/_admin/` — treat `app/_admin/` as the source of truth.
The full routing rewrite logic lives in `lib/admin-routing.ts` and `proxy.ts` (the Next.js 16 middleware entry point — this project has no `middleware.ts`). The full routing rewrite logic lives in `lib/admin-routing.ts` and `middleware.ts`.
### i18n ### i18n
@@ -59,7 +58,7 @@ The full routing rewrite logic lives in `lib/admin-routing.ts` and `proxy.ts` (t
### Persistence ### Persistence
The Drizzle client is in `lib/db/index.ts` (postgres.js driver); the schema is in `lib/db/schema.ts` and DB enums in `lib/db/enums.ts`. All DB access must go through server-side modules in `lib/`. Client components must never access the database. Prisma client is in `lib/prisma.ts`. All DB access must go through server-side modules in `lib/`. Client components must never access Prisma.
`AppConfig` is a key-value table used for all runtime configuration: site settings, SMTP, marquee, maintenance mode, default locale. `lib/app-config.ts` is the aggregate entry point; individual settings are in `lib/site-settings.ts`, `lib/mail-settings.ts`, `lib/marquee-settings.ts`. `AppConfig` is a key-value table used for all runtime configuration: site settings, SMTP, marquee, maintenance mode, default locale. `lib/app-config.ts` is the aggregate entry point; individual settings are in `lib/site-settings.ts`, `lib/mail-settings.ts`, `lib/marquee-settings.ts`.
@@ -78,22 +77,18 @@ The Drizzle client is in `lib/db/index.ts` (postgres.js driver); the schema is i
|---|---| |---|---|
| i18n routing | `i18n/routing.ts` | | i18n routing | `i18n/routing.ts` |
| Admin routing logic | `lib/admin-routing.ts` | | Admin routing logic | `lib/admin-routing.ts` |
| Middleware (routing + auth) | `proxy.ts` | | Middleware (routing + auth) | `middleware.ts` |
| DB client (Drizzle) | `lib/db/index.ts` | | Prisma client | `lib/prisma.ts` |
| DB schema | `lib/db/schema.ts` |
| AppConfig aggregate | `lib/app-config.ts` | | AppConfig aggregate | `lib/app-config.ts` |
| Portfolio queries | `lib/portfolio.ts` | | Portfolio queries | `lib/portfolio.ts` |
| Media handling | `lib/media.ts`, `lib/media-storage.ts` | | Media handling | `lib/media.ts` |
| Contact flow | `lib/mail.ts` | | Contact flow | `lib/mail.ts` |
| SEO (metadata, robots, sitemap, JSON-LD) | `lib/metadata.ts`, `lib/seo-settings.ts`, `app/robots.ts`, `app/sitemap.ts` — see `docs/SEO.md` |
| Admin session token (middleware + auth) | `lib/admin-session-token.ts` |
### Documentation to read by task scope ### Documentation to read by task scope
- **Small UI/copy/style fixes**: read only the relevant files - **Small UI/copy/style fixes**: read only the relevant files
- **Feature changes**: read `specs/<feature>.md` + `docs/ARCHITECTURE.md` if structure is affected - **Feature changes**: read `specs/<feature>.md` + `docs/ARCHITECTURE.md` if structure is affected
- **Cross-cutting/architecture changes**: read `docs/ARCHITECTURE.md`, `docs/DOMAIN_RULES.md`, `docs/FEATURES.md`, and the relevant `specs/` file - **Cross-cutting/architecture changes**: read `docs/ARCHITECTURE.md`, `docs/DOMAIN_RULES.md`, `docs/FEATURES.md`, and the relevant `specs/` file
- **SEO / metadata / robots / sitemap**: read `docs/SEO.md` first
Update `docs/` and `specs/` only when the change affects feature scope, business rules, architecture, or public behavior. Update `docs/` and `specs/` only when the change affects feature scope, business rules, architecture, or public behavior.
@@ -115,46 +110,16 @@ SITE_RUNTIME_ORIGIN Internal origin for middleware to fetch runtime state
## Working rules ## Working rules
- **Tests are mandatory for every logic change, in the SAME change.** New behaviour → new tests covering the intent (positive **and** negative cases), not one happy example. Deliberate change → update the affected tests and say which/why. A test that fails unexpectedly is a real bug → fix the code, not the test. Test the real thing — integration tests use a real (in-process PGlite) database, so do NOT mock our own `lib/`/DB layer; only true external boundaries (the admin session, third-party APIs, SMTP) may be substituted. Keep the suite green (`make test`); the `pre-push` hook (`.githooks/pre-push`) enforces it. Frontend/UI is verified manually; add component tests only for components with real logic.
- Before making any change, first explain the plan briefly and list the files that will be touched. - Before making any change, first explain the plan briefly and list the files that will be touched.
- Make the smallest safe change that solves the task. - Make the smallest safe change that solves the task.
- Do not modify unrelated files. - Do not modify unrelated files.
- Preserve existing architecture, naming, and folder conventions. - Preserve existing architecture, naming, and folder conventions.
- Prefer server-side logic in `lib/*` and keep business logic out of UI components. - Prefer server-side logic in `lib/*` and keep business logic out of UI components.
- Never access the database (Drizzle) from client components. - Never access Prisma from client components.
- For admin-related changes, treat `app/_admin/` as the canonical source of truth unless explicitly told otherwise. - For admin-related changes, treat `app/_admin/` as the canonical source of truth unless explicitly told otherwise.
- Do not add new dependencies unless absolutely necessary and explicitly justified. - Do not add new dependencies unless absolutely necessary and explicitly justified.
- After code changes, run only the minimum relevant checks (for example: targeted test, lint on changed files, or build if necessary). - After code changes, run only the minimum relevant checks (for example: targeted test, lint on changed files, or build if necessary).
- If a task may affect routing, auth, i18n, or runtime config, inspect `proxy.ts`, `lib/admin-routing.ts`, `i18n/routing.ts`, and the relevant `lib/app-config.ts` modules first. - If a task may affect routing, auth, i18n, or runtime config, inspect `middleware.ts`, `lib/admin-routing.ts`, `i18n/routing.ts`, and the relevant `lib/app-config.ts` modules first.
- For schema or database changes, inspect the Drizzle schema (`lib/db/schema.ts`), the drizzle-kit migration flow, and migration impact before editing. - For schema or database changes, inspect Prisma schema, migration flow, and seed impact before editing.
- Ask before performing large refactors, file moves, destructive changes, or broad formatting changes. - Ask before performing large refactors, file moves, destructive changes, or broad formatting changes.
- When updating behavior, also update docs/specs if the change affects public behavior, business rules, or architecture. - When updating behavior, also update docs/specs if the change affects public behavior, business rules, or architecture.
## Git commit messages (MANDATORY)
Every commit subject **must** follow this exact style, or the `commit-msg` hook
(`.githooks/commit-msg`) will reject the commit:
```
VERB - Short description
```
- `VERB` is an ALL-CAPS verb, at least 3 letters — e.g. `ADDED`, `FIXED`,
`STYLED`, `REMOVED`, `RENAMED`, `REFACTORED`, `IMPROVED`, `DOCUMENTED`,
`CLEANED`, `REVERTED`, `CONFIGURED`, `POLISHED`.
- Then exactly `" - "` (space, hyphen, space).
- Then a capitalized, imperative description with no trailing period.
Good: `FIXED - Load .env in drizzle.config so drizzle-kit targets the right DB`
Bad (rejected): `Flatten portfolio category routes` — no `VERB` prefix.
Bad (rejected): `feat(hero): add backdrop` — Conventional Commits is NOT used here.
Do **not** add any `Co-Authored-By` / "Generated with Claude Code" attribution
lines to commits in this repo. Write the body (when useful) as wrapped prose or
bullet points explaining the *why*, matching the existing history.
The hook (and the `pre-push` test gate) live in `.githooks/` and are activated
per clone via `core.hooksPath`. You don't need to remember this: the `prepare`
npm script runs `git config core.hooksPath .githooks` automatically on every
`npm install`. The deploy server does not need it — it only pulls, never commits
or pushes, so no hook ever fires there. Emergency skip: `git commit --no-verify`.
+56 -91
View File
@@ -1,109 +1,74 @@
# mohfarawati.de — task runner. .PHONY: start stop restart deploy logs build ps port health clean-orphans app-shell db-shell db-init db-generate db-migrate db-push db-seed help
# Local dev = DB in Docker, app on host (`npm run dev`).
# Production = docker-compose.yml runs the full stack on the server.
.DEFAULT_GOAL := help
.PHONY: help install start stop restart db-up db-down \
migrate generate db-generate db-push studio psql \
build deploy deploy-logs deploy-down \
test test-watch \
health help
## --- Help ------------------------------------------------------------------
help:
@echo "Local development (DB in Docker, app on host):"
@echo " make start Start DB + dev server (http://localhost:3014)"
@echo " make stop Stop the database container"
@echo " make restart Restart DB + dev server"
@echo " make install Install host dependencies"
@echo ""
@echo "Database:"
@echo " make db-up Start only the database container"
@echo " make db-down Stop the database container"
@echo " make migrate Apply Drizzle migrations"
@echo " make db-generate Generate a migration from schema changes"
@echo " make db-push Push schema to DB (dev shortcut, no migration)"
@echo " make studio Open Drizzle Studio to browse the database"
@echo " make psql Open a psql shell on the database"
@echo ""
@echo "Quality:"
@echo " make build Production build"
@echo " make test Run the whole test suite + copy-paste summary"
@echo " make test-watch Run the test suite in watch mode"
@echo ""
@echo "Deploy (run on the SERVER, with .env filled in):"
@echo " make deploy Pull latest, rebuild, restart"
@echo " make deploy-logs Follow the app logs"
@echo " make deploy-down Stop the production stack"
@echo " make health Check app health endpoint via public domain"
## --- Local development (DB in Docker, app on host) -------------------------
install:
npm install
start: start:
-@docker compose stop app 2>/dev/null || true docker compose up -d --build
docker compose up -d db
@for _ in $$(seq 1 20); do docker compose exec -T db pg_isready -U postgres >/dev/null 2>&1 && break; sleep 1; done
npm run dev
stop: stop:
docker compose down docker compose down
restart: stop start restart: stop start
## --- Database --------------------------------------------------------------
db-up:
docker compose up -d db
db-down:
docker compose down
migrate:
npm run db:migrate
generate: db-generate ## Alias for db-generate
db-generate:
npm run db:generate
db-push:
npm run db:push
studio:
npm run db:studio
psql:
docker compose exec db psql -U postgres -d moh_sass
## --- Quality ---------------------------------------------------------------
build:
npm run build
test:
@node scripts/test-summary.mjs
test-watch:
npm run test:watch
## --- Deploy (server) -------------------------------------------------------
# Run these ON THE SERVER, inside the repo. docker-compose.yml is the full stack
# (app + db); the server does `git pull` + `make deploy`.
deploy: deploy:
git pull git pull
docker compose up -d --build docker compose up -d --build
@git log -1 --oneline @git log -1 --oneline
deploy-logs: logs:
docker compose logs -f --tail=200 docker compose logs -f --tail=200
deploy-down: build:
docker compose down docker compose build
ps:
docker compose ps
port:
@echo "Site: http://localhost:3014"
@echo "Admin: http://rootmohfarawati.localhost:3014"
clean-orphans:
docker compose up -d --remove-orphans
app-shell:
docker compose exec app sh
db-shell:
docker compose exec db psql -U postgres -d moh_sass
db-init:
docker compose exec app sh -lc "npm run db:migrate && npm run db:seed"
db-generate:
docker compose exec app npm run db:generate
db-migrate:
docker compose exec app npm run db:migrate
db-push:
docker compose exec app npm run db:push
db-seed:
docker compose exec app npm run db:seed
health: health:
curl -sS https://mohfarawati.de/api/health curl -sS https://mohfarawati.de/api/health
help:
@echo "Available targets:"
@echo " make start Start all containers"
@echo " make stop Stop and remove containers"
@echo " make restart Restart all containers"
@echo " make deploy Pull latest code and deploy updated stack"
@echo " make logs Follow container logs"
@echo " make build Build images"
@echo " make ps Show container status"
@echo " make port Show app public URL"
@echo " make clean-orphans Remove orphaned old containers"
@echo " make app-shell Open shell in app container"
@echo " make db-shell Open PostgreSQL shell"
@echo " make db-init Apply migrations and run seed (first run)"
@echo " make db-generate Generate a Drizzle migration from schema changes"
@echo " make db-migrate Apply pending Drizzle migrations"
@echo " make db-push Push schema directly (dev convenience)"
@echo " make db-seed Seed database data"
@echo " make health Check app health endpoint via public domain"
+5 -99
View File
@@ -3,19 +3,9 @@ import { getLocale, getTranslations } from "next-intl/server";
import { Container } from "@/components/layout/container"; import { Container } from "@/components/layout/container";
import { PageHero } from "@/components/layout/page-hero"; import { PageHero } from "@/components/layout/page-hero";
import { MotionFade } from "@/components/motion-fade";
import { AppCard } from "@/components/ui/app-card"; import { AppCard } from "@/components/ui/app-card";
import { Badge } from "@/components/ui/badge";
import { CapabilitiesSection } from "@/components/home/capabilities-section";
import { ProcessSection } from "@/components/home/process-section";
import { ContactCtaSection } from "@/components/home/contact-cta-section";
import type {
CapabilitiesSectionCopy,
ContactCtaSectionCopy,
ProcessSectionCopy,
} from "@/components/home/types";
import { getSiteSettings } from "@/lib/app-config"; import { getSiteSettings } from "@/lib/app-config";
import { getLocalizedPath, resolveLocale } from "@/lib/locale"; import { resolveLocale } from "@/lib/locale";
import { buildLocalizedMetadata } from "@/lib/metadata"; import { buildLocalizedMetadata } from "@/lib/metadata";
type AboutPageProps = { type AboutPageProps = {
@@ -46,38 +36,6 @@ export default async function AboutPage({ params }: AboutPageProps) {
const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale); const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale);
const t = await getTranslations({ locale: localeKey, namespace: "aboutPage" }); const t = await getTranslations({ locale: localeKey, namespace: "aboutPage" });
const contactHref = getLocalizedPath(localeKey, "/contact", siteSettings.defaultLocale);
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"),
description: t("contactCta.description"),
contactCta: t("contactCta.contactCta"),
githubCta: t("contactCta.githubCta"),
emailLabel: t("contactCta.emailLabel"),
emailValue: t("contactCta.emailValue"),
availabilityLabel: t("contactCta.availabilityLabel"),
availabilityValue: t("contactCta.availabilityValue"),
githubHref: t("contactCta.githubHref"),
};
const storyParagraphs = t.raw("story.paragraphs") as string[];
const toolItems = t.raw("tools.items") as string[];
return ( return (
<> <>
<PageHero <PageHero
@@ -87,62 +45,10 @@ export default async function AboutPage({ params }: AboutPageProps) {
description={t("description")} description={t("description")}
/> />
<Container className="flex flex-col gap-16 pb-16 sm:gap-20 lg:gap-24 lg:pb-20"> <Container className="pb-16 lg:pb-20">
<MotionFade> <AppCard level={3} padding="lg" className="mx-auto max-w-3xl text-center">
<section className="mx-auto max-w-3xl space-y-6"> <p className="text-lg font-medium text-foreground">{t("placeholder")}</p>
<p className="eyebrow text-caption font-medium text-brand-primary"> </AppCard>
{t("story.eyebrow")}
</p>
<h2 className="text-balance text-h2 text-foreground">{t("story.title")}</h2>
<div className="space-y-4">
{storyParagraphs.map((paragraph) => (
<p key={paragraph} className="text-body-lg text-muted-foreground">
{paragraph}
</p>
))}
</div>
</section>
</MotionFade>
<MotionFade delay={0.06}>
<CapabilitiesSection copy={capabilitiesCopy} />
</MotionFade>
<MotionFade delay={0.08}>
<AppCard padding="lg">
<div className="space-y-5">
<div className="space-y-2">
<p className="eyebrow text-caption font-medium text-brand-primary">
{t("tools.eyebrow")}
</p>
<h3 className="text-title font-semibold text-foreground">{t("tools.title")}</h3>
</div>
<div className="flex flex-wrap gap-2">
{toolItems.map((tool) => (
<Badge key={tool} variant="secondary">
{tool}
</Badge>
))}
</div>
</div>
</AppCard>
</MotionFade>
<MotionFade delay={0.1}>
<ProcessSection copy={processCopy} />
</MotionFade>
<MotionFade delay={0.12}>
<p className="mx-auto max-w-2xl text-center text-small text-muted-foreground">
<span className="text-brand-primary">{t("personalNote.eyebrow")}</span>
{" — "}
{t("personalNote.text")}
</p>
</MotionFade>
<MotionFade delay={0.14}>
<ContactCtaSection copy={contactCtaCopy} contactHref={contactHref} />
</MotionFade>
</Container> </Container>
</> </>
); );
+12 -14
View File
@@ -4,15 +4,12 @@ import { getLocale } from "next-intl/server";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { SiteAmbientBackdrop } from "@/components/layout/site-ambient-backdrop"; import { SiteAmbientBackdrop } from "@/components/layout/site-ambient-backdrop";
import { PageTransition } from "@/components/layout/page-transition";
import { SiteDock } from "@/components/layout/site-dock";
import { SiteFooter } from "@/components/layout/site-footer"; import { SiteFooter } from "@/components/layout/site-footer";
import { SiteHeader } from "@/components/layout/site-header";
import { ScrollSmootherProvider } from "@/components/scroll-smoother-provider"; import { ScrollSmootherProvider } from "@/components/scroll-smoother-provider";
import { JsonLd } from "@/components/seo/json-ld";
import { isSuperAdmin } from "@/lib/admin-auth"; import { isSuperAdmin } from "@/lib/admin-auth";
import { getMaintenanceMode, getSeoSettings, getSiteSettings, getSiteSettingsMediaBindings } from "@/lib/app-config"; import { getMaintenanceMode, getSiteSettings, getSiteSettingsMediaBindings } from "@/lib/app-config";
import { getLocalizedPath, resolveLocale } from "@/lib/locale"; import { getLocalizedPath, resolveLocale } from "@/lib/locale";
import { buildSiteJsonLd } from "@/lib/metadata";
type SiteLayoutProps = { type SiteLayoutProps = {
children: ReactNode; children: ReactNode;
@@ -27,14 +24,13 @@ export const revalidate = 0;
export default async function SiteLayout({ children, params }: SiteLayoutProps) { export default async function SiteLayout({ children, params }: SiteLayoutProps) {
noStore(); noStore();
await params; await params;
const [maintenanceEnabled, siteSettings, seo, mediaBindings] = await Promise.all([ const [maintenanceEnabled, mediaBindings, siteSettings] = await Promise.all([
getMaintenanceMode(), getMaintenanceMode(),
getSiteSettings(),
getSeoSettings(),
getSiteSettingsMediaBindings(), getSiteSettingsMediaBindings(),
getSiteSettings(),
]); ]);
const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale); const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale);
// Server-side decision only. The dock receives just this boolean and uses // Server-side decision only. The header receives just this boolean and uses
// it purely to show/hide the Admin shortcut link — it grants no access by // it purely to show/hide the Admin shortcut link — it grants no access by
// itself. Every admin page/action re-checks the session independently, so // itself. Every admin page/action re-checks the session independently, so
// this value is not a security boundary, only a UI convenience. // this value is not a security boundary, only a UI convenience.
@@ -46,16 +42,18 @@ export default async function SiteLayout({ children, params }: SiteLayoutProps)
return ( return (
<> <>
<JsonLd data={buildSiteJsonLd({ settings: siteSettings, seo, bindings: mediaBindings, locale: localeKey })} />
<ScrollSmootherProvider /> <ScrollSmootherProvider />
<SiteAmbientBackdrop /> <SiteAmbientBackdrop />
<SiteDock defaultLocale={siteSettings.defaultLocale} isSuperAdmin={authenticated} /> <SiteHeader
lightLogoUrl={mediaBindings.siteLogoLight?.url}
darkLogoUrl={mediaBindings.siteLogoDark?.url}
defaultLocale={siteSettings.defaultLocale}
isSuperAdmin={authenticated}
/>
<div id="smooth-wrapper"> <div id="smooth-wrapper">
<div id="smooth-content"> <div id="smooth-content">
<div className="site-content-frame flex min-h-screen flex-col"> <div className="site-content-frame flex min-h-screen flex-col">
<main className="flex-1"> <main className="flex-1">{children}</main>
<PageTransition>{children}</PageTransition>
</main>
<SiteFooter defaultLocale={siteSettings.defaultLocale} /> <SiteFooter defaultLocale={siteSettings.defaultLocale} />
</div> </div>
</div> </div>
+86 -1
View File
@@ -5,11 +5,18 @@ import { Container } from "@/components/layout/container";
import { HomeHero } from "@/components/layout/home-hero"; import { HomeHero } from "@/components/layout/home-hero";
import { MotionFade } from "@/components/motion-fade"; import { MotionFade } from "@/components/motion-fade";
import { MagicBentoSection } from "@/components/home/magic-bento-section"; import { MagicBentoSection } from "@/components/home/magic-bento-section";
import { CapabilitiesSection } from "@/components/home/capabilities-section";
import { ContactCtaSection } from "@/components/home/contact-cta-section"; import { ContactCtaSection } from "@/components/home/contact-cta-section";
import { MarqueeSection } from "@/components/home/marquee-section"; import { MarqueeSection } from "@/components/home/marquee-section";
import { ProcessSection } from "@/components/home/process-section";
import { ProjectsSection } from "@/components/home/projects-section";
import type { import type {
CapabilitiesSectionCopy,
ContactCtaSectionCopy, ContactCtaSectionCopy,
MarqueeSectionCopy, MarqueeSectionCopy,
ProcessSectionCopy,
ProjectCardCopy,
ProjectsSectionCopy,
} from "@/components/home/types"; } from "@/components/home/types";
import { buildLocalizedMetadata } from "@/lib/metadata"; import { buildLocalizedMetadata } from "@/lib/metadata";
import { getLocalizedPath, resolveLocale } from "@/lib/locale"; import { getLocalizedPath, resolveLocale } from "@/lib/locale";
@@ -18,6 +25,10 @@ import {
getSiteSettings, getSiteSettings,
splitMarqueeRowItems, splitMarqueeRowItems,
} from "@/lib/app-config"; } from "@/lib/app-config";
import {
getLocalizedValue,
getPublishedPortfolioProjects,
} from "@/lib/portfolio";
import { type MarqueeRow } from "@/components/layout/stacked-marquee-section"; import { type MarqueeRow } from "@/components/layout/stacked-marquee-section";
type HomePageProps = { type HomePageProps = {
@@ -54,9 +65,10 @@ export default async function HomePage({ params }: HomePageProps) {
siteSettings.defaultLocale, siteSettings.defaultLocale,
); );
const [t, marqueeSettings] = await Promise.all([ const [t, marqueeSettings, publishedProjects] = await Promise.all([
getTranslations({ locale: localeKey, namespace: "homepage" }), getTranslations({ locale: localeKey, namespace: "homepage" }),
getMarqueeSettings(), getMarqueeSettings(),
getPublishedPortfolioProjects(),
]); ]);
const bentoCards = [ const bentoCards = [
@@ -100,6 +112,28 @@ export default async function HomePage({ params }: HomePageProps) {
description: t("marquee.description"), description: t("marquee.description"),
}; };
const projectsCopy: ProjectsSectionCopy = {
eyebrow: t("projects.eyebrow"),
title: t("projects.title"),
description: t("projects.description"),
stackLabel: t("projects.stackLabel"),
fallbackItems: t.raw("projects.fallbackItems") as ProjectsSectionCopy["fallbackItems"],
};
const capabilitiesCopy: CapabilitiesSectionCopy = {
eyebrow: t("capabilities.eyebrow"),
title: t("capabilities.title"),
description: t("capabilities.description"),
items: t.raw("capabilities.items") as CapabilitiesSectionCopy["items"],
};
const processCopy: ProcessSectionCopy = {
eyebrow: t("process.eyebrow"),
title: t("process.title"),
description: t("process.description"),
steps: t.raw("process.steps") as ProcessSectionCopy["steps"],
};
const contactCtaCopy: ContactCtaSectionCopy = { const contactCtaCopy: ContactCtaSectionCopy = {
eyebrow: t("contactCta.eyebrow"), eyebrow: t("contactCta.eyebrow"),
title: t("contactCta.title"), title: t("contactCta.title"),
@@ -123,6 +157,45 @@ export default async function HomePage({ params }: HomePageProps) {
const portfolioHref = getLocalizedPath(localeKey, "/portfolio", siteSettings.defaultLocale); const portfolioHref = getLocalizedPath(localeKey, "/portfolio", siteSettings.defaultLocale);
const contactHref = getLocalizedPath(localeKey, "/contact", siteSettings.defaultLocale); const contactHref = getLocalizedPath(localeKey, "/contact", siteSettings.defaultLocale);
const stackSets = t.raw("projects.stackSets") as string[][];
const selectedProjects = [
...publishedProjects.filter((project) => project.isFeatured),
...publishedProjects.filter((project) => !project.isFeatured),
].slice(0, 3);
const projectCards: ProjectCardCopy[] = selectedProjects.map((project, index) => ({
title: getLocalizedValue(project.title, localeKey),
summary: getLocalizedValue(project.summary, localeKey),
stack: stackSets[index] ?? stackSets[stackSets.length - 1] ?? [],
href: getLocalizedPath(
localeKey,
`/portfolio/${project.slug}`,
siteSettings.defaultLocale,
),
cta: t("projects.cta"),
meta: [
getLocalizedValue(project.category.name, localeKey),
getLocalizedValue(project.serviceLabel, localeKey),
String(project.projectYear),
],
featured: index === 0,
coverImagePath: project.coverImagePath,
}));
const fallbackProjectCards: ProjectCardCopy[] = projectsCopy.fallbackItems.map((item) => ({
title: item.title,
summary: item.summary,
stack: item.stack,
href: item.href === "/portfolio" ? portfolioHref : item.href === "/contact" ? contactHref : item.href,
cta: t("projects.cta"),
meta: [t("projects.fallbackMeta")],
}));
const mergedProjectCards = [
...projectCards,
...fallbackProjectCards.slice(0, Math.max(0, 3 - projectCards.length)),
].slice(0, 3);
return ( return (
<> <>
@@ -153,7 +226,19 @@ export default async function HomePage({ params }: HomePageProps) {
<MarqueeSection copy={marqueeCopy} rows={marqueeRows} /> <MarqueeSection copy={marqueeCopy} rows={marqueeRows} />
</MotionFade> </MotionFade>
<MotionFade delay={0.08}>
<ProjectsSection copy={projectsCopy} items={mergedProjectCards} />
</MotionFade>
<MotionFade delay={0.1}> <MotionFade delay={0.1}>
<CapabilitiesSection copy={capabilitiesCopy} />
</MotionFade>
<MotionFade delay={0.12}>
<ProcessSection copy={processCopy} />
</MotionFade>
<MotionFade delay={0.14}>
<ContactCtaSection copy={contactCtaCopy} contactHref={contactHref} /> <ContactCtaSection copy={contactCtaCopy} contactHref={contactHref} />
</MotionFade> </MotionFade>
</Container> </Container>
+17 -89
View File
@@ -4,21 +4,16 @@ import { notFound } from "next/navigation";
import { Container } from "@/components/layout/container"; import { Container } from "@/components/layout/container";
import { PageHero } from "@/components/layout/page-hero"; import { PageHero } from "@/components/layout/page-hero";
import { PortfolioCategoryFilter } from "@/components/site/portfolio-category-filter";
import { PortfolioProjectDetail } from "@/components/site/portfolio-project-detail"; import { PortfolioProjectDetail } from "@/components/site/portfolio-project-detail";
import { PortfolioProjectGrid } from "@/components/site/portfolio-project-grid"; import { getSiteSettings } from "@/lib/app-config";
import { JsonLd } from "@/components/seo/json-ld"; import { buildLocalizedMetadata } from "@/lib/metadata";
import { getSeoSettings, getSiteSettings } from "@/lib/app-config";
import { buildLocalizedMetadata, buildProjectJsonLd } from "@/lib/metadata";
import { resolveLocale } from "@/lib/locale"; import { resolveLocale } from "@/lib/locale";
import { import {
getActivePortfolioCategories,
getLocalizedValue, getLocalizedValue,
getPublishedPortfolioProjects, getPublishedPortfolioProjectBySlug,
resolvePortfolioSlug,
} from "@/lib/portfolio"; } from "@/lib/portfolio";
type PortfolioSlugPageProps = { type PortfolioItemPageProps = {
params: Promise<{ params: Promise<{
locale: string; locale: string;
slug: string; slug: string;
@@ -27,115 +22,48 @@ type PortfolioSlugPageProps = {
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export async function generateMetadata({ params }: PortfolioSlugPageProps): Promise<Metadata> { export async function generateMetadata({ params }: PortfolioItemPageProps): Promise<Metadata> {
const { slug } = await params; const { slug } = await params;
const siteSettings = await getSiteSettings(); const siteSettings = await getSiteSettings();
const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale); const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale);
const resolved = await resolvePortfolioSlug(slug); const item = await getPublishedPortfolioProjectBySlug(slug);
if (!resolved) { if (!item) {
const t = await getTranslations({ locale: localeKey, namespace: "portfolioPage" });
return await buildLocalizedMetadata({ return await buildLocalizedMetadata({
locale: localeKey, locale: localeKey,
pathname: `/portfolio/${slug}`, pathname: `/portfolio/${slug}`,
title: t("title"), title: "Portfolio",
description: t("intro"), description: "Portfolio item",
});
}
if (resolved.kind === "category") {
const t = await getTranslations({ locale: localeKey, namespace: "portfolioPage" });
return await buildLocalizedMetadata({
locale: localeKey,
pathname: `/portfolio/${slug}`,
title: getLocalizedValue(resolved.category.name, localeKey),
description: getLocalizedValue(resolved.category.description, localeKey) || t("intro"),
}); });
} }
return await buildLocalizedMetadata({ return await buildLocalizedMetadata({
locale: localeKey, locale: localeKey,
pathname: `/portfolio/${slug}`, pathname: `/portfolio/${slug}`,
title: getLocalizedValue(resolved.project.title, localeKey), title: getLocalizedValue(item.title, localeKey),
description: getLocalizedValue(resolved.project.summary, localeKey), description: getLocalizedValue(item.summary, localeKey),
image: resolved.project.coverImagePath,
type: "article",
publishedTime: resolved.project.publishedAt,
}); });
} }
export default async function PortfolioSlugPage({ params }: PortfolioSlugPageProps) { export default async function PortfolioItemPage({
params,
}: PortfolioItemPageProps) {
const { slug } = await params; const { slug } = await params;
const siteSettings = await getSiteSettings(); const siteSettings = await getSiteSettings();
const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale); const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale);
const resolved = await resolvePortfolioSlug(slug); const item = await getPublishedPortfolioProjectBySlug(slug);
if (!resolved) { if (!item) {
notFound(); notFound();
} }
if (resolved.kind === "category") { const t = await getTranslations({ locale: localeKey, namespace: "portfolioDetail" });
const { category } = resolved;
const [categories, projects] = await Promise.all([
getActivePortfolioCategories(),
getPublishedPortfolioProjects({ categorySlug: slug }),
]);
const t = await getTranslations({ locale: localeKey, namespace: "portfolioPage" });
return (
<>
<PageHero
locale={localeKey}
badge={t("heroBadge")}
title={getLocalizedValue(category.name, localeKey)}
description={getLocalizedValue(category.description, localeKey) || t("intro")}
/>
<Container className="flex flex-col gap-section pb-12 lg:pb-16">
<PortfolioCategoryFilter
locale={localeKey}
defaultLocale={siteSettings.defaultLocale}
categories={categories}
allLabel={t("all")}
activeCategorySlug={category.slug}
/>
<PortfolioProjectGrid
locale={localeKey}
defaultLocale={siteSettings.defaultLocale}
projects={projects}
emptyLabel={t("empty")}
openLabel={t("open")}
/>
</Container>
</>
);
}
const { project: item } = resolved;
const [t, seo] = await Promise.all([
getTranslations({ locale: localeKey, namespace: "portfolioDetail" }),
getSeoSettings(),
]);
const title = getLocalizedValue(item.title, localeKey); const title = getLocalizedValue(item.title, localeKey);
const category = getLocalizedValue(item.category.name, localeKey); const category = getLocalizedValue(item.category.name, localeKey);
const summary = getLocalizedValue(item.summary, localeKey); const summary = getLocalizedValue(item.summary, localeKey);
const jsonLd = buildProjectJsonLd({
settings: siteSettings,
seo,
locale: localeKey,
pathname: `/portfolio/${slug}`,
title,
description: summary,
image: item.coverImagePath,
datePublished: item.publishedAt,
genre: category,
keywords: [getLocalizedValue(item.serviceLabel, localeKey), String(item.projectYear)].filter(Boolean),
clientName: item.clientName,
});
return ( return (
<> <>
<JsonLd data={jsonLd} />
<PageHero <PageHero
locale={localeKey} locale={localeKey}
badge={category} badge={category}
@@ -0,0 +1,96 @@
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>
</>
);
}
@@ -0,0 +1,21 @@
import { getLocale } from "next-intl/server";
import { permanentRedirect } from "next/navigation";
import { getSiteSettings } from "@/lib/app-config";
import { getLocalizedPath, resolveLocale } from "@/lib/locale";
type PortfolioCategoryIndexPageProps = {
params: Promise<{
locale: string;
}>;
};
export default async function PortfolioCategoryIndexPage({
params,
}: PortfolioCategoryIndexPageProps) {
await params;
const siteSettings = await getSiteSettings();
const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale);
permanentRedirect(getLocalizedPath(localeKey, "/portfolio", siteSettings.defaultLocale));
}
+1 -1
View File
@@ -50,7 +50,7 @@ export default async function PortfolioPage({
const selectedCategory = resolvedSearchParams?.category ?? ""; const selectedCategory = resolvedSearchParams?.category ?? "";
if (selectedCategory) { if (selectedCategory) {
redirect(getLocalizedPath(localeKey, `/portfolio/${selectedCategory}`, siteSettings.defaultLocale)); redirect(getLocalizedPath(localeKey, `/portfolio/category/${selectedCategory}`, siteSettings.defaultLocale));
} }
const [categories, projects] = await Promise.all([ const [categories, projects] = await Promise.all([
-1
View File
@@ -30,7 +30,6 @@ export async function generateMetadata({ params }: SuccessPageProps): Promise<Me
pathname: "/success", pathname: "/success",
title: t("title"), title: t("title"),
description: t("text"), description: t("text"),
noIndex: true,
}); });
} }
+87 -19
View File
@@ -1,13 +1,17 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { unstable_noStore as noStore } from "next/cache"; import { unstable_noStore as noStore } from "next/cache";
import Link from "next/link";
import { ArrowLeft, ArrowRight, Mail } from "lucide-react";
import { getLocale, getTranslations } from "next-intl/server"; import { getLocale, getTranslations } from "next-intl/server";
import { FloatingPreferences } from "@/components/layout/floating-preferences"; import { FloatingPreferences } from "@/components/layout/floating-preferences";
import { HeroContentMotion, HeroMotionItem, HeroShell, HeroTitle } from "@/components/layout/site-hero"; import { HeroContentMotion, HeroMotionItem, HeroShell, HeroTitle } from "@/components/layout/site-hero";
import { LaunchCountdown } from "@/components/site/launch-countdown"; import { LaunchCountdown } from "@/components/site/launch-countdown";
import { Button } from "@/components/ui/button";
import { getSiteSettings } from "@/lib/app-config"; import { getSiteSettings } from "@/lib/app-config";
import { buildLocalizedMetadata } from "@/lib/metadata"; import { buildLocalizedMetadata } from "@/lib/metadata";
import { resolveLocale } from "@/lib/locale"; import { getLocalizedPath, resolveLocale } from "@/lib/locale";
import { cn } from "@/lib/utils";
// Target launch date for the countdown. Edit this single line to change it. // Target launch date for the countdown. Edit this single line to change it.
const LAUNCH_DATE_ISO = "2026-08-28T12:00:00Z"; const LAUNCH_DATE_ISO = "2026-08-28T12:00:00Z";
@@ -34,7 +38,6 @@ export async function generateMetadata({ params }: ComingSoonPageProps): Promise
title: siteSettings.locales[localeKey].siteName, title: siteSettings.locales[localeKey].siteName,
description: t("description"), description: t("description"),
applyTitleTemplate: false, applyTitleTemplate: false,
noIndex: true,
}); });
} }
@@ -45,6 +48,7 @@ export default async function ComingSoonPage({ params }: ComingSoonPageProps) {
const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale); const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale);
const t = await getTranslations({ locale: localeKey, namespace: "comingSoon" }); const t = await getTranslations({ locale: localeKey, namespace: "comingSoon" });
const isArabic = localeKey === "ar"; const isArabic = localeKey === "ar";
const DirectionIcon = isArabic ? ArrowLeft : ArrowRight;
const lines = [ const lines = [
{ {
@@ -64,28 +68,92 @@ export default async function ComingSoonPage({ params }: ComingSoonPageProps) {
}, },
]; ];
const trackedEyebrow = isArabic ? "tracking-normal" : "uppercase tracking-[0.28em]";
const trackedLabel = isArabic ? "tracking-normal" : "uppercase tracking-[0.24em]";
const trackedPill = isArabic ? "tracking-normal" : "uppercase tracking-[0.12em]";
return ( return (
<div className="relative h-screen overflow-hidden"> <div className="relative min-h-screen overflow-hidden">
<FloatingPreferences locale={localeKey} defaultLocale={siteSettings.defaultLocale} /> <FloatingPreferences locale={localeKey} defaultLocale={siteSettings.defaultLocale} />
<HeroShell className="h-screen" showBridges> <HeroShell className="min-h-screen" showBridges>
<HeroContentMotion className="relative z-10 w-full"> <HeroContentMotion className="relative z-10 w-full">
<div className="mx-auto flex w-full max-w-[60rem] flex-col items-center text-center"> <div className="mx-auto w-full max-w-[60rem]">
<HeroTitle locale={localeKey} lines={lines} className="mx-auto tracking-normal" /> <div className="relative overflow-hidden rounded-[calc(var(--radius-surface)+10px)] border border-foreground/10 bg-background/45 px-6 py-12 shadow-panel backdrop-blur-[26px] dark:border-foreground/12 dark:bg-background/25 sm:px-14 sm:py-16">
{/* top hairline highlight */}
<HeroMotionItem className="mt-12"> <span
<LaunchCountdown aria-hidden
targetIso={LAUNCH_DATE_ISO} className="pointer-events-none absolute inset-x-8 top-0 h-px bg-gradient-to-r from-transparent via-foreground/25 to-transparent"
arabic={isArabic}
launchedLabel={t("launched")}
labels={{
days: t("unitDays"),
hours: t("unitHours"),
minutes: t("unitMinutes"),
seconds: t("unitSeconds"),
}}
/> />
</HeroMotionItem> {/* soft brand glow */}
<span
aria-hidden
className="pointer-events-none absolute -top-28 left-1/2 h-64 w-[38rem] max-w-[120%] -translate-x-1/2 rounded-full bg-[radial-gradient(closest-side,hsl(var(--brand-primary)/0.18),transparent)] blur-2xl"
/>
<div className="relative flex flex-col items-center text-center">
<HeroMotionItem>
<span
className={cn(
"inline-flex items-center gap-2.5 rounded-pill border border-foreground/10 bg-background/55 px-4 py-2 text-xs font-medium text-[hsl(var(--hero-ink)/0.82)] shadow-[var(--shadow-sm)] backdrop-blur-[18px] dark:border-foreground/12 dark:bg-background/20 dark:text-foreground/82",
trackedPill,
)}
>
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-brand-secondary opacity-70" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-brand-secondary" />
</span>
{t("status")}
</span>
</HeroMotionItem>
<HeroMotionItem>
<p className={cn("mt-7 text-xs font-medium text-foreground/45", trackedEyebrow)}>
{t("kicker")}
</p>
</HeroMotionItem>
<HeroTitle locale={localeKey} lines={lines} className="mx-auto mt-4 tracking-normal" />
<HeroMotionItem>
<p className="mx-auto mt-8 max-w-[36rem] text-sm leading-7 text-foreground/68 sm:text-base">
{t("description")}
</p>
</HeroMotionItem>
<HeroMotionItem className="mt-11 flex flex-col items-center gap-4">
<span className={cn("text-xs font-medium text-foreground/45", trackedLabel)}>
{t("countdownLabel")}
</span>
<LaunchCountdown
targetIso={LAUNCH_DATE_ISO}
arabic={isArabic}
launchedLabel={t("launched")}
labels={{
days: t("unitDays"),
hours: t("unitHours"),
minutes: t("unitMinutes"),
seconds: t("unitSeconds"),
}}
/>
</HeroMotionItem>
<HeroMotionItem className="mt-12 flex w-full flex-col items-center justify-center gap-3 sm:w-auto sm:flex-row">
<Button asChild size="lg" className="w-full sm:w-auto">
<Link href={getLocalizedPath(localeKey, "/contact", siteSettings.defaultLocale)}>
<Mail className="h-4 w-4" />
{t("primaryCta")}
</Link>
</Button>
<Button asChild variant="outline" size="lg" className="w-full sm:w-auto">
<Link href={getLocalizedPath(localeKey, "/", siteSettings.defaultLocale)}>
{t("secondaryCta")}
<DirectionIcon className="h-4 w-4" />
</Link>
</Button>
</HeroMotionItem>
</div>
</div>
</div> </div>
</HeroContentMotion> </HeroContentMotion>
</HeroShell> </HeroShell>
+1 -1
View File
@@ -60,7 +60,7 @@ export default async function AdminMaintenancePage({
headerDescription={copy.subtitle} headerDescription={copy.subtitle}
> >
<MotionFade delay={0.1}> <MotionFade delay={0.1}>
<AppCard> <AppCard layer="single">
<CardContent className="space-y-4 p-6"> <CardContent className="space-y-4 p-6">
<p className="text-sm text-muted-foreground">{copy.maintenanceText}</p> <p className="text-sm text-muted-foreground">{copy.maintenanceText}</p>
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
+2 -3
View File
@@ -1,6 +1,6 @@
"use server"; "use server";
import { MediaKind } from "@/lib/db/enums"; import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { isRedirectError } from "next/dist/client/components/redirect-error"; import { isRedirectError } from "next/dist/client/components/redirect-error";
@@ -11,10 +11,9 @@ import { withFlash } from "@/lib/admin-feedback";
import { countMediaUsageReferences, getMediaAssetById } from "@/lib/media"; import { countMediaUsageReferences, getMediaAssetById } from "@/lib/media";
import { createStandaloneMediaAsset, deleteMediaAssetAndFile } from "@/lib/media-service"; import { createStandaloneMediaAsset, deleteMediaAssetAndFile } from "@/lib/media-service";
import { isManagedMediaFilePath } from "@/lib/media-storage"; import { isManagedMediaFilePath } from "@/lib/media-storage";
import { eq } from "drizzle-orm";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { mediaAsset } from "@/lib/db/schema"; import { mediaAsset } from "@/lib/db/schema";
import { MediaKind } from "@/lib/db/enums";
async function ensureAdmin() { async function ensureAdmin() {
if (!(await isAdminAuthenticated())) { if (!(await isAdminAuthenticated())) {
+36 -103
View File
@@ -1,6 +1,6 @@
"use server"; "use server";
import { eq, inArray } from "drizzle-orm"; import { and, count, eq, inArray } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { isRedirectError } from "next/dist/client/components/redirect-error"; import { isRedirectError } from "next/dist/client/components/redirect-error";
@@ -30,21 +30,10 @@ import { getSiteSettings } from "@/lib/app-config";
import { import {
assetInputSchema, assetInputSchema,
categoryInputSchema, categoryInputSchema,
projectDraftInputSchema,
projectInputSchema, projectInputSchema,
sectionInputSchema, sectionInputSchema,
} from "@/lib/portfolio-validation"; } 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() { async function ensureAdmin() {
if (!(await isAdminAuthenticated())) { if (!(await isAdminAuthenticated())) {
await clearAdminSessionCookie(); await clearAdminSessionCookie();
@@ -101,18 +90,14 @@ function parseZodError(error: ZodError) {
return error.issues[0]?.message ?? "Validierung fehlgeschlagen."; return error.issues[0]?.message ?? "Validierung fehlgeschlagen.";
} }
// Postgres unique-violation (code 23505, was Prisma's "P2002"). The error shape // Postgres unique-violation SQLSTATE (was Prisma's P2002).
// differs between drivers (postgres.js exposes `.code`; PGlite in tests nests it
// or only in the message), so check code, cause.code, and the message text.
function isUniqueViolation(error: unknown): boolean { function isUniqueViolation(error: unknown): boolean {
if (typeof error !== "object" || error === null) { return (
return false; typeof error === "object" &&
} error !== null &&
const e = error as { code?: string; cause?: { code?: string }; message?: string }; "code" in error &&
if (e.code === "23505" || e.cause?.code === "23505") { (error as { code?: string }).code === "23505"
return true; );
}
return typeof e.message === "string" && /23505|duplicate key|unique constraint/i.test(e.message);
} }
async function revalidatePortfolioPages() { async function revalidatePortfolioPages() {
@@ -154,23 +139,15 @@ export async function upsertCategoryAction(formData: FormData) {
isActive: normalizeCheckboxValue(formData, "isActive"), isActive: normalizeCheckboxValue(formData, "isActive"),
}); });
// Categories and projects share the public `/portfolio/[slug]` route, so a const { id: categoryId, ...categoryValues } = parsed;
// 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) { if (categoryId) {
throw new Error("Kategorie Slug ist bereits als Projekt Slug vergeben."); await db
} .update(category)
.set({ ...categoryValues, updatedAt: new Date() })
if (parsed.id) { .where(eq(category.id, categoryId));
await db.update(category).set(parsed).where(eq(category.id, parsed.id));
} else { } else {
await db.insert(category).values(parsed); await db.insert(category).values(categoryValues);
} }
await revalidatePortfolioPages(); await revalidatePortfolioPages();
@@ -185,9 +162,7 @@ export async function upsertCategoryAction(formData: FormData) {
? parseZodError(error) ? parseZodError(error)
: isUniqueViolation(error) : isUniqueViolation(error)
? "Kategorie Slug muss eindeutig sein." ? "Kategorie Slug muss eindeutig sein."
: error instanceof Error && error.message.includes("Slug") : "Kategorie konnte nicht gespeichert werden.";
? error.message
: "Kategorie konnte nicht gespeichert werden.";
redirect(withFlash(redirectPath, { error: message })); redirect(withFlash(redirectPath, { error: message }));
} }
@@ -200,9 +175,12 @@ export async function deleteCategoryAction(formData: FormData) {
const id = String(formData.get("id") ?? ""); const id = String(formData.get("id") ?? "");
try { try {
const projectCount = await db.$count(portfolioProject, eq(portfolioProject.categoryId, id)); const [projectCountRow] = await db
.select({ value: count() })
.from(portfolioProject)
.where(eq(portfolioProject.categoryId, id));
if (projectCount > 0) { if ((projectCountRow?.value ?? 0) > 0) {
redirect(withFlash(redirectPath, { error: "Kategorie mit Projekten kann nicht geloescht werden." })); redirect(withFlash(redirectPath, { error: "Kategorie mit Projekten kann nicht geloescht werden." }));
} }
@@ -230,67 +208,28 @@ export async function saveProjectAction(formData: FormData) {
const createdMediaAssetIds: string[] = []; const createdMediaAssetIds: string[] = [];
try { try {
const intent = String(formData.get("intent") ?? "save"); const sections = parseJsonArray(formData.get("sections"), "sections").map((section, index) =>
const isDraft = intent === "draft";
const parseSection = (section: Record<string, unknown>, index: number) =>
sectionInputSchema.parse({ sectionInputSchema.parse({
...section, ...section,
media: section.media ? mediaFieldInputSchema.parse(section.media) : undefined, media: section.media ? mediaFieldInputSchema.parse(section.media) : undefined,
sortOrder: section.sortOrder ?? index, sortOrder: section.sortOrder ?? index,
}); }),
);
const parseAsset = (asset: Record<string, unknown>, index: number) => const assets = parseJsonArray(formData.get("assets"), "assets").map((asset, index) =>
assetInputSchema.parse({ assetInputSchema.parse({
...asset, ...asset,
media: asset.media ? mediaFieldInputSchema.parse(asset.media) : undefined, media: asset.media ? mediaFieldInputSchema.parse(asset.media) : undefined,
sortOrder: asset.sortOrder ?? index, 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 coverMedia = parseJsonObject(formData.get("coverMedia"), "coverMedia");
const rawSlug = String(formData.get("slug") ?? "").trim(); const parsed = projectInputSchema.parse({
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, id: String(formData.get("id") ?? "").trim() || undefined,
categoryId: String(formData.get("categoryId") ?? ""), categoryId: String(formData.get("categoryId") ?? ""),
slug, slug: String(formData.get("slug") ?? ""),
viewMode: String(formData.get("viewMode") ?? "GRID"), viewMode: String(formData.get("viewMode") ?? "GRID"),
titleAr: String(formData.get("titleAr") ?? ""), titleAr: String(formData.get("titleAr") ?? ""),
titleEn: String(formData.get("titleEn") ?? ""), titleEn: String(formData.get("titleEn") ?? ""),
@@ -299,7 +238,7 @@ export async function saveProjectAction(formData: FormData) {
summaryEn: String(formData.get("summaryEn") ?? ""), summaryEn: String(formData.get("summaryEn") ?? ""),
summaryDe: String(formData.get("summaryDe") ?? ""), summaryDe: String(formData.get("summaryDe") ?? ""),
clientName: String(formData.get("clientName") ?? ""), clientName: String(formData.get("clientName") ?? ""),
projectYear, projectYear: String(formData.get("projectYear") ?? ""),
serviceLabelAr: String(formData.get("serviceLabelAr") ?? ""), serviceLabelAr: String(formData.get("serviceLabelAr") ?? ""),
serviceLabelEn: String(formData.get("serviceLabelEn") ?? ""), serviceLabelEn: String(formData.get("serviceLabelEn") ?? ""),
serviceLabelDe: String(formData.get("serviceLabelDe") ?? ""), serviceLabelDe: String(formData.get("serviceLabelDe") ?? ""),
@@ -308,21 +247,11 @@ export async function saveProjectAction(formData: FormData) {
coverMedia: coverMedia ? mediaFieldInputSchema.parse(coverMedia) : undefined, coverMedia: coverMedia ? mediaFieldInputSchema.parse(coverMedia) : undefined,
sortOrder: String(formData.get("sortOrder") ?? "0"), sortOrder: String(formData.get("sortOrder") ?? "0"),
isFeatured: normalizeCheckboxValue(formData, "isFeatured"), isFeatured: normalizeCheckboxValue(formData, "isFeatured"),
isPublished: isDraft ? false : normalizeCheckboxValue(formData, "isPublished"), isPublished: normalizeCheckboxValue(formData, "isPublished"),
sections, sections,
assets, assets,
}); });
const [categoryWithSlug] = await db
.select({ id: category.id })
.from(category)
.where(eq(category.slug, parsed.slug))
.limit(1);
if (categoryWithSlug) {
throw new Error("Projekt Slug ist bereits als Kategorie Slug vergeben.");
}
const existingProject = parsed.id const existingProject = parsed.id
? ( ? (
await db await db
@@ -476,12 +405,16 @@ export async function saveProjectAction(formData: FormData) {
? new Date() ? new Date()
: existingProject?.publishedAt ?? new Date() : existingProject?.publishedAt ?? new Date()
: null, : null,
updatedAt: new Date(),
}) })
.where(eq(portfolioProject.id, parsed.id)) .where(eq(portfolioProject.id, parsed.id))
.returning() .returning()
: await tx : await tx
.insert(portfolioProject) .insert(portfolioProject)
.values({ ...projectValues, publishedAt: parsed.isPublished ? new Date() : null }) .values({
...projectValues,
publishedAt: parsed.isPublished ? new Date() : null,
})
.returning(); .returning();
await tx.delete(portfolioSection).where(eq(portfolioSection.projectId, currentProject.id)); await tx.delete(portfolioSection).where(eq(portfolioSection.projectId, currentProject.id));
+1 -1
View File
@@ -104,7 +104,7 @@ export default async function AdminPortfolioProjectPage({
</MotionFade> </MotionFade>
<MotionFade delay={0.2}> <MotionFade delay={0.2}>
<AppCard level={2}> <AppCard level={2} layer="single">
<CardContent className="flex flex-col gap-4 p-6 lg:flex-row lg:items-center lg:justify-between"> <CardContent className="flex flex-col gap-4 p-6 lg:flex-row lg:items-center lg:justify-between">
<div> <div>
<p className="text-sm font-medium text-foreground">{copy.dangerZone}</p> <p className="text-sm font-medium text-foreground">{copy.dangerZone}</p>
+2 -74
View File
@@ -1,6 +1,6 @@
"use server"; "use server";
import { MediaUsageType } from "@/lib/db/enums"; import { inArray } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { isRedirectError } from "next/dist/client/components/redirect-error"; import { isRedirectError } from "next/dist/client/components/redirect-error";
@@ -12,9 +12,7 @@ import {
SITE_SETTINGS_FAVICON_FIELD_KEY, SITE_SETTINGS_FAVICON_FIELD_KEY,
SITE_SETTINGS_LOGO_DARK_FIELD_KEY, SITE_SETTINGS_LOGO_DARK_FIELD_KEY,
SITE_SETTINGS_LOGO_LIGHT_FIELD_KEY, SITE_SETTINGS_LOGO_LIGHT_FIELD_KEY,
getSeoSettings,
getSiteSettings, getSiteSettings,
updateSeoSettings,
updateSiteSettings, updateSiteSettings,
} from "@/lib/app-config"; } from "@/lib/app-config";
import { getAdminAppPath, toInternalAdminPath } from "@/lib/admin-routing"; import { getAdminAppPath, toInternalAdminPath } from "@/lib/admin-routing";
@@ -26,25 +24,15 @@ import {
type SiteSettings, type SiteSettings,
} from "@/lib/site-settings"; } from "@/lib/site-settings";
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth"; import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
import {
normalizeKeywords,
normalizeSameAs,
normalizeStructuredDataType,
normalizeTwitterHandle,
normalizeVerificationToken,
type SeoSettings,
} from "@/lib/seo-settings";
import { isCheckedFormValue } from "@/lib/form-data";
import { replaceEntityMediaUsages } from "@/lib/media"; import { replaceEntityMediaUsages } from "@/lib/media";
import { resolveMediaSelection } from "@/lib/media-service"; import { resolveMediaSelection } from "@/lib/media-service";
import { routing } from "@/i18n/routing"; import { routing } from "@/i18n/routing";
import { getLocalizedPath } from "@/lib/locale"; import { getLocalizedPath } from "@/lib/locale";
import { removeManagedMediaFile } from "@/lib/media-storage"; import { removeManagedMediaFile } from "@/lib/media-storage";
import { mediaFieldInputSchema } from "@/lib/media-validation"; import { mediaFieldInputSchema } from "@/lib/media-validation";
import { inArray } from "drizzle-orm";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { mediaAsset } from "@/lib/db/schema"; import { mediaAsset } from "@/lib/db/schema";
import { MediaUsageType } from "@/lib/db/enums";
async function ensureAdmin() { async function ensureAdmin() {
if (!(await isAdminAuthenticated())) { if (!(await isAdminAuthenticated())) {
@@ -340,63 +328,3 @@ export async function saveSiteLocalizationSettingsAction(formData: FormData) {
redirect(withFlash(getAdminAppPath("/site-settings/localization"), { error: message })); redirect(withFlash(getAdminAppPath("/site-settings/localization"), { error: message }));
} }
} }
export async function saveSeoSettingsAction(formData: FormData) {
await ensureAdmin();
try {
const currentSettings = await getSeoSettings();
const googleRaw = String(formData.get("googleSiteVerification") ?? "").trim();
const bingRaw = String(formData.get("bingSiteVerification") ?? "").trim();
const twitterRaw = String(formData.get("twitterHandle") ?? "").trim();
const googleSiteVerification = normalizeVerificationToken(googleRaw);
const bingSiteVerification = normalizeVerificationToken(bingRaw);
const twitterHandle = normalizeTwitterHandle(twitterRaw);
if (googleRaw && !googleSiteVerification) {
throw new Error("Google Verification Code darf nur Buchstaben, Zahlen, - und _ enthalten.");
}
if (bingRaw && !bingSiteVerification) {
throw new Error("Bing Verification Code darf nur Buchstaben, Zahlen, - und _ enthalten.");
}
if (twitterRaw && !twitterHandle) {
throw new Error("X/Twitter Handle ist ungueltig (max. 15 Zeichen, Buchstaben/Zahlen/_).");
}
const parsedSettings: SeoSettings = {
...currentSettings,
allowIndexing: isCheckedFormValue(formData.get("allowIndexing")),
googleSiteVerification,
bingSiteVerification,
twitterHandle,
structuredDataType: normalizeStructuredDataType(formData.get("structuredDataType")),
structuredDataName: String(formData.get("structuredDataName") ?? "").trim().slice(0, 120),
structuredDataJobTitle: String(formData.get("structuredDataJobTitle") ?? "").trim().slice(0, 160),
sameAs: normalizeSameAs(formData.get("sameAs")),
locales: {
ar: { keywords: normalizeKeywords(formData.get("keywordsAr")) },
en: { keywords: normalizeKeywords(formData.get("keywordsEn")) },
de: { keywords: normalizeKeywords(formData.get("keywordsDe")) },
},
};
await updateSeoSettings(parsedSettings);
const siteSettings = await getSiteSettings();
await revalidateSiteSettingsPages(siteSettings.defaultLocale);
revalidatePath("/sitemap.xml");
revalidatePath("/robots.txt");
revalidatePath(toInternalAdminPath("/site-settings/seo"));
redirect(withFlash(getAdminAppPath("/site-settings/seo"), { success: "SEO Einstellungen gespeichert." }));
} catch (error) {
if (isRedirectError(error)) {
throw error;
}
const message =
error instanceof Error ? error.message : "SEO Einstellungen konnten nicht gespeichert werden.";
redirect(withFlash(getAdminAppPath("/site-settings/seo"), { error: message }));
}
}
-104
View File
@@ -1,104 +0,0 @@
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>
);
}
@@ -1 +0,0 @@
export { default } from "../../../_admin/site-settings/seo/page";
-217
View File
@@ -295,223 +295,6 @@ html[lang="ar"] .eyebrow {
display: none; display: none;
} }
/* ---- Layered hero backdrop (HeroMotionBackdrop) ---- */
.hero-backdrop-base {
position: absolute;
inset: 0;
background: hsl(var(--background));
}
.hero-backdrop-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(hsl(var(--border) / 0.55) 1px, transparent 1px),
linear-gradient(90deg, hsl(var(--border) / 0.55) 1px, transparent 1px);
background-size: 30px 30px;
background-position: center top;
opacity: 0.55;
-webkit-mask-image: radial-gradient(ellipse 78% 58% at 50% 30%, #000 0%, transparent 72%);
mask-image: radial-gradient(ellipse 78% 58% at 50% 30%, #000 0%, transparent 72%);
}
.dark .hero-backdrop-grid {
opacity: 0.4;
}
.hero-backdrop-glow {
position: absolute;
inset: 0;
background:
radial-gradient(58% 46% at 50% 20%, hsl(var(--brand-primary) / 0.16), transparent 70%),
radial-gradient(46% 42% at 80% 10%, hsl(var(--brand-secondary) / 0.13), transparent 68%),
radial-gradient(48% 44% at 16% 18%, hsl(var(--brand-primary) / 0.1), transparent 66%);
}
.hero-orb {
position: absolute;
border-radius: 9999px;
filter: blur(64px);
will-change: transform;
}
.hero-orb-1 {
top: -10%;
inset-inline-start: -8%;
height: 32rem;
width: 32rem;
background: hsl(var(--brand-primary) / 0.18);
animation: hero-orb-drift-a 22s ease-in-out infinite;
}
.hero-orb-2 {
top: -6%;
inset-inline-end: -10%;
height: 28rem;
width: 28rem;
background: hsl(var(--brand-secondary) / 0.15);
animation: hero-orb-drift-b 27s ease-in-out infinite;
}
.hero-orb-3 {
bottom: -22%;
inset-inline-start: 24%;
height: 26rem;
width: 26rem;
background: hsl(var(--brand-primary) / 0.1);
animation: hero-orb-drift-a 31s ease-in-out infinite reverse;
}
.hero-backdrop-noise {
position: absolute;
inset: -8%;
opacity: 0.26;
mix-blend-mode: soft-light;
}
.dark .hero-backdrop-noise {
opacity: 0.1;
}
.hero-backdrop-floor {
position: absolute;
inset-inline: 0;
bottom: 0;
height: 40%;
background: linear-gradient(to bottom, transparent, hsl(var(--background)) 92%);
}
.hero-backdrop.is-compact .hero-backdrop-grid {
opacity: 0.32;
}
.hero-backdrop.is-compact .hero-orb {
opacity: 0.7;
filter: blur(72px);
}
@keyframes hero-orb-drift-a {
0%,
100% {
transform: translate3d(0, 0, 0) scale(1);
}
33% {
transform: translate3d(70px, -48px, 0) scale(1.1);
}
66% {
transform: translate3d(-42px, 26px, 0) scale(0.94);
}
}
@keyframes hero-orb-drift-b {
0%,
100% {
transform: translate3d(0, 0, 0) scale(1);
}
33% {
transform: translate3d(-80px, 42px, 0) scale(0.92);
}
66% {
transform: translate3d(38px, -24px, 0) scale(1.08);
}
}
@media (prefers-reduced-motion: reduce) {
.hero-orb {
animation: none;
}
}
/* ---- Hero entrance ----
Per-element rise is disabled: the site `template.tsx` now runs one unified
page-transition animation on every navigation instead. ---- */
.hero-rise {
opacity: 1;
}
.hero-content > *:nth-child(1) {
animation-delay: 0.06s;
}
.hero-content > *:nth-child(2) {
animation-delay: 0.14s;
}
.hero-content > *:nth-child(3) {
animation-delay: 0.22s;
}
.hero-content > *:nth-child(4) {
animation-delay: 0.3s;
}
.hero-content > *:nth-child(5) {
animation-delay: 0.38s;
}
.hero-content > *:nth-child(6) {
animation-delay: 0.46s;
}
@keyframes hero-rise-in {
from {
opacity: 0;
transform: translateY(18px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Focused glow directly behind the hero title */
.hero-title {
position: relative;
isolation: isolate;
}
.hero-title::before {
content: "";
position: absolute;
left: 50%;
top: 46%;
z-index: -1;
width: 84%;
height: 74%;
transform: translate(-50%, -50%);
background: radial-gradient(closest-side, hsl(var(--brand-primary) / 0.2), transparent 78%);
filter: blur(48px);
pointer-events: none;
}
.dark .hero-title::before {
background: radial-gradient(closest-side, hsl(var(--brand-primary) / 0.3), transparent 78%);
}
/* Scroll cue pinned to the bottom of the home hero */
.hero-scroll-anchor {
position: absolute;
bottom: 1.5rem;
left: 50%;
z-index: 10;
transform: translateX(-50%);
opacity: 0;
animation: hero-fade-in 0.6s ease 0.7s forwards;
}
.hero-scroll-anchor:hover {
transform: translateX(-50%) translateY(2px);
}
@keyframes hero-fade-in {
to {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.hero-rise,
.hero-scroll-anchor {
animation: none;
opacity: 1;
}
}
.hero-title-line { .hero-title-line {
display: inline-block; display: inline-block;
padding-inline: 0.02em; padding-inline: 0.02em;
+3 -37
View File
@@ -1,49 +1,15 @@
import type { MetadataRoute } from "next"; import type { MetadataRoute } from "next";
import { unstable_noStore as noStore } from "next/cache";
import { getMaintenanceMode, getSeoSettings } from "@/lib/app-config"; export default function robots(): MetadataRoute.Robots {
import { INTERNAL_ADMIN_PREFIX } from "@/lib/admin-routing"; const siteUrl = new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "https://mohfarawati.de");
import { getSiteUrl } from "@/lib/metadata";
export const dynamic = "force-dynamic";
/** Paths that must never be crawled even when indexing is enabled. */
export const ROBOTS_DISALLOWED_PATHS = [
INTERNAL_ADMIN_PREFIX,
"/root",
"/api/",
"/success",
"/coming-soon",
"/*/success",
"/*/coming-soon",
];
export function buildRobots(input: { indexable: boolean }): MetadataRoute.Robots {
const siteUrl = getSiteUrl();
if (!input.indexable) {
return {
rules: [{ userAgent: "*", disallow: "/" }],
host: siteUrl.origin,
};
}
return { return {
rules: [ rules: [
{ {
userAgent: "*", userAgent: "*",
allow: "/", disallow: ["/admin-internal"],
disallow: ROBOTS_DISALLOWED_PATHS,
}, },
], ],
sitemap: new URL("/sitemap.xml", siteUrl).toString(), sitemap: new URL("/sitemap.xml", siteUrl).toString(),
host: siteUrl.origin,
}; };
} }
export default async function robots(): Promise<MetadataRoute.Robots> {
noStore();
const [seo, maintenanceEnabled] = await Promise.all([getSeoSettings(), getMaintenanceMode()]);
return buildRobots({ indexable: seo.allowIndexing && !maintenanceEnabled });
}
-1
View File
@@ -1 +0,0 @@
export { default } from "../../../_admin/site-settings/seo/page";
+24 -63
View File
@@ -2,111 +2,72 @@ import type { MetadataRoute } from "next";
import { unstable_noStore as noStore } from "next/cache"; import { unstable_noStore as noStore } from "next/cache";
import { routing } from "@/i18n/routing"; import { routing } from "@/i18n/routing";
import { getMaintenanceMode, getSeoSettings, getSiteSettings } from "@/lib/app-config"; import { getSiteSettings } from "@/lib/app-config";
import { getLocalizedPath, type AppLocale } from "@/lib/locale"; import { getLocalizedPath } from "@/lib/locale";
import { toAbsoluteUrl } from "@/lib/metadata"; import { getPublishedPortfolioProjects } from "@/lib/portfolio";
import { getActivePortfolioCategories, getPublishedPortfolioProjects } from "@/lib/portfolio";
export const dynamic = "force-dynamic"; function getSiteUrl(): URL {
return new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "https://mohfarawati.de");
}
type EntryOptions = Pick<MetadataRoute.Sitemap[number], "changeFrequency" | "priority" | "lastModified">; function toAbsoluteUrl(pathname: string): string {
return new URL(pathname, getSiteUrl()).toString();
}
/** function buildLocalizedEntries(
* One entry per locale for a path, each carrying hreflang alternates so search
* engines link the three language versions together.
*/
export function buildLocalizedEntries(
pathname: string, pathname: string,
defaultLocale: AppLocale, defaultLocale: "de" | "en" | "ar",
options?: EntryOptions, options?: Pick<MetadataRoute.Sitemap[number], "changeFrequency" | "priority" | "lastModified">,
): MetadataRoute.Sitemap { ): MetadataRoute.Sitemap {
const languages = Object.fromEntries(
routing.locales.map((locale) => [locale, toAbsoluteUrl(getLocalizedPath(locale, pathname, defaultLocale))]),
) as Record<AppLocale, string>;
return routing.locales.map((locale) => ({ return routing.locales.map((locale) => ({
url: languages[locale], url: toAbsoluteUrl(getLocalizedPath(locale, pathname, defaultLocale)),
lastModified: options?.lastModified, lastModified: options?.lastModified,
changeFrequency: options?.changeFrequency, changeFrequency: options?.changeFrequency,
priority: options?.priority, priority: options?.priority,
alternates: {
languages: {
...languages,
"x-default": languages[defaultLocale],
},
},
})); }));
} }
export default async function sitemap(): Promise<MetadataRoute.Sitemap> { export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
noStore(); noStore();
const [siteSettings, seo, maintenanceEnabled] = await Promise.all([ const siteSettings = await getSiteSettings();
getSiteSettings(),
getSeoSettings(),
getMaintenanceMode(),
]);
// 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 defaultLocale = siteSettings.defaultLocale;
let projects: Awaited<ReturnType<typeof getPublishedPortfolioProjects>> = []; let projects: Awaited<ReturnType<typeof getPublishedPortfolioProjects>> = [];
let categories: Awaited<ReturnType<typeof getActivePortfolioCategories>> = [];
try { try {
[projects, categories] = await Promise.all([ projects = await getPublishedPortfolioProjects();
getPublishedPortfolioProjects(),
getActivePortfolioCategories(),
]);
} catch { } catch {
projects = []; projects = [];
categories = [];
} }
// Only categories that actually have published work get a landing URL; const categories = Array.from(
// an empty category page has nothing to index. new Map(projects.map((project) => [project.category.slug, project.category])).values(),
const categoriesWithProjects = categories.filter((category) =>
projects.some((project) => project.category.slug === category.slug),
); );
const latestProjectDate = projects.reduce<Date | undefined>((latest, project) => {
const date = project.publishedAt ?? undefined;
return date && (!latest || date > latest) ? date : latest;
}, undefined);
return [ return [
...buildLocalizedEntries("/", defaultLocale, { ...buildLocalizedEntries("/", siteSettings.defaultLocale, {
changeFrequency: "weekly", changeFrequency: "weekly",
priority: 1, priority: 1,
lastModified: latestProjectDate,
}), }),
...buildLocalizedEntries("/about", defaultLocale, { ...buildLocalizedEntries("/about", siteSettings.defaultLocale, {
changeFrequency: "monthly", changeFrequency: "monthly",
priority: 0.8, priority: 0.8,
}), }),
...buildLocalizedEntries("/portfolio", defaultLocale, { ...buildLocalizedEntries("/portfolio", siteSettings.defaultLocale, {
changeFrequency: "weekly", changeFrequency: "weekly",
priority: 0.9, priority: 0.9,
lastModified: latestProjectDate,
}), }),
...categoriesWithProjects.flatMap((category) => ...categories.flatMap((category) =>
buildLocalizedEntries(`/portfolio/${category.slug}`, defaultLocale, { buildLocalizedEntries(`/portfolio/category/${category.slug}`, siteSettings.defaultLocale, {
changeFrequency: "weekly", changeFrequency: "weekly",
priority: 0.8, priority: 0.8,
lastModified: latestProjectDate,
}), }),
), ),
...buildLocalizedEntries("/contact", defaultLocale, { ...buildLocalizedEntries("/contact", siteSettings.defaultLocale, {
changeFrequency: "monthly", changeFrequency: "monthly",
priority: 0.7, priority: 0.7,
}), }),
...projects.flatMap((project) => ...projects.flatMap((project) =>
buildLocalizedEntries(`/portfolio/${project.slug}`, defaultLocale, { buildLocalizedEntries(`/portfolio/${project.slug}`, siteSettings.defaultLocale, {
lastModified: project.publishedAt ?? undefined, lastModified: project.publishedAt ?? undefined,
changeFrequency: "monthly", changeFrequency: "monthly",
priority: 0.8, priority: 0.8,
+8 -24
View File
@@ -14,7 +14,6 @@ export const dynamic = "force-dynamic";
const CONTENT_TYPES: Record<string, string> = { const CONTENT_TYPES: Record<string, string> = {
".ico": "image/x-icon", ".ico": "image/x-icon",
".gif": "image/gif",
".jpg": "image/jpeg", ".jpg": "image/jpeg",
".jpeg": "image/jpeg", ".jpeg": "image/jpeg",
".png": "image/png", ".png": "image/png",
@@ -31,30 +30,15 @@ export async function GET(_: Request, { params }: MediaFileRouteProps) {
try { try {
const absolutePath = resolveMediaUploadPath(publicPath); const absolutePath = resolveMediaUploadPath(publicPath);
const fileBuffer = await readFile(absolutePath); const fileBuffer = await readFile(absolutePath);
const extension = path.extname(absolutePath).toLowerCase(); const contentType = CONTENT_TYPES[path.extname(absolutePath).toLowerCase()] ?? "application/octet-stream";
const contentType = CONTENT_TYPES[extension];
if (!contentType) { return new NextResponse(fileBuffer, {
return new NextResponse("Not Found", { status: 404 }); status: 200,
} headers: {
"Content-Type": contentType,
const headers: Record<string, string> = { "Cache-Control": "public, max-age=31536000, immutable",
"Content-Type": contentType, },
"Cache-Control": "public, max-age=31536000, immutable", });
"X-Content-Type-Options": "nosniff",
};
// SVG is an active document type: sandbox it so an uploaded file can never
// run script or reach our origin even if it is opened directly.
if (extension === ".svg") {
headers["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
}
if (extension === ".pdf") {
headers["Content-Disposition"] = "inline";
}
return new NextResponse(new Uint8Array(fileBuffer), { status: 200, headers });
} catch { } catch {
return new NextResponse("Not Found", { return new NextResponse("Not Found", {
status: 404, status: 404,
+1 -5
View File
@@ -8,7 +8,6 @@ import {
LogOut, LogOut,
Palette, Palette,
PlusSquare, PlusSquare,
Search,
ShieldAlert, ShieldAlert,
SwatchBook, SwatchBook,
Tags, Tags,
@@ -41,7 +40,6 @@ type AdminDashboardCopy = {
siteSettings: string; siteSettings: string;
brandSettings?: string; brandSettings?: string;
localizationSettings?: string; localizationSettings?: string;
seoSettings?: string;
marquee?: string; marquee?: string;
smtp?: string; smtp?: string;
logout: string; logout: string;
@@ -52,7 +50,7 @@ type AdminDashboardShellProps = {
copy: AdminDashboardCopy; copy: AdminDashboardCopy;
active: "overview" | "maintenance" | "ui-kit" | "portfolio" | "media" | "site-settings" | "smtp" | "marquee"; active: "overview" | "maintenance" | "ui-kit" | "portfolio" | "media" | "site-settings" | "smtp" | "marquee";
portfolioChild?: "overview" | "projects" | "new-project" | "categories"; portfolioChild?: "overview" | "projects" | "new-project" | "categories";
siteSettingsChild?: "brand" | "localization" | "seo"; siteSettingsChild?: "brand" | "localization";
flash?: FlashMessages; flash?: FlashMessages;
logoutAction: () => Promise<void>; logoutAction: () => Promise<void>;
headerTitle: string; headerTitle: string;
@@ -102,8 +100,6 @@ export async function AdminDashboardShell({
: active === "site-settings" : active === "site-settings"
? siteSettingsChild === "localization" ? siteSettingsChild === "localization"
? Languages ? Languages
: siteSettingsChild === "seo"
? Search
: siteSettingsChild === "brand" : siteSettingsChild === "brand"
? Palette ? Palette
: Globe2 : Globe2
+1 -1
View File
@@ -29,7 +29,7 @@ export function MarqueeSettingsForm({
<div className="grid gap-4 xl:grid-cols-4"> <div className="grid gap-4 xl:grid-cols-4">
{rowMeta.map((row) => ( {rowMeta.map((row) => (
<AppCard key={`de-${row.key}`} level={2} padding="sm" contentClassName="space-y-2"> <AppCard key={`de-${row.key}`} level={2} layer="single" padding="sm" className="space-y-2">
<Label htmlFor={`${row.key}-de`} className="text-sm font-semibold text-foreground">{row.label}</Label> <Label htmlFor={`${row.key}-de`} className="text-sm font-semibold text-foreground">{row.label}</Label>
<Textarea <Textarea
id={`${row.key}-de`} id={`${row.key}-de`}
+37 -31
View File
@@ -6,6 +6,7 @@ import type { MediaKind } from "@/lib/db/enums";
import { Check, ImageIcon, Search, Trash2 } from "lucide-react"; import { Check, ImageIcon, Search, Trash2 } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { AppCard } from "@/components/ui/app-card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@@ -16,6 +17,7 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { MediaOption } from "@/lib/media"; import type { MediaOption } from "@/lib/media";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -87,45 +89,29 @@ export function MediaFieldPicker({
}, [serializedValue]); }, [serializedValue]);
return ( return (
<div className="space-y-2"> <AppCard level={2} layer="single" padding="sm" className="space-y-4">
<input ref={hiddenInputRef} type="hidden" name={inputName} value={serializedValue} /> <input ref={hiddenInputRef} type="hidden" name={inputName} value={serializedValue} />
<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 items-start justify-between gap-4">
<div className="flex min-w-0 items-center gap-3"> <div className="space-y-1">
{selectedOption ? ( <Label className="text-sm font-semibold text-foreground">{title}</Label>
<> <p className="text-sm text-muted-foreground">
<img Media must be selected from the
src={selectedOption.url} {" "}
alt={selectedOption.label} Media Library
className="h-14 w-14 shrink-0 rounded-nested border border-border/60 object-cover" .
/> </p>
<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 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>
<div className="flex gap-2">
<div className="flex shrink-0 gap-2"> <Button type="button" variant="outline" onClick={() => setOpen(true)}>
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(true)}>
<ImageIcon className="h-4 w-4" /> <ImageIcon className="h-4 w-4" />
{selectedOption ? "Ändern" : "Auswählen"} Select from Media
</Button> </Button>
{canClear ? ( {canClear ? (
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="sm"
className="text-destructive hover:text-destructive" className="text-destructive hover:text-destructive"
title={clearLabel}
onClick={() => onClick={() =>
onChange({ onChange({
...value, ...value,
@@ -138,12 +124,32 @@ export function MediaFieldPicker({
} }
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
<span className="sr-only">{clearLabel}</span> {clearLabel}
</Button> </Button>
) : null} ) : null}
</div> </div>
</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}> <Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-4xl"> <DialogContent className="max-w-4xl">
<DialogHeader> <DialogHeader>
@@ -209,6 +215,6 @@ export function MediaFieldPicker({
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </AppCard>
); );
} }
+1 -1
View File
@@ -486,7 +486,7 @@ export function MediaLibraryManager({
) )
) : ( ) : (
<MotionFade delay={0.18}> <MotionFade delay={0.18}>
<AppCard> <AppCard layer="single">
<CardContent className="p-6 text-sm text-muted-foreground"> <CardContent className="p-6 text-sm text-muted-foreground">
{copy.empty} {copy.empty}
</CardContent> </CardContent>
@@ -110,7 +110,7 @@ function CategoryLocaleFields({
const descriptionKey = `description${locale.key}` as const; const descriptionKey = `description${locale.key}` as const;
return ( return (
<AppCard key={`${idPrefix}-${locale.key}`} level={2} padding="sm" className="rounded-nested" contentClassName="space-y-4"> <AppCard key={`${idPrefix}-${locale.key}`} level={2} layer="single" padding="sm" className="space-y-4 rounded-nested">
<p className="text-sm font-medium text-foreground">{locale.label}</p> <p className="text-sm font-medium text-foreground">{locale.label}</p>
<div className="space-y-2"> <div className="space-y-2">
@@ -168,7 +168,7 @@ function CategoryStatusFields({
</div> </div>
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_220px]"> <div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_220px]">
<AppCard level={2} padding="sm" className="rounded-nested"> <AppCard level={2} layer="single" padding="sm" className="rounded-nested">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
{isActive ? ( {isActive ? (
<ShieldCheck className="mt-0.5 h-4 w-4 text-status-success" /> <ShieldCheck className="mt-0.5 h-4 w-4 text-status-success" />
@@ -395,7 +395,7 @@ export function PortfolioCategoriesManager({
<DialogDescription>{copy.modalDescription}</DialogDescription> <DialogDescription>{copy.modalDescription}</DialogDescription>
</DialogHeader> </DialogHeader>
<AppCard level={2} padding="sm" className="rounded-nested"> <AppCard level={2} layer="single" padding="sm" className="rounded-nested">
<div className="grid gap-3 sm:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-3">
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm font-medium text-foreground">1. Basics</p> <p className="text-sm font-medium text-foreground">1. Basics</p>
@@ -426,7 +426,7 @@ export function PortfolioCategoriesManager({
</Dialog> </Dialog>
</div> </div>
<AppCard level={3}> <AppCard level={3} layer="single">
<CardContent className="space-y-4 p-6"> <CardContent className="space-y-4 p-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Layers3 className="h-5 w-5 text-brand-primary" /> <Layers3 className="h-5 w-5 text-brand-primary" />
File diff suppressed because it is too large Load Diff
+55 -152
View File
@@ -1,22 +1,13 @@
/* eslint-disable @next/next/no-img-element */ import { ExternalLink, FolderKanban, Plus, Tags, CheckCircle2 } from "lucide-react";
import { CheckCircle2, ExternalLink, FolderKanban, ImageOff, Plus, Star, Tags } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { StatsCard } from "@/components/dashboard/stats-card"; import { StatsCard } from "@/components/dashboard/stats-card";
import { MotionFade } from "@/components/motion-fade";
import { PortfolioProjectActions } from "@/components/admin/portfolio-project-actions"; import { PortfolioProjectActions } from "@/components/admin/portfolio-project-actions";
import { AppCard } from "@/components/ui/app-card"; import { AppCard } from "@/components/ui/app-card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card"; import { CardContent } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { getAdminAppPath } from "@/lib/admin-routing"; import { getAdminAppPath } from "@/lib/admin-routing";
import { getSiteSettings } from "@/lib/app-config"; import { getSiteSettings } from "@/lib/app-config";
import { getLocalizedPath } from "@/lib/locale"; import { getLocalizedPath } from "@/lib/locale";
@@ -33,34 +24,17 @@ const copy = {
all: "Alle", all: "Alle",
newProject: "Neues Projekt", newProject: "Neues Projekt",
newCategory: "Neues Kategorie", newCategory: "Neues Kategorie",
view: "Auf der Website ansehen", openProject: "Ansehen",
untitled: "Unbenanntes Projekt", untitled: "Unbenanntes Projekt",
empty: "Noch keine Projekte vorhanden.", 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({ export async function PortfolioProjectsOverview({
categories, categories,
projects, projects,
selectedCategory, selectedCategory,
}: PortfolioProjectsOverviewProps) { }: PortfolioProjectsOverviewProps) {
const siteSettings = await getSiteSettings(); const siteSettings = await getSiteSettings();
const publishedCount = projects.filter((project) => project.isPublished).length;
const isFiltered = selectedCategory !== "";
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -68,7 +42,11 @@ export async function PortfolioProjectsOverview({
<div className="grid gap-4 md:grid-cols-3 xl:min-w-[620px]"> <div className="grid gap-4 md:grid-cols-3 xl:min-w-[620px]">
<StatsCard title="Projects" value={String(projects.length)} icon={FolderKanban} /> <StatsCard title="Projects" value={String(projects.length)} icon={FolderKanban} />
<StatsCard title="Categories" value={String(categories.length)} icon={Tags} /> <StatsCard title="Categories" value={String(categories.length)} icon={Tags} />
<StatsCard title="Published" value={String(publishedCount)} icon={CheckCircle2} /> <StatsCard
title="Published"
value={String(projects.filter((project) => project.isPublished).length)}
icon={CheckCircle2}
/>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@@ -98,134 +76,59 @@ export async function PortfolioProjectsOverview({
variant={selectedCategory === category.id ? "default" : "outline"} variant={selectedCategory === category.id ? "default" : "outline"}
> >
<Link href={`${getAdminAppPath("/portfolio")}?category=${category.id}`}> <Link href={`${getAdminAppPath("/portfolio")}?category=${category.id}`}>
{categoryLabel(category.name)} {category.name.de || category.name.en || category.name.ar}
</Link> </Link>
</Button> </Button>
))} ))}
</div> </div>
{projects.length === 0 ? ( <div className="grid gap-4 xl:grid-cols-2">
<AppCard level={3} padding="lg"> {projects.map((project, index) => (
<div className="flex flex-col items-center gap-4 py-12 text-center"> <MotionFade key={project.id} delay={0.06 + index * 0.03}>
<div className="flex h-14 w-14 items-center justify-center rounded-pill border border-border/70 bg-surface-2 text-muted-foreground"> <AppCard interactive layer="single" className="h-full">
<FolderKanban className="h-6 w-6" /> <CardContent className="flex flex-col gap-4 p-5 lg:flex-row lg:items-center lg:justify-between">
</div> <div className="space-y-2">
<div className="space-y-1"> <div className="flex flex-wrap items-center gap-2">
<p className="text-base font-semibold text-foreground"> <p className="text-xl font-semibold text-foreground">
{isFiltered ? copy.emptyFiltered : copy.empty} {getLocalizedValue(project.title, "de") || copy.untitled}
</p> </p>
<p className="text-sm text-muted-foreground">{copy.emptyHint}</p> <Badge variant={project.isPublished ? "success" : "warning"}>
</div> {project.isPublished ? "Published" : "Draft"}
{isFiltered ? ( </Badge>
<Button asChild variant="outline"> <Badge variant="outline">{project.viewMode}</Badge>
<Link href={getAdminAppPath("/portfolio")}>{copy.all}</Link> </div>
</Button> <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>
<Button asChild> <span>{project.projectYear}</span>
<Link href={getAdminAppPath("/portfolio/projects/new")}> </div>
<Plus className="h-4 w-4" /> </div>
{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 ( <div className="flex flex-wrap gap-3">
<TableRow key={project.id}> <Button asChild variant="outline">
<TableCell> <Link
<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"> href={getLocalizedPath("de", `/portfolio/${project.slug}`, siteSettings.defaultLocale)}
{project.coverImagePath ? ( target="_blank"
<img rel="noreferrer"
src={project.coverImagePath} >
alt={title} <ExternalLink className="h-4 w-4" />
className="h-full w-full object-cover" {copy.openProject}
/> </Link>
) : ( </Button>
<ImageOff className="h-4 w-4" /> <PortfolioProjectActions projectId={project.id} />
)} </div>
</div> </CardContent>
</TableCell> </AppCard>
</MotionFade>
))}
<TableCell> {projects.length === 0 ? (
<div className="flex min-w-0 flex-col"> <AppCard layer="single" className="xl:col-span-2">
<span className="inline-flex items-center gap-1.5 font-medium text-foreground"> <CardContent className="p-6 text-sm text-muted-foreground">
<span className="truncate">{title}</span> {copy.empty}
{project.isFeatured ? ( </CardContent>
<Star </AppCard>
className="h-3.5 w-3.5 shrink-0 text-brand-primary" ) : null}
fill="currentColor" </div>
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,
)}
target="_blank"
rel="noreferrer"
>
<ExternalLink className="h-4 w-4" />
<span className="sr-only">{copy.view}</span>
</Link>
</Button>
<PortfolioProjectActions projectId={project.id} />
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</Card>
)}
</div> </div>
); );
} }
-278
View File
@@ -1,278 +0,0 @@
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 ( return (
<> <>
<AppCard level={2} padding="sm"> <AppCard level={2} layer="single" padding="sm">
<div className="grid gap-4 lg:grid-cols-[180px_minmax(0,1fr)]"> <div className="grid gap-4 lg:grid-cols-[180px_minmax(0,1fr)]">
<div className="space-y-2"> <div className="space-y-2">
<p className="text-sm font-semibold text-foreground">{title}</p> <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"> <div className="grid gap-5 lg:grid-cols-3">
{localeFields.map((locale) => ( {localeFields.map((locale) => (
<AppCard key={locale.key} level={2} padding="sm" contentClassName="space-y-4"> <AppCard key={locale.key} level={2} layer="single" padding="sm" className="space-y-4">
<p className="text-sm font-semibold text-foreground">{locale.label}</p> <p className="text-sm font-semibold text-foreground">{locale.label}</p>
<div className="space-y-2"> <div className="space-y-2">
@@ -558,7 +558,7 @@ export function SiteSettingsForm({
</p> </p>
</div> </div>
<AppCard level={2} padding="sm" contentClassName="space-y-4"> <AppCard level={2} layer="single" padding="sm" className="space-y-4">
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_220px]"> <div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_220px]">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="primaryColor" className="sr-only">Primary Color</Label> <Label htmlFor="primaryColor" className="sr-only">Primary Color</Label>
@@ -679,7 +679,7 @@ export function SiteSettingsForm({
</p> </p>
</div> </div>
<AppCard level={2} padding="sm" contentClassName="space-y-4"> <AppCard level={2} layer="single" padding="sm" className="space-y-4">
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]"> <div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="defaultLocaleTrigger" className="sr-only">Default Locale</Label> <Label htmlFor="defaultLocaleTrigger" className="sr-only">Default Locale</Label>
@@ -729,7 +729,7 @@ export function SiteSettingsForm({
<section className="space-y-4"> <section className="space-y-4">
{mode === "brand" ? ( {mode === "brand" ? (
<> <>
<AppCard level={2} padding="sm" contentClassName="space-y-2"> <AppCard level={2} layer="single" padding="sm" className="space-y-2">
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Search Result</p> <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="rounded-nested border border-border/70 bg-background p-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -750,7 +750,7 @@ export function SiteSettingsForm({
</div> </div>
</AppCard> </AppCard>
<AppCard level={2} padding="sm" contentClassName="space-y-2"> <AppCard level={2} layer="single" padding="sm" className="space-y-2">
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Brand Assets</p> <p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Brand Assets</p>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<AppCard level={1} layer="single" padding="sm"> <AppCard level={1} layer="single" padding="sm">
@@ -773,7 +773,7 @@ export function SiteSettingsForm({
</div> </div>
</AppCard> </AppCard>
<AppCard level={2} padding="sm" contentClassName="space-y-2"> <AppCard level={2} layer="single" padding="sm" className="space-y-2">
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Social Preview</p> <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="overflow-hidden rounded-nested border border-border/70 bg-background">
<div className="h-1.5" style={{ backgroundColor: settings.brand.primaryColor }} /> <div className="h-1.5" style={{ backgroundColor: settings.brand.primaryColor }} />
@@ -797,7 +797,7 @@ export function SiteSettingsForm({
</> </>
) : ( ) : (
<> <>
<AppCard level={2} padding="sm" contentClassName="space-y-2"> <AppCard level={2} layer="single" padding="sm" className="space-y-2">
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Visitor Routing</p> <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="rounded-nested border border-border/70 bg-background p-3">
<div className="flex items-center gap-2 text-sm font-medium text-foreground"> <div className="flex items-center gap-2 text-sm font-medium text-foreground">
@@ -810,7 +810,7 @@ export function SiteSettingsForm({
</div> </div>
</AppCard> </AppCard>
<AppCard level={2} padding="sm" contentClassName="space-y-2"> <AppCard level={2} layer="single" padding="sm" className="space-y-2">
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Locale Summary</p> <p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Locale Summary</p>
<div className="rounded-nested border border-border/70 bg-background"> <div className="rounded-nested border border-border/70 bg-background">
<Table> <Table>
+2 -2
View File
@@ -25,7 +25,7 @@ export function SMTPSettingsForm({
return ( return (
<form id="smtp-settings-form" action={action} className="space-y-6"> <form id="smtp-settings-form" action={action} className="space-y-6">
<div className="grid gap-6 xl:grid-cols-2"> <div className="grid gap-6 xl:grid-cols-2">
<AppCard> <AppCard layer="single">
<CardHeader> <CardHeader>
<CardTitle>SMTP Connection</CardTitle> <CardTitle>SMTP Connection</CardTitle>
<CardDescription>Server und Login.</CardDescription> <CardDescription>Server und Login.</CardDescription>
@@ -109,7 +109,7 @@ export function SMTPSettingsForm({
</CardContent> </CardContent>
</AppCard> </AppCard>
<AppCard> <AppCard layer="single">
<CardHeader> <CardHeader>
<CardTitle>Sender And Recipients</CardTitle> <CardTitle>Sender And Recipients</CardTitle>
<CardDescription>Absender und Ziele.</CardDescription> <CardDescription>Absender und Ziele.</CardDescription>
+3 -3
View File
@@ -17,7 +17,7 @@ export function WorkspaceHero({
aside?: ReactNode; aside?: ReactNode;
}) { }) {
return ( return (
<AppCard level={3}> <AppCard level={3} layer="single">
<CardContent className="flex flex-col gap-4 p-6 lg:flex-row lg:items-end lg:justify-between lg:p-8"> <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"> <div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground"> <p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
@@ -40,7 +40,7 @@ export function WorkspaceSidebarPanel({
children: ReactNode; children: ReactNode;
}) { }) {
return ( return (
<AppCard level={2}> <AppCard level={2} layer="single">
<CardHeader className="pb-4"> <CardHeader className="pb-4">
<CardTitle className="text-base">{title}</CardTitle> <CardTitle className="text-base">{title}</CardTitle>
</CardHeader> </CardHeader>
@@ -100,7 +100,7 @@ export function WorkspaceLocaleCard({
children: ReactNode; children: ReactNode;
}) { }) {
return ( return (
<AppCard level={2}> <AppCard level={2} layer="single">
<CardHeader className="pb-4"> <CardHeader className="pb-4">
<CardTitle className="text-lg">{title}</CardTitle> <CardTitle className="text-lg">{title}</CardTitle>
<CardDescription>{hint}</CardDescription> <CardDescription>{hint}</CardDescription>
+7 -6
View File
@@ -1,4 +1,5 @@
import { StackedMarqueeSection, type MarqueeRow } from "@/components/layout/stacked-marquee-section"; import { StackedMarqueeSection, type MarqueeRow } from "@/components/layout/stacked-marquee-section";
import { AppCard } from "@/components/ui/app-card";
import { SectionHeading } from "./section-heading"; import { SectionHeading } from "./section-heading";
import type { MarqueeSectionCopy } from "./types"; import type { MarqueeSectionCopy } from "./types";
@@ -9,18 +10,18 @@ type MarqueeSectionProps = {
export function MarqueeSection({ copy, rows }: MarqueeSectionProps) { export function MarqueeSection({ copy, rows }: MarqueeSectionProps) {
return ( return (
<section className="space-y-8"> <section className="space-y-8 overflow-hidden">
<SectionHeading <SectionHeading
eyebrow={copy.eyebrow} eyebrow={copy.eyebrow}
title={copy.title} title={copy.title}
description={copy.description} description={copy.description}
/> />
{/* Full-bleed band: breaks out of the centered container to span the <AppCard className="overflow-hidden">
entire site width instead of sitting inside a card. */} <div className="rounded-nested border border-border/70 bg-background py-6">
<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" />
<StackedMarqueeSection rows={rows} className="py-0" /> </div>
</div> </AppCard>
</section> </section>
); );
} }
@@ -1,25 +0,0 @@
"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
@@ -1,149 +0,0 @@
"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>
);
}
+5 -25
View File
@@ -1,33 +1,13 @@
import { cn } from "@/lib/utils";
type HeroMotionBackdropProps = { type HeroMotionBackdropProps = {
compact?: boolean; compact?: boolean;
}; };
/** export function HeroMotionBackdrop({}: HeroMotionBackdropProps) {
* Layered ambient backdrop rendered behind every hero (home + page).
* Pure CSS so it needs no client JS: a faded grid for structure, a soft
* brand glow, slowly drifting brand orbs, a grain overlay for texture, and
* a bottom fade that blends the hero into the page content. The orb drift is
* disabled under `prefers-reduced-motion` (see globals.css).
*/
export function HeroMotionBackdrop({ compact = false }: HeroMotionBackdropProps) {
return ( return (
<div <div className="hero-sheet pointer-events-none absolute inset-0 overflow-hidden">
aria-hidden="true" <div className="hero-sheet-base" />
className={cn( <div className="hero-sheet-veil" />
"hero-backdrop pointer-events-none absolute inset-0 z-0 overflow-hidden", <div className="hero-sheet-fade" />
compact && "is-compact",
)}
>
<div className="hero-backdrop-base" />
<div className="hero-backdrop-grid" />
<div className="hero-backdrop-glow" />
<span className="hero-orb hero-orb-1" />
<span className="hero-orb hero-orb-2" />
<span className="hero-orb hero-orb-3" />
<div className="hero-noise hero-backdrop-noise" />
<div className="hero-backdrop-floor" />
</div> </div>
); );
} }
-47
View File
@@ -1,47 +0,0 @@
"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
@@ -1,189 +0,0 @@
"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>
);
}
+37 -13
View File
@@ -1,11 +1,30 @@
"use client";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { ArrowDown } from "lucide-react"; import { ArrowDown } from "lucide-react";
import { motion } from "framer-motion";
import { Container } from "@/components/layout/container"; import { Container } from "@/components/layout/container";
import { HeroMotionBackdrop } from "@/components/layout/hero-motion-backdrop"; import { HeroMotionBackdrop } from "@/components/layout/hero-motion-backdrop";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const heroItemVariants = {
hidden: { opacity: 0, y: 16 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.54, ease: [0.22, 1, 0.36, 1] as [number, number, number, number] },
},
};
const heroContainerVariants = {
hidden: {},
visible: {
transition: { staggerChildren: 0.13, delayChildren: 0.08 },
},
};
type HeroTone = "default" | "accent" | "soft"; type HeroTone = "default" | "accent" | "soft";
type HeroLineAlign = "start" | "center" | "end"; type HeroLineAlign = "start" | "center" | "end";
@@ -109,14 +128,6 @@ export function HeroShell({
{children} {children}
</div> </div>
</Container> </Container>
{variant === "home" ? (
<HeroScrollLink
href="#home-content"
label="Scroll to content"
className="hero-scroll-anchor hidden sm:inline-flex"
/>
) : null}
</section> </section>
); );
} }
@@ -125,14 +136,27 @@ export function HeroContentMotion({
children, children,
className, className,
}: HeroContentMotionProps) { }: HeroContentMotionProps) {
return <div className={cn("hero-content", className)}>{children}</div>; return (
<motion.div
className={className}
initial="hidden"
animate="visible"
variants={heroContainerVariants}
>
{children}
</motion.div>
);
} }
export function HeroMotionItem({ export function HeroMotionItem({
children, children,
className, className,
}: HeroContentMotionProps) { }: HeroContentMotionProps) {
return <div className={cn("hero-rise", className)}>{children}</div>; return (
<motion.div className={className} variants={heroItemVariants}>
{children}
</motion.div>
);
} }
export function HeroTitle({ export function HeroTitle({
@@ -143,7 +167,7 @@ export function HeroTitle({
}: HeroTitleProps) { }: HeroTitleProps) {
const isArabic = locale === "ar"; const isArabic = locale === "ar";
const titleClassName = cn( const titleClassName = cn(
"hero-title hero-rise text-balance font-semibold text-[hsl(var(--hero-ink))]", "text-balance font-semibold text-[hsl(var(--hero-ink))]",
variant === "home" variant === "home"
? isArabic ? isArabic
? "mt-6 flex w-full max-w-[26rem] flex-col gap-y-1 px-4 text-[clamp(2.85rem,11vw,5.9rem)] leading-[1.06] tracking-[-0.03em] sm:max-w-[30rem] sm:px-0 md:max-w-[36rem] lg:max-w-[32rem]" ? "mt-6 flex w-full max-w-[26rem] flex-col gap-y-1 px-4 text-[clamp(2.85rem,11vw,5.9rem)] leading-[1.06] tracking-[-0.03em] sm:max-w-[30rem] sm:px-0 md:max-w-[36rem] lg:max-w-[32rem]"
@@ -153,7 +177,7 @@ export function HeroTitle({
); );
return ( return (
<h1 className={titleClassName}> <motion.h1 className={titleClassName} variants={heroItemVariants}>
{lines.map((line, index) => ( {lines.map((line, index) => (
<span <span
key={`${index}-${line.text}`} key={`${index}-${line.text}`}
@@ -178,7 +202,7 @@ export function HeroTitle({
)} )}
</span> </span>
))} ))}
</h1> </motion.h1>
); );
} }
-14
View File
@@ -1,14 +0,0 @@
import { serializeJsonLd } from "@/lib/metadata";
/**
* Renders a JSON-LD `<script>` block. Server component only — the payload is
* serialized with `<` escaped so it can never break out of the script tag.
*/
export function JsonLd({ data }: { data: Record<string, unknown> }) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: serializeJsonLd(data) }}
/>
);
}
@@ -30,7 +30,7 @@ export function PortfolioCategoryFilter({
{categories.map((category) => ( {categories.map((category) => (
<CategoryLink <CategoryLink
key={category.id} key={category.id}
href={getLocalizedPath(locale, `/portfolio/${category.slug}`, defaultLocale)} href={getLocalizedPath(locale, `/portfolio/category/${category.slug}`, defaultLocale)}
label={getLocalizedValue(category.name, locale)} label={getLocalizedValue(category.name, locale)}
active={activeCategorySlug === category.slug} active={activeCategorySlug === category.slug}
/> />
+342 -348
View File
@@ -5,10 +5,10 @@ import {
Tag, Tag,
UserRound, UserRound,
} from "lucide-react"; } from "lucide-react";
import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { MotionFade } from "@/components/motion-fade"; import { MotionFade } from "@/components/motion-fade";
import { PortfolioCover } from "@/components/site/portfolio-cover";
import { AppCard } from "@/components/ui/app-card"; import { AppCard } from "@/components/ui/app-card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { CardContent } from "@/components/ui/card"; import { CardContent } from "@/components/ui/card";
@@ -16,6 +16,7 @@ import { getLocalizedPath, type AppLocale } from "@/lib/locale";
import { import {
getLocalizedValue, getLocalizedValue,
resolvePortfolioProjectViewMode, resolvePortfolioProjectViewMode,
type PortfolioAssetView,
type PortfolioProjectView, type PortfolioProjectView,
type PortfolioSectionView, type PortfolioSectionView,
} from "@/lib/portfolio"; } from "@/lib/portfolio";
@@ -27,76 +28,27 @@ type PortfolioProjectDetailProps = {
t: (key: "back" | "preview" | "openLink" | "gallery" | "download") => string; t: (key: "back" | "preview" | "openLink" | "gallery" | "download") => string;
}; };
type ProjectImage = { function PortfolioImage({
key: string; src,
src: string | null; alt,
label: string; className,
}; width,
height,
/**
* 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,
}: { }: {
image: ProjectImage; src: string;
aspect?: string; alt: string;
chrome?: boolean; className: string;
priority?: boolean; width: number;
height: number;
}) { }) {
return ( return (
<figure className="overflow-hidden rounded-surface border border-border bg-surface-2 shadow-card"> <Image
{chrome ? ( src={src}
<div className="flex items-center gap-2 border-b border-border bg-surface-3 px-4 py-3"> alt={alt}
<span className="h-2.5 w-2.5 rounded-full bg-border-strong" /> width={width}
<span className="h-2.5 w-2.5 rounded-full bg-border-strong" /> height={height}
<span className="h-2.5 w-2.5 rounded-full bg-border-strong" /> className={className}
<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>
); );
} }
@@ -108,10 +60,22 @@ function ProjectMeta({
locale: AppLocale; locale: AppLocale;
}) { }) {
const metadata = [ const metadata = [
{ icon: Tag, label: getLocalizedValue(item.category.name, locale) }, {
{ icon: CalendarDays, label: String(item.projectYear) }, icon: Tag,
{ icon: FolderKanban, label: getLocalizedValue(item.serviceLabel, locale) }, label: getLocalizedValue(item.category.name, locale),
{ icon: UserRound, label: item.clientName }, },
{
icon: CalendarDays,
label: String(item.projectYear),
},
{
icon: FolderKanban,
label: getLocalizedValue(item.serviceLabel, locale),
},
{
icon: UserRound,
label: item.clientName,
},
].filter((entry) => entry.label); ].filter((entry) => entry.label);
return ( return (
@@ -120,111 +84,19 @@ function ProjectMeta({
const Icon = meta.icon; const Icon = meta.icon;
return ( return (
<span <AppCard key={`${meta.label}-${Icon.name}`} level={2}>
key={`${meta.label}-${Icon.name}`} <CardContent className="flex items-center gap-2 p-3">
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}
<Icon className="h-4 w-4 text-brand-primary" /> </CardContent>
{meta.label} </AppCard>
</span>
); );
})} })}
</div> </div>
); );
} }
function BackLink({ function SectionBlock({
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, section,
locale, locale,
t, t,
@@ -236,204 +108,326 @@ function TextSection({
const title = getLocalizedValue(section.title, locale); const title = getLocalizedValue(section.title, locale);
const body = getLocalizedValue(section.body, 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"
/>
) : 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 ? (
<Button asChild variant="outline">
<Link href={section.linkUrl} target="_blank" rel="noreferrer">
{t("openLink")}
<ArrowUpRight className="h-4 w-4" />
</Link>
</Button>
) : null}
</div>
);
}
return ( return (
<div className="space-y-3"> <div className="space-y-4">
<h3 className="text-h3 text-foreground">{title}</h3> <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>
<p className="whitespace-pre-line text-body leading-7 text-muted-foreground">{body}</p>
) : null}
{section.type === "LINK" && section.linkUrl ? (
<Button asChild variant="outline">
<Link href={section.linkUrl} target="_blank" rel="noreferrer">
{t("openLink")}
<ArrowUpRight className="h-4 w-4" />
</Link>
</Button>
) : null}
</div> </div>
); );
} }
/* ============================================================ function AssetGallery({
WEB (viewMode: CASE_STUDY) assets,
Two columns: stacked full screenshots + sticky info panel. locale,
============================================================ */ t,
function WebTemplate({ item, locale, defaultLocale, t }: PortfolioProjectDetailProps) { }: {
const images = collectImages(item, locale); assets: PortfolioAssetView[];
const texts = textSections(item); locale: AppLocale;
t: PortfolioProjectDetailProps["t"];
}) {
if (assets.length === 0) {
return null;
}
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 (
<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>
</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>
<CardContent className="p-5 lg:p-6">
<SectionBlock 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} />
</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),
);
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<MotionFade> <ProjectHeader item={item} locale={locale} defaultLocale={defaultLocale} t={t} />
<div className="space-y-6">
<BackLink locale={locale} defaultLocale={defaultLocale} t={t} />
<ProjectIntro item={item} locale={locale} />
</div>
</MotionFade>
<div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_340px] lg:items-start"> <div className="grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_420px]">
<div className="order-2 space-y-6 lg:order-1"> <div className="space-y-6">
{images.map((image, index) => ( {item.coverImagePath ? (
<MotionFade key={image.key} delay={0.04 * index}> <MotionFade delay={0.04}>
<MediaFrame image={image} chrome aspect="aspect-[16/11]" priority={index === 0} /> <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"
/>
</CardContent>
</AppCard>
</MotionFade>
) : null}
{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"}
</p>
<SectionBlock section={section} locale={locale} t={t} />
</CardContent>
</AppCard>
</MotionFade> </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">
<TextSection section={section} locale={locale} t={t} />
</CardContent>
</AppCard>
))}
</div>
) : null}
</div> </div>
<aside className="order-1 lg:order-2">
<InfoPanel item={item} locale={locale} t={t} />
</aside>
</div>
</div>
);
}
/* ============================================================
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-10">
<MotionFade>
<div className="space-y-6"> <div className="space-y-6">
<BackLink locale={locale} defaultLocale={defaultLocale} t={t} /> <MotionFade delay={0.08}>
<ProjectIntro item={item} locale={locale} /> <AppCard level={2} className="sticky top-24">
<div className="flex flex-wrap items-center gap-3"> <CardContent className="space-y-5 p-6">
<ProjectMeta item={item} locale={locale} /> <div>
<PreviewButton item={item} t={t} /> <p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">Case Study Snapshot</p>
</div> <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">
{getLocalizedValue(item.summary, locale)}
</p>
<ProjectMeta item={item} locale={locale} />
</CardContent>
</AppCard>
</MotionFade>
</div> </div>
</MotionFade> </div>
<MotionFade delay={0.06}> {restSections.length > 0 ? (
<div className="grid auto-rows-[minmax(0,1fr)] grid-cols-2 gap-4 md:grid-cols-3"> <div className="grid gap-4 lg:grid-cols-2">
{images.map((image, index) => { {restSections.map((section, index) => (
// First image spans a large feature tile; the rest alternate size. <MotionFade key={section.id} delay={0.1 + index * 0.04}>
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> <AppCard>
<CardContent className="p-6"> <CardContent className="p-5 lg:p-6">
<TextSection section={section} locale={locale} t={t} /> <SectionBlock section={section} locale={locale} t={t} />
</CardContent> </CardContent>
</AppCard> </AppCard>
</MotionFade> </MotionFade>
))} ))}
</div> </div>
) : null} ) : null}
</div>
);
}
/* ============================================================ <AssetGallery assets={item.assets} locale={locale} t={t} />
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>
<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>
<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>
</MotionFade>
<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>
);
}
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> </div>
); );
} }
@@ -447,12 +441,12 @@ export function PortfolioProjectDetail({
const viewMode = resolvePortfolioProjectViewMode(item.viewMode); const viewMode = resolvePortfolioProjectViewMode(item.viewMode);
if (viewMode === "STORY") { if (viewMode === "STORY") {
return <EditorialTemplate item={item} locale={locale} defaultLocale={defaultLocale} t={t} />; return <StoryTemplate item={item} locale={locale} defaultLocale={defaultLocale} t={t} />;
} }
if (viewMode === "CASE_STUDY") { if (viewMode === "CASE_STUDY") {
return <WebTemplate item={item} locale={locale} defaultLocale={defaultLocale} t={t} />; return <CaseStudyTemplate item={item} locale={locale} defaultLocale={defaultLocale} t={t} />;
} }
return <PrintTemplate item={item} locale={locale} defaultLocale={defaultLocale} t={t} />; return <GridTemplate item={item} locale={locale} defaultLocale={defaultLocale} t={t} />;
} }
+1 -14
View File
@@ -87,21 +87,10 @@ export interface AppCardProps
VariantProps<typeof appCardVariants>, VariantProps<typeof appCardVariants>,
VariantProps<typeof appCardInnerVariants> { VariantProps<typeof appCardInnerVariants> {
layer?: "double" | "single"; 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>( 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") { if (layer === "single") {
return ( return (
<Card <Card
@@ -113,7 +102,6 @@ const AppCard = React.forwardRef<HTMLDivElement, AppCardProps>(
interactive, interactive,
}), }),
className, className,
contentClassName,
)} )}
{...props} {...props}
> >
@@ -141,7 +129,6 @@ const AppCard = React.forwardRef<HTMLDivElement, AppCardProps>(
padding, padding,
interactive, interactive,
}), }),
contentClassName,
)} )}
> >
{children} {children}
-152
View File
@@ -1,152 +0,0 @@
"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 };
+1 -7
View File
@@ -98,28 +98,22 @@ function TabsTrigger({
type TabsContentProps = React.HTMLAttributes<HTMLDivElement> & { type TabsContentProps = React.HTMLAttributes<HTMLDivElement> & {
value: string; 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({ function TabsContent({
className, className,
value, value,
children, children,
forceMount = false,
...props ...props
}: TabsContentProps) { }: TabsContentProps) {
const { value: activeValue } = useTabsContext(); const { value: activeValue } = useTabsContext();
const isActive = activeValue === value;
if (!isActive && !forceMount) { if (activeValue !== value) {
return null; return null;
} }
return ( return (
<div <div
hidden={!isActive}
className={cn( className={cn(
"mt-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0", "mt-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0",
className, className,
+3 -23
View File
@@ -12,9 +12,8 @@ services:
NEXT_TELEMETRY_DISABLED: "1" NEXT_TELEMETRY_DISABLED: "1"
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-https://mohfarawati.de} NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-https://mohfarawati.de}
NEXT_PUBLIC_ADMIN_URL: ${NEXT_PUBLIC_ADMIN_URL:-https://root.mohfarawati.de} NEXT_PUBLIC_ADMIN_URL: ${NEXT_PUBLIC_ADMIN_URL:-https://root.mohfarawati.de}
ADMIN_HOST: ${ADMIN_HOST:-root.mohfarawati.de}
SITE_RUNTIME_ORIGIN: ${SITE_RUNTIME_ORIGIN:-http://127.0.0.1:3000} SITE_RUNTIME_ORIGIN: ${SITE_RUNTIME_ORIGIN:-http://127.0.0.1:3000}
DATABASE_URL: postgresql://postgres:postgres@db:5432/moh_sass DATABASE_URL: postgresql://postgres:postgres@db:5432/moh_sass?schema=public
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD must be set in .env} ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD must be set in .env}
ADMIN_AUTH_SECRET: ${ADMIN_AUTH_SECRET:?ADMIN_AUTH_SECRET must be set in .env (use a long random string)} ADMIN_AUTH_SECRET: ${ADMIN_AUTH_SECRET:?ADMIN_AUTH_SECRET must be set in .env (use a long random string)}
ADMIN_BASIC_AUTH_USER: ${ADMIN_BASIC_AUTH_USER:?ADMIN_BASIC_AUTH_USER must be set in .env} ADMIN_BASIC_AUTH_USER: ${ADMIN_BASIC_AUTH_USER:?ADMIN_BASIC_AUTH_USER must be set in .env}
@@ -28,25 +27,11 @@ services:
timeout: 5s timeout: 5s
retries: 10 retries: 10
start_period: 20s start_period: 20s
labels: ports:
- traefik.enable=true - "${PORT:-3014}:3000"
- traefik.docker.network=${TRAEFIK_NETWORK:-proxy}
# HTTPS router — public site + www + admin subdomain all hit this one app;
# the app's middleware routes root.mohfarawati.de to the admin internally.
- "traefik.http.routers.sass.rule=Host(`mohfarawati.de`) || Host(`www.mohfarawati.de`) || Host(`root.mohfarawati.de`)"
- traefik.http.routers.sass.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}
- traefik.http.routers.sass.tls=true
- traefik.http.routers.sass.tls.certresolver=${TRAEFIK_CERTRESOLVER:-cf}
- traefik.http.services.sass.loadbalancer.server.port=3000
# HTTP router → redirect to HTTPS
- "traefik.http.routers.sass-http.rule=Host(`mohfarawati.de`) || Host(`www.mohfarawati.de`) || Host(`root.mohfarawati.de`)"
- traefik.http.routers.sass-http.entrypoints=web
- traefik.http.routers.sass-http.middlewares=sass-redirect
- traefik.http.middlewares.sass-redirect.redirectscheme.scheme=https
volumes: volumes:
- media_uploads:/app/public/uploads/media - media_uploads:/app/public/uploads/media
networks: networks:
- proxy
- appnet - appnet
db: db:
@@ -72,8 +57,3 @@ volumes:
networks: networks:
appnet: appnet:
# Shared external Traefik network (same one the other projects use).
# Override the name per server with TRAEFIK_NETWORK in .env.
proxy:
external: true
name: ${TRAEFIK_NETWORK:-proxy}
+3 -9
View File
@@ -181,8 +181,8 @@ i18n/routing.ts
Admin routing Admin routing
lib/admin-routing.ts lib/admin-routing.ts
Database access (Drizzle) Prisma access
lib/db/index.ts, lib/db/schema.ts lib/prisma.ts
Application configuration Application configuration
lib/app-config.ts lib/app-config.ts
@@ -191,13 +191,7 @@ Portfolio logic
lib/portfolio.ts lib/portfolio.ts
Media handling Media handling
lib/media.ts (DB), lib/media-storage.ts (filesystem + content validation), lib/media-service.ts lib/media.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
--- ---
+4 -11
View File
@@ -14,11 +14,8 @@
- Contact form with: - Contact form with:
- validation - validation
- email delivery - email delivery
- Success page after contact submission (noindex) - Success page after contact submission
- Maintenance redirect flow (bypass requires a *signed* admin session cookie) - Maintenance redirect flow
- SEO: localized metadata with canonical + hreflang, OG/Twitter cards (project
cover as share image), JSON-LD (WebSite + Person/Organization, CreativeWork per
project), dynamic `robots.txt` and hreflang `sitemap.xml` — see `docs/SEO.md`
### Admin ### Admin
@@ -27,12 +24,8 @@
- Portfolio category management - Portfolio category management
- Portfolio project creation and editing - Portfolio project creation and editing
- Section and asset management inside each project - Section and asset management inside each project
- Media library with usage bindings (uploads are magic-byte checked, SVGs are - Media library with usage bindings
sanitized and served sandboxed) - Site settings management
- Site settings management (Brand, Localization, SEO)
- SEO page: indexing switch, Search Console/Bing verification, X handle,
structured-data identity, per-locale keywords, readiness checklist and links
to sitemap/robots/manifest
- SMTP settings and test email - SMTP settings and test email
- Marquee settings - Marquee settings
- Maintenance toggle - Maintenance toggle
-83
View File
@@ -1,83 +0,0 @@
# 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.
+6 -15
View File
@@ -1,22 +1,13 @@
import { existsSync } from "node:fs"; import type { Config } from "drizzle-kit";
import { defineConfig } from "drizzle-kit"; const rawConnectionString =
process.env.DATABASE_URL ?? "postgresql://postgres:postgres@localhost:5432/moh_sass";
// drizzle-kit (unlike the Next.js app) does not load .env automatically, so export default {
// 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", schema: "./lib/db/schema.ts",
out: "./lib/db/migrations", out: "./lib/db/migrations",
dialect: "postgresql", dialect: "postgresql",
dbCredentials: { dbCredentials: {
url: url: rawConnectionString.split("?")[0],
process.env.DATABASE_URL ??
"postgresql://postgres:postgres@localhost:5432/moh_sass",
}, },
}); } satisfies Config;
+1 -1
View File
@@ -17,7 +17,7 @@ const config = [
}, },
}, },
{ {
ignores: ["prisma/seed.js", "scripts/legacy-prisma-seed.cjs"], ignores: ["prisma/seed.js"],
}, },
]; ];
+38 -21
View File
@@ -1,19 +1,14 @@
import { createHash, timingSafeEqual } from "crypto"; import { createHash, createHmac, timingSafeEqual } from "crypto";
import { and, eq, like, lt } from "drizzle-orm";
import { cookies, headers } from "next/headers"; import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { and, eq, like, lt } from "drizzle-orm";
import { db } from "./db"; import { db } from "./db";
import { appConfig } from "./db/schema"; import { appConfig } from "./db/schema";
import { getAdminAppPath } from "./admin-routing"; import { getAdminAppPath } from "./admin-routing";
import {
ADMIN_SESSION_COOKIE as SHARED_ADMIN_SESSION_COOKIE,
buildAdminSessionToken,
verifyAdminSessionToken,
} from "./admin-session-token";
export const ADMIN_SESSION_COOKIE = SHARED_ADMIN_SESSION_COOKIE; export const ADMIN_SESSION_COOKIE = "moh_admin_session";
const ADMIN_SESSION_VALUE = "superadmin";
const MAX_FAILED_ATTEMPTS = 5; const MAX_FAILED_ATTEMPTS = 5;
const LOCKOUT_SECONDS = 15 * 60; const LOCKOUT_SECONDS = 15 * 60;
const ADMIN_LOCKOUT_KEY_PREFIX = "admin_lockout"; const ADMIN_LOCKOUT_KEY_PREFIX = "admin_lockout";
@@ -95,12 +90,34 @@ function getAdminCookieDomain(): string | undefined {
return hostname ? `.${hostname}` : undefined; return hostname ? `.${hostname}` : undefined;
} }
function signValue(value: string): string {
return createHmac("sha256", getSecret()).update(value).digest("hex");
}
function buildToken(): string { function buildToken(): string {
return buildAdminSessionToken(); return `${ADMIN_SESSION_VALUE}.${signValue(ADMIN_SESSION_VALUE)}`;
} }
function verifyToken(token: string): boolean { function verifyToken(token: string): boolean {
return verifyAdminSessionToken(token); 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);
} }
async function getClientIp(): Promise<string> { async function getClientIp(): Promise<string> {
@@ -124,7 +141,9 @@ async function cleanupExpiredLockouts(): Promise<void> {
const cutoff = new Date(Date.now() - LOCKOUT_SECONDS * 2 * 1000); const cutoff = new Date(Date.now() - LOCKOUT_SECONDS * 2 * 1000);
await db await db
.delete(appConfig) .delete(appConfig)
.where(and(like(appConfig.key, `${ADMIN_LOCKOUT_KEY_PREFIX}:%`), lt(appConfig.updatedAt, cutoff))); .where(
and(like(appConfig.key, `${ADMIN_LOCKOUT_KEY_PREFIX}:%`), lt(appConfig.updatedAt, cutoff)),
);
} catch { } catch {
// Non-critical — ignore cleanup errors. // Non-critical — ignore cleanup errors.
} }
@@ -199,12 +218,12 @@ export async function getAdminLockState(): Promise<{ locked: boolean; remainingS
try { try {
const ip = await getClientIp(); const ip = await getClientIp();
const key = getLockoutKey(ip); const key = getLockoutKey(ip);
const [config] = await db const rows = await db
.select({ value: appConfig.value }) .select({ value: appConfig.value })
.from(appConfig) .from(appConfig)
.where(eq(appConfig.key, key)) .where(eq(appConfig.key, key))
.limit(1); .limit(1);
const state = parseFailState(config?.value); const state = parseFailState(rows[0]?.value);
const now = Date.now(); const now = Date.now();
if (state.lockUntil > now) { if (state.lockUntil > now) {
@@ -228,26 +247,24 @@ export async function registerFailedAdminAttempt(): Promise<{ locked: boolean; r
await cleanupExpiredLockouts(); await cleanupExpiredLockouts();
const [config] = await db const rows = await db
.select({ value: appConfig.value }) .select({ value: appConfig.value })
.from(appConfig) .from(appConfig)
.where(eq(appConfig.key, key)) .where(eq(appConfig.key, key))
.limit(1); .limit(1);
const current = parseFailState(config?.value); const current = parseFailState(rows[0]?.value);
// If a previous lockout has expired, reset the counter. // If a previous lockout has expired, reset the counter.
const baseAttempts = current.lockUntil > 0 && current.lockUntil < now ? 0 : current.attempts; const baseAttempts = current.lockUntil > 0 && current.lockUntil < now ? 0 : current.attempts;
const attempts = baseAttempts + 1; const attempts = baseAttempts + 1;
const locked = attempts >= MAX_FAILED_ATTEMPTS; const locked = attempts >= MAX_FAILED_ATTEMPTS;
const lockUntil = locked ? now + LOCKOUT_SECONDS * 1000 : 0; const lockUntil = locked ? now + LOCKOUT_SECONDS * 1000 : 0;
const value = JSON.stringify({ attempts, lockUntil });
await db await db
.insert(appConfig) .insert(appConfig)
.values({ key, value: JSON.stringify({ attempts, lockUntil }) }) .values({ key, value })
.onConflictDoUpdate({ .onConflictDoUpdate({ target: appConfig.key, set: { value, updatedAt: new Date() } });
target: appConfig.key,
set: { value: JSON.stringify({ attempts, lockUntil }) },
});
return { return {
locked, locked,
+1 -9
View File
@@ -7,7 +7,6 @@ import {
Mail, Mail,
Palette, Palette,
PlusSquare, PlusSquare,
Search,
ShieldAlert, ShieldAlert,
SwatchBook, SwatchBook,
Tags, Tags,
@@ -25,7 +24,6 @@ type AdminNavigationCopy = {
siteSettings: string; siteSettings: string;
brandSettings?: string; brandSettings?: string;
localizationSettings?: string; localizationSettings?: string;
seoSettings?: string;
marquee?: string; marquee?: string;
smtp?: string; smtp?: string;
}; };
@@ -43,7 +41,7 @@ export function getAdminNavigation(
copy: AdminNavigationCopy, copy: AdminNavigationCopy,
active: "overview" | "maintenance" | "ui-kit" | "portfolio" | "media" | "site-settings" | "smtp" | "marquee", active: "overview" | "maintenance" | "ui-kit" | "portfolio" | "media" | "site-settings" | "smtp" | "marquee",
portfolioChild?: "overview" | "projects" | "new-project" | "categories", portfolioChild?: "overview" | "projects" | "new-project" | "categories",
siteSettingsChild?: "brand" | "localization" | "seo", siteSettingsChild?: "brand" | "localization",
): AdminNavItem[] { ): AdminNavItem[] {
return [ return [
{ {
@@ -89,12 +87,6 @@ export function getAdminNavigation(
icon: Languages, icon: Languages,
active: siteSettingsChild === "localization", active: siteSettingsChild === "localization",
}, },
{
label: copy.seoSettings ?? "SEO",
href: getAdminAppPath("/site-settings/seo"),
icon: Search,
active: siteSettingsChild === "seo",
},
], ],
}, },
{ {
-47
View File
@@ -1,47 +0,0 @@
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);
}
+68 -69
View File
@@ -1,7 +1,7 @@
import { and, eq, inArray } from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
import { db } from "./db"; import { db } from "./db";
import { appConfig, mediaAsset, mediaUsage } from "./db/schema"; import { appConfig, mediaUsage } from "./db/schema";
export const MAINTENANCE_MODE_KEY = "maintenance_mode"; export const MAINTENANCE_MODE_KEY = "maintenance_mode";
export { export {
SITE_NAME_KEY, SITE_NAME_KEY,
@@ -67,42 +67,37 @@ import {
syncMarqueeSettingsToGermanSource, syncMarqueeSettingsToGermanSource,
type MarqueeSettings, type MarqueeSettings,
} from "./marquee-settings"; } from "./marquee-settings";
import { SEO_SETTINGS_KEY, buildDefaultSeoSettings, parseSeoSettingsValue, type SeoSettings } from "./seo-settings";
export {
SEO_SETTINGS_KEY,
buildDefaultSeoSettings,
parseSeoSettingsValue,
type SeoSettings,
} from "./seo-settings";
// Small helpers over the app_config key/value table (Drizzle). async function getAppConfigValue(key: string): Promise<string | undefined> {
async function readConfigValue(key: string): Promise<string | undefined> { const rows = await db
const [row] = await db
.select({ value: appConfig.value }) .select({ value: appConfig.value })
.from(appConfig) .from(appConfig)
.where(eq(appConfig.key, key)) .where(eq(appConfig.key, key))
.limit(1); .limit(1);
return row?.value; return rows[0]?.value;
} }
async function upsertConfig(key: string, value: string): Promise<void> { async function upsertAppConfigValue(key: string, value: string): Promise<void> {
await db await db
.insert(appConfig) .insert(appConfig)
.values({ key, value }) .values({ key, value })
.onConflictDoUpdate({ target: appConfig.key, set: { value } }); .onConflictDoUpdate({
target: appConfig.key,
set: { value, updatedAt: new Date() },
});
} }
export async function getMaintenanceMode(): Promise<boolean> { export async function getMaintenanceMode(): Promise<boolean> {
try { try {
return (await readConfigValue(MAINTENANCE_MODE_KEY)) === "true"; return (await getAppConfigValue(MAINTENANCE_MODE_KEY)) === "true";
} catch { } catch {
return false; return false;
} }
} }
export async function setMaintenanceMode(enabled: boolean): Promise<void> { export async function setMaintenanceMode(enabled: boolean): Promise<void> {
await upsertConfig(MAINTENANCE_MODE_KEY, enabled ? "true" : "false"); await upsertAppConfigValue(MAINTENANCE_MODE_KEY, enabled ? "true" : "false");
} }
export async function getSiteSettings(): Promise<SiteSettings> { export async function getSiteSettings(): Promise<SiteSettings> {
@@ -122,12 +117,12 @@ export async function getSiteSettings(): Promise<SiteSettings> {
} }
export async function updateSiteSettings(settings: SiteSettings): Promise<void> { export async function updateSiteSettings(settings: SiteSettings): Promise<void> {
await upsertConfig(SITE_SETTINGS_KEY, JSON.stringify(settings)); await upsertAppConfigValue(SITE_SETTINGS_KEY, JSON.stringify(settings));
} }
export async function getMailSettings(): Promise<MailSettings> { export async function getMailSettings(): Promise<MailSettings> {
try { try {
return parseMailSettingsValue(await readConfigValue(MAIL_SETTINGS_KEY)); return parseMailSettingsValue(await getAppConfigValue(MAIL_SETTINGS_KEY));
} catch { } catch {
return buildDefaultMailSettings(); return buildDefaultMailSettings();
} }
@@ -140,12 +135,12 @@ export async function getMailSettingsFormValues(): Promise<MailSettingsFormValue
} }
export async function updateMailSettings(settings: MailSettings): Promise<void> { export async function updateMailSettings(settings: MailSettings): Promise<void> {
await upsertConfig(MAIL_SETTINGS_KEY, JSON.stringify(settings)); await upsertAppConfigValue(MAIL_SETTINGS_KEY, JSON.stringify(settings));
} }
export async function getMarqueeSettings(): Promise<MarqueeSettings> { export async function getMarqueeSettings(): Promise<MarqueeSettings> {
try { try {
return parseMarqueeSettingsValue(await readConfigValue(MARQUEE_SETTINGS_KEY)); return parseMarqueeSettingsValue(await getAppConfigValue(MARQUEE_SETTINGS_KEY));
} catch { } catch {
return buildDefaultMarqueeSettings(); return buildDefaultMarqueeSettings();
} }
@@ -154,64 +149,68 @@ export async function getMarqueeSettings(): Promise<MarqueeSettings> {
export async function updateMarqueeSettings(settings: MarqueeSettings): Promise<void> { export async function updateMarqueeSettings(settings: MarqueeSettings): Promise<void> {
const normalizedSettings = syncMarqueeSettingsToGermanSource(settings); const normalizedSettings = syncMarqueeSettingsToGermanSource(settings);
await upsertConfig(MARQUEE_SETTINGS_KEY, JSON.stringify(normalizedSettings)); await upsertAppConfigValue(MARQUEE_SETTINGS_KEY, JSON.stringify(normalizedSettings));
}
export async function getSeoSettings(): Promise<SeoSettings> {
try {
return parseSeoSettingsValue(await readConfigValue(SEO_SETTINGS_KEY));
} catch {
return buildDefaultSeoSettings();
}
}
export async function updateSeoSettings(settings: SeoSettings): Promise<void> {
await upsertConfig(SEO_SETTINGS_KEY, JSON.stringify(settings));
} }
export async function getSiteSettingsMediaBindings(): Promise<SiteSettingsMediaBindings> { export async function getSiteSettingsMediaBindings(): Promise<SiteSettingsMediaBindings> {
try { try {
const usages = await db const usages = await db.query.mediaUsage.findMany({
.select({ where: and(
fieldKey: mediaUsage.fieldKey, eq(mediaUsage.entityType, SITE_SETTINGS_ENTITY_TYPE),
updatedAt: mediaUsage.updatedAt, eq(mediaUsage.entityId, SITE_SETTINGS_ENTITY_ID),
assetId: mediaAsset.id, ),
assetUrl: mediaAsset.url, columns: {
}) fieldKey: true,
.from(mediaUsage) updatedAt: true,
.innerJoin(mediaAsset, eq(mediaAsset.id, mediaUsage.assetId)) },
.where( with: {
and( asset: {
eq(mediaUsage.entityType, SITE_SETTINGS_ENTITY_TYPE), columns: {
eq(mediaUsage.entityId, SITE_SETTINGS_ENTITY_ID), id: true,
), url: true,
); },
},
},
});
return usages.reduce<SiteSettingsMediaBindings>((result, usage) => { return usages.reduce<SiteSettingsMediaBindings>(
const binding = { (result, usage) => {
assetId: usage.assetId, if (usage.fieldKey === SITE_SETTINGS_LOGO_LIGHT_FIELD_KEY) {
url: usage.assetUrl, result.siteLogoLight = {
version: usage.updatedAt.toISOString(), assetId: usage.asset.id,
}; url: usage.asset.url,
version: usage.updatedAt.toISOString(),
};
}
if (usage.fieldKey === SITE_SETTINGS_LOGO_LIGHT_FIELD_KEY) { if (usage.fieldKey === SITE_SETTINGS_LOGO_DARK_FIELD_KEY) {
result.siteLogoLight = binding; result.siteLogoDark = {
} assetId: usage.asset.id,
url: usage.asset.url,
version: usage.updatedAt.toISOString(),
};
}
if (usage.fieldKey === SITE_SETTINGS_LOGO_DARK_FIELD_KEY) { if (usage.fieldKey === SITE_SETTINGS_FAVICON_FIELD_KEY) {
result.siteLogoDark = binding; result.favicon = {
} assetId: usage.asset.id,
url: usage.asset.url,
version: usage.updatedAt.toISOString(),
};
}
if (usage.fieldKey === SITE_SETTINGS_FAVICON_FIELD_KEY) { if (usage.fieldKey === SITE_SETTINGS_DEFAULT_OG_IMAGE_FIELD_KEY) {
result.favicon = binding; result.defaultOgImage = {
} assetId: usage.asset.id,
url: usage.asset.url,
version: usage.updatedAt.toISOString(),
};
}
if (usage.fieldKey === SITE_SETTINGS_DEFAULT_OG_IMAGE_FIELD_KEY) { return result;
result.defaultOgImage = binding; },
} getDefaultSiteSettingsMediaBindings(),
);
return result;
}, getDefaultSiteSettingsMediaBindings());
} catch { } catch {
return getDefaultSiteSettingsMediaBindings(); return getDefaultSiteSettingsMediaBindings();
} }
+54 -33
View File
@@ -1,35 +1,56 @@
import { // Shared enum values + types. NO server/ORM imports here — this file is safe to
mediaKind, // import from client components (replaces the old `@prisma/client` enum imports).
mediaSource, //
mediaUsageType, // Defined as `const object + union type` (the same shape Prisma generated) rather
portfolioAssetKind, // than a TS `enum`, so bare string literals like "IMAGE" stay assignable and
portfolioProjectViewMode, // `z.nativeEnum(...)` keeps working.
portfolioSectionType,
} from "./schema";
/** export const PortfolioSectionType = {
* Prisma-compatible enum objects + types, derived from the Drizzle pgEnums, so RICH_TEXT: "RICH_TEXT",
* existing consumers can keep writing `MediaKind.IMAGE` (value) and `: MediaKind` GALLERY: "GALLERY",
* (type) — only the import path changes from `@prisma/client` to `@/lib/db/enums`. STATS: "STATS",
*/ DELIVERABLES: "DELIVERABLES",
function asEnum<T extends string>(values: readonly T[]): { [K in T]: K } { LINK: "LINK",
return Object.fromEntries(values.map((v) => [v, v])) as { [K in T]: K }; } as const;
export type PortfolioSectionType = (typeof PortfolioSectionType)[keyof typeof PortfolioSectionType];
export const PortfolioAssetKind = {
IMAGE: "IMAGE",
DOCUMENT: "DOCUMENT",
} as const;
export type PortfolioAssetKind = (typeof PortfolioAssetKind)[keyof typeof PortfolioAssetKind];
export const PortfolioProjectViewMode = {
GRID: "GRID",
STORY: "STORY",
CASE_STUDY: "CASE_STUDY",
} as const;
export type PortfolioProjectViewMode =
(typeof PortfolioProjectViewMode)[keyof typeof PortfolioProjectViewMode];
export const MediaSource = {
UPLOAD: "UPLOAD",
EXTERNAL: "EXTERNAL",
} as const;
export type MediaSource = (typeof MediaSource)[keyof typeof MediaSource];
export const MediaKind = {
IMAGE: "IMAGE",
DOCUMENT: "DOCUMENT",
} as const;
export type MediaKind = (typeof MediaKind)[keyof typeof MediaKind];
export const MediaUsageType = {
PORTFOLIO_COVER: "PORTFOLIO_COVER",
PORTFOLIO_SECTION: "PORTFOLIO_SECTION",
PORTFOLIO_ASSET: "PORTFOLIO_ASSET",
GENERIC: "GENERIC",
} as const;
export type MediaUsageType = (typeof MediaUsageType)[keyof typeof MediaUsageType];
// Helper: enum-object -> tuple of its string values, for Drizzle pgEnum(...).
// Preserves the literal union (not widened to `string`) so pgEnum columns infer
// as the exact union type.
export function enumValues<T extends Record<string, string>>(e: T): [T[keyof T], ...T[keyof T][]] {
return Object.values(e) as [T[keyof T], ...T[keyof T][]];
} }
export const MediaKind = asEnum(mediaKind.enumValues);
export type MediaKind = (typeof mediaKind.enumValues)[number];
export const MediaSource = asEnum(mediaSource.enumValues);
export type MediaSource = (typeof mediaSource.enumValues)[number];
export const MediaUsageType = asEnum(mediaUsageType.enumValues);
export type MediaUsageType = (typeof mediaUsageType.enumValues)[number];
export const PortfolioAssetKind = asEnum(portfolioAssetKind.enumValues);
export type PortfolioAssetKind = (typeof portfolioAssetKind.enumValues)[number];
export const PortfolioProjectViewMode = asEnum(portfolioProjectViewMode.enumValues);
export type PortfolioProjectViewMode = (typeof portfolioProjectViewMode.enumValues)[number];
export const PortfolioSectionType = asEnum(portfolioSectionType.enumValues);
export type PortfolioSectionType = (typeof portfolioSectionType.enumValues)[number];
+9 -14
View File
@@ -3,26 +3,21 @@ import postgres from "postgres";
import * as schema from "./schema"; import * as schema from "./schema";
/** const rawConnectionString =
* The Drizzle database client (postgres.js driver), matching the house standard process.env.DATABASE_URL ?? "postgresql://postgres:postgres@localhost:5432/moh_sass";
* used by the other projects. Replaces the old Prisma client (`lib/prisma.ts`).
* A single connection is reused across hot reloads in dev. // Prisma allowed a `?schema=public` query param that postgres.js does not
*/ // understand — strip any unknown query string; `public` is the default schema.
// Strip any query string (e.g. a leftover Prisma `?schema=public`) — postgres.js const connectionString = rawConnectionString.split("?")[0];
// forwards unknown params to the server as startup options and Postgres rejects
// them ("unrecognized configuration parameter"). `public` is the default schema.
const connectionString = (
process.env.DATABASE_URL ?? "postgresql://postgres:postgres@localhost:5432/moh_sass"
).split("?")[0];
const globalForDb = globalThis as unknown as { const globalForDb = globalThis as unknown as {
dbClient: ReturnType<typeof postgres> | undefined; pgClient: ReturnType<typeof postgres> | undefined;
}; };
const client = globalForDb.dbClient ?? postgres(connectionString); const client = globalForDb.pgClient ?? postgres(connectionString, { max: 10 });
if (process.env.NODE_ENV !== "production") { if (process.env.NODE_ENV !== "production") {
globalForDb.dbClient = client; globalForDb.pgClient = client;
} }
export const db = drizzle(client, { schema }); export const db = drizzle(client, { schema });
@@ -0,0 +1,125 @@
CREATE TYPE "public"."MediaKind" AS ENUM('IMAGE', 'DOCUMENT');--> statement-breakpoint
CREATE TYPE "public"."MediaSource" AS ENUM('UPLOAD', 'EXTERNAL');--> statement-breakpoint
CREATE TYPE "public"."MediaUsageType" AS ENUM('PORTFOLIO_COVER', 'PORTFOLIO_SECTION', 'PORTFOLIO_ASSET', 'GENERIC');--> statement-breakpoint
CREATE TYPE "public"."PortfolioAssetKind" AS ENUM('IMAGE', 'DOCUMENT');--> statement-breakpoint
CREATE TYPE "public"."PortfolioProjectViewMode" AS ENUM('GRID', 'STORY', 'CASE_STUDY');--> statement-breakpoint
CREATE TYPE "public"."PortfolioSectionType" AS ENUM('RICH_TEXT', 'GALLERY', 'STATS', 'DELIVERABLES', 'LINK');--> statement-breakpoint
CREATE TABLE "AppConfig" (
"id" text PRIMARY KEY NOT NULL,
"key" text NOT NULL,
"value" text NOT NULL,
"createdAt" timestamp (3) DEFAULT now() NOT NULL,
"updatedAt" timestamp (3) DEFAULT now() NOT NULL,
CONSTRAINT "AppConfig_key_unique" UNIQUE("key")
);
--> statement-breakpoint
CREATE TABLE "Category" (
"id" text PRIMARY KEY NOT NULL,
"slug" text NOT NULL,
"nameAr" text NOT NULL,
"nameEn" text NOT NULL,
"nameDe" text NOT NULL,
"descriptionAr" text NOT NULL,
"descriptionEn" text NOT NULL,
"descriptionDe" text NOT NULL,
"sortOrder" integer DEFAULT 0 NOT NULL,
"isActive" boolean DEFAULT true NOT NULL,
"createdAt" timestamp (3) DEFAULT now() NOT NULL,
"updatedAt" timestamp (3) DEFAULT now() NOT NULL,
CONSTRAINT "Category_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE "MediaAsset" (
"id" text PRIMARY KEY NOT NULL,
"source" "MediaSource" NOT NULL,
"kind" "MediaKind" NOT NULL,
"url" text NOT NULL,
"fileName" text NOT NULL,
"label" text NOT NULL,
"altText" text,
"mimeType" text,
"size" integer,
"createdAt" timestamp (3) DEFAULT now() NOT NULL,
"updatedAt" timestamp (3) DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "MediaUsage" (
"id" text PRIMARY KEY NOT NULL,
"assetId" text NOT NULL,
"usageType" "MediaUsageType" NOT NULL,
"entityType" text NOT NULL,
"entityId" text NOT NULL,
"fieldKey" text NOT NULL,
"createdAt" timestamp (3) DEFAULT now() NOT NULL,
"updatedAt" timestamp (3) DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "PortfolioAsset" (
"id" text PRIMARY KEY NOT NULL,
"projectId" text NOT NULL,
"kind" "PortfolioAssetKind" NOT NULL,
"filePath" text NOT NULL,
"altAr" text NOT NULL,
"altEn" text NOT NULL,
"altDe" text NOT NULL,
"sortOrder" integer DEFAULT 0 NOT NULL,
"createdAt" timestamp (3) DEFAULT now() NOT NULL,
"updatedAt" timestamp (3) DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "PortfolioProject" (
"id" text PRIMARY KEY NOT NULL,
"categoryId" text NOT NULL,
"slug" text NOT NULL,
"viewMode" "PortfolioProjectViewMode" DEFAULT 'GRID' NOT NULL,
"titleAr" text NOT NULL,
"titleEn" text NOT NULL,
"titleDe" text NOT NULL,
"summaryAr" text NOT NULL,
"summaryEn" text NOT NULL,
"summaryDe" text NOT NULL,
"clientName" text NOT NULL,
"projectYear" integer NOT NULL,
"serviceLabelAr" text NOT NULL,
"serviceLabelEn" text NOT NULL,
"serviceLabelDe" text NOT NULL,
"previewUrl" text,
"coverImagePath" text,
"isFeatured" boolean DEFAULT false NOT NULL,
"isPublished" boolean DEFAULT false NOT NULL,
"publishedAt" timestamp (3),
"sortOrder" integer DEFAULT 0 NOT NULL,
"createdAt" timestamp (3) DEFAULT now() NOT NULL,
"updatedAt" timestamp (3) DEFAULT now() NOT NULL,
CONSTRAINT "PortfolioProject_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE "PortfolioSection" (
"id" text PRIMARY KEY NOT NULL,
"projectId" text NOT NULL,
"type" "PortfolioSectionType" NOT NULL,
"titleAr" text NOT NULL,
"titleEn" text NOT NULL,
"titleDe" text NOT NULL,
"bodyAr" text NOT NULL,
"bodyEn" text NOT NULL,
"bodyDe" text NOT NULL,
"imagePath" text,
"linkUrl" text,
"sortOrder" integer DEFAULT 0 NOT NULL,
"createdAt" timestamp (3) DEFAULT now() NOT NULL,
"updatedAt" timestamp (3) DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "MediaUsage" ADD CONSTRAINT "MediaUsage_assetId_MediaAsset_id_fk" FOREIGN KEY ("assetId") REFERENCES "public"."MediaAsset"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "PortfolioAsset" ADD CONSTRAINT "PortfolioAsset_projectId_PortfolioProject_id_fk" FOREIGN KEY ("projectId") REFERENCES "public"."PortfolioProject"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "PortfolioProject" ADD CONSTRAINT "PortfolioProject_categoryId_Category_id_fk" FOREIGN KEY ("categoryId") REFERENCES "public"."Category"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "PortfolioSection" ADD CONSTRAINT "PortfolioSection_projectId_PortfolioProject_id_fk" FOREIGN KEY ("projectId") REFERENCES "public"."PortfolioProject"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "MediaAsset_kind_createdAt_idx" ON "MediaAsset" USING btree ("kind","createdAt");--> statement-breakpoint
CREATE UNIQUE INDEX "MediaUsage_usageType_entityType_entityId_fieldKey_key" ON "MediaUsage" USING btree ("usageType","entityType","entityId","fieldKey");--> statement-breakpoint
CREATE INDEX "MediaUsage_assetId_idx" ON "MediaUsage" USING btree ("assetId");--> statement-breakpoint
CREATE INDEX "MediaUsage_entityType_entityId_idx" ON "MediaUsage" USING btree ("entityType","entityId");--> statement-breakpoint
CREATE INDEX "PortfolioAsset_projectId_sortOrder_idx" ON "PortfolioAsset" USING btree ("projectId","sortOrder");--> statement-breakpoint
CREATE INDEX "PortfolioProject_categoryId_isPublished_sortOrder_idx" ON "PortfolioProject" USING btree ("categoryId","isPublished","sortOrder");--> statement-breakpoint
CREATE INDEX "PortfolioProject_isPublished_sortOrder_idx" ON "PortfolioProject" USING btree ("isPublished","sortOrder");--> statement-breakpoint
CREATE INDEX "PortfolioSection_projectId_sortOrder_idx" ON "PortfolioSection" USING btree ("projectId","sortOrder");
-125
View File
@@ -1,125 +0,0 @@
CREATE TYPE "public"."media_kind" AS ENUM('IMAGE', 'DOCUMENT');--> statement-breakpoint
CREATE TYPE "public"."media_source" AS ENUM('UPLOAD', 'EXTERNAL');--> statement-breakpoint
CREATE TYPE "public"."media_usage_type" AS ENUM('PORTFOLIO_COVER', 'PORTFOLIO_SECTION', 'PORTFOLIO_ASSET', 'GENERIC');--> statement-breakpoint
CREATE TYPE "public"."portfolio_asset_kind" AS ENUM('IMAGE', 'DOCUMENT');--> statement-breakpoint
CREATE TYPE "public"."portfolio_project_view_mode" AS ENUM('GRID', 'STORY', 'CASE_STUDY');--> statement-breakpoint
CREATE TYPE "public"."portfolio_section_type" AS ENUM('RICH_TEXT', 'GALLERY', 'STATS', 'DELIVERABLES', 'LINK');--> statement-breakpoint
CREATE TABLE "app_config" (
"id" text PRIMARY KEY NOT NULL,
"key" text NOT NULL,
"value" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "app_config_key_unique" UNIQUE("key")
);
--> statement-breakpoint
CREATE TABLE "category" (
"id" text PRIMARY KEY NOT NULL,
"slug" text NOT NULL,
"name_ar" text NOT NULL,
"name_en" text NOT NULL,
"name_de" text NOT NULL,
"description_ar" text NOT NULL,
"description_en" text NOT NULL,
"description_de" text NOT NULL,
"sort_order" integer DEFAULT 0 NOT NULL,
"is_active" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "category_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE "media_asset" (
"id" text PRIMARY KEY NOT NULL,
"source" "media_source" NOT NULL,
"kind" "media_kind" NOT NULL,
"url" text NOT NULL,
"file_name" text NOT NULL,
"label" text NOT NULL,
"alt_text" text,
"mime_type" text,
"size" integer,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "media_usage" (
"id" text PRIMARY KEY NOT NULL,
"asset_id" text NOT NULL,
"usage_type" "media_usage_type" NOT NULL,
"entity_type" text NOT NULL,
"entity_id" text NOT NULL,
"field_key" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "portfolio_asset" (
"id" text PRIMARY KEY NOT NULL,
"project_id" text NOT NULL,
"kind" "portfolio_asset_kind" NOT NULL,
"file_path" text NOT NULL,
"alt_ar" text NOT NULL,
"alt_en" text NOT NULL,
"alt_de" text NOT NULL,
"sort_order" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "portfolio_project" (
"id" text PRIMARY KEY NOT NULL,
"category_id" text NOT NULL,
"slug" text NOT NULL,
"view_mode" "portfolio_project_view_mode" DEFAULT 'GRID' NOT NULL,
"title_ar" text NOT NULL,
"title_en" text NOT NULL,
"title_de" text NOT NULL,
"summary_ar" text NOT NULL,
"summary_en" text NOT NULL,
"summary_de" text NOT NULL,
"client_name" text NOT NULL,
"project_year" integer NOT NULL,
"service_label_ar" text NOT NULL,
"service_label_en" text NOT NULL,
"service_label_de" text NOT NULL,
"preview_url" text,
"cover_image_path" text,
"is_featured" boolean DEFAULT false NOT NULL,
"is_published" boolean DEFAULT false NOT NULL,
"published_at" timestamp with time zone,
"sort_order" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "portfolio_project_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE "portfolio_section" (
"id" text PRIMARY KEY NOT NULL,
"project_id" text NOT NULL,
"type" "portfolio_section_type" NOT NULL,
"title_ar" text NOT NULL,
"title_en" text NOT NULL,
"title_de" text NOT NULL,
"body_ar" text NOT NULL,
"body_en" text NOT NULL,
"body_de" text NOT NULL,
"image_path" text,
"link_url" text,
"sort_order" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "media_usage" ADD CONSTRAINT "media_usage_asset_id_media_asset_id_fk" FOREIGN KEY ("asset_id") REFERENCES "public"."media_asset"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "portfolio_asset" ADD CONSTRAINT "portfolio_asset_project_id_portfolio_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."portfolio_project"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "portfolio_project" ADD CONSTRAINT "portfolio_project_category_id_category_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."category"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "portfolio_section" ADD CONSTRAINT "portfolio_section_project_id_portfolio_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."portfolio_project"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "media_asset_kind_created_idx" ON "media_asset" USING btree ("kind","created_at");--> statement-breakpoint
CREATE UNIQUE INDEX "media_usage_unique_slot" ON "media_usage" USING btree ("usage_type","entity_type","entity_id","field_key");--> statement-breakpoint
CREATE INDEX "media_usage_asset_idx" ON "media_usage" USING btree ("asset_id");--> statement-breakpoint
CREATE INDEX "media_usage_entity_idx" ON "media_usage" USING btree ("entity_type","entity_id");--> statement-breakpoint
CREATE INDEX "portfolio_asset_project_sort_idx" ON "portfolio_asset" USING btree ("project_id","sort_order");--> statement-breakpoint
CREATE INDEX "portfolio_project_category_published_sort_idx" ON "portfolio_project" USING btree ("category_id","is_published","sort_order");--> statement-breakpoint
CREATE INDEX "portfolio_project_published_sort_idx" ON "portfolio_project" USING btree ("is_published","sort_order");--> statement-breakpoint
CREATE INDEX "portfolio_section_project_sort_idx" ON "portfolio_section" USING btree ("project_id","sort_order");
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -5,8 +5,8 @@
{ {
"idx": 0, "idx": 0,
"version": "7", "version": "7",
"when": 1786049545718, "when": 1786146995564,
"tag": "0000_fixed_venom", "tag": "0000_absurd_rawhide_kid",
"breakpoints": true "breakpoints": true
} }
] ]
+148 -141
View File
@@ -1,3 +1,4 @@
import { createId } from "@paralleldrive/cuid2";
import { relations } from "drizzle-orm"; import { relations } from "drizzle-orm";
import { import {
boolean, boolean,
@@ -10,198 +11,186 @@ import {
uniqueIndex, uniqueIndex,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
/** import {
* Drizzle schema — the single source of truth for the database, replacing the MediaKind,
* old Prisma schema (see docs). The database is Postgres; migrations are MediaSource,
* generated with `drizzle-kit generate`. IDs are app-generated opaque strings MediaUsageType,
* (was Prisma `cuid()`), timestamps default in the DB and bump on update. PortfolioAssetKind,
*/ PortfolioProjectViewMode,
PortfolioSectionType,
enumValues,
} from "./enums";
// `crypto.randomUUID()` is a global in Node 20+ and browsers (no node: import), // Postgres enum types — names match the ones Prisma created, so no DB migration
// so the schema stays safe to pull into a client bundle via lib/db/enums. // is needed for the ORM swap.
const id = () => export const portfolioSectionTypeEnum = pgEnum("PortfolioSectionType", enumValues(PortfolioSectionType));
text("id") export const portfolioAssetKindEnum = pgEnum("PortfolioAssetKind", enumValues(PortfolioAssetKind));
.primaryKey() export const portfolioProjectViewModeEnum = pgEnum("PortfolioProjectViewMode", enumValues(PortfolioProjectViewMode));
.$defaultFn(() => crypto.randomUUID()); export const mediaSourceEnum = pgEnum("MediaSource", enumValues(MediaSource));
export const mediaKindEnum = pgEnum("MediaKind", enumValues(MediaKind));
export const mediaUsageTypeEnum = pgEnum("MediaUsageType", enumValues(MediaUsageType));
const createdAt = timestamp("created_at", { withTimezone: true }).notNull().defaultNow(); // Shared column builders (Prisma parity): cuid ids, precision-3 timestamps.
const updatedAt = timestamp("updated_at", { withTimezone: true }) const id = () => text("id").primaryKey().$defaultFn(() => createId());
.notNull() const createdAt = () => timestamp("createdAt", { precision: 3, mode: "date" }).defaultNow().notNull();
.defaultNow() const updatedAt = () =>
.$onUpdate(() => new Date()); timestamp("updatedAt", { precision: 3, mode: "date" })
.defaultNow()
.notNull()
.$onUpdate(() => new Date());
// --- Enums ------------------------------------------------------------------ export const appConfig = pgTable("AppConfig", {
export const portfolioSectionType = pgEnum("portfolio_section_type", [
"RICH_TEXT",
"GALLERY",
"STATS",
"DELIVERABLES",
"LINK",
]);
export const portfolioAssetKind = pgEnum("portfolio_asset_kind", ["IMAGE", "DOCUMENT"]);
export const portfolioProjectViewMode = pgEnum("portfolio_project_view_mode", [
"GRID",
"STORY",
"CASE_STUDY",
]);
export const mediaSource = pgEnum("media_source", ["UPLOAD", "EXTERNAL"]);
export const mediaKind = pgEnum("media_kind", ["IMAGE", "DOCUMENT"]);
export const mediaUsageType = pgEnum("media_usage_type", [
"PORTFOLIO_COVER",
"PORTFOLIO_SECTION",
"PORTFOLIO_ASSET",
"GENERIC",
]);
// --- Tables -----------------------------------------------------------------
export const appConfig = pgTable("app_config", {
id: id(), id: id(),
key: text("key").notNull().unique(), key: text("key").notNull().unique(),
value: text("value").notNull(), value: text("value").notNull(),
createdAt, createdAt: createdAt(),
updatedAt, updatedAt: updatedAt(),
}); });
export const category = pgTable("category", { export const category = pgTable(
id: id(), "Category",
slug: text("slug").notNull().unique(),
nameAr: text("name_ar").notNull(),
nameEn: text("name_en").notNull(),
nameDe: text("name_de").notNull(),
descriptionAr: text("description_ar").notNull(),
descriptionEn: text("description_en").notNull(),
descriptionDe: text("description_de").notNull(),
sortOrder: integer("sort_order").notNull().default(0),
isActive: boolean("is_active").notNull().default(true),
createdAt,
updatedAt,
});
export const portfolioProject = pgTable(
"portfolio_project",
{ {
id: id(), id: id(),
categoryId: text("category_id") slug: text("slug").notNull().unique(),
nameAr: text("nameAr").notNull(),
nameEn: text("nameEn").notNull(),
nameDe: text("nameDe").notNull(),
descriptionAr: text("descriptionAr").notNull(),
descriptionEn: text("descriptionEn").notNull(),
descriptionDe: text("descriptionDe").notNull(),
sortOrder: integer("sortOrder").notNull().default(0),
isActive: boolean("isActive").notNull().default(true),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
);
export const portfolioProject = pgTable(
"PortfolioProject",
{
id: id(),
categoryId: text("categoryId")
.notNull() .notNull()
.references(() => category.id, { onDelete: "restrict" }), .references(() => category.id, { onDelete: "restrict" }),
slug: text("slug").notNull().unique(), slug: text("slug").notNull().unique(),
viewMode: portfolioProjectViewMode("view_mode").notNull().default("GRID"), viewMode: portfolioProjectViewModeEnum("viewMode").notNull().default("GRID"),
titleAr: text("title_ar").notNull(), titleAr: text("titleAr").notNull(),
titleEn: text("title_en").notNull(), titleEn: text("titleEn").notNull(),
titleDe: text("title_de").notNull(), titleDe: text("titleDe").notNull(),
summaryAr: text("summary_ar").notNull(), summaryAr: text("summaryAr").notNull(),
summaryEn: text("summary_en").notNull(), summaryEn: text("summaryEn").notNull(),
summaryDe: text("summary_de").notNull(), summaryDe: text("summaryDe").notNull(),
clientName: text("client_name").notNull(), clientName: text("clientName").notNull(),
projectYear: integer("project_year").notNull(), projectYear: integer("projectYear").notNull(),
serviceLabelAr: text("service_label_ar").notNull(), serviceLabelAr: text("serviceLabelAr").notNull(),
serviceLabelEn: text("service_label_en").notNull(), serviceLabelEn: text("serviceLabelEn").notNull(),
serviceLabelDe: text("service_label_de").notNull(), serviceLabelDe: text("serviceLabelDe").notNull(),
previewUrl: text("preview_url"), previewUrl: text("previewUrl"),
coverImagePath: text("cover_image_path"), coverImagePath: text("coverImagePath"),
isFeatured: boolean("is_featured").notNull().default(false), isFeatured: boolean("isFeatured").notNull().default(false),
isPublished: boolean("is_published").notNull().default(false), isPublished: boolean("isPublished").notNull().default(false),
publishedAt: timestamp("published_at", { withTimezone: true }), publishedAt: timestamp("publishedAt", { precision: 3, mode: "date" }),
sortOrder: integer("sort_order").notNull().default(0), sortOrder: integer("sortOrder").notNull().default(0),
createdAt, createdAt: createdAt(),
updatedAt, updatedAt: updatedAt(),
}, },
(t) => [ (table) => [
index("portfolio_project_category_published_sort_idx").on(t.categoryId, t.isPublished, t.sortOrder), index("PortfolioProject_categoryId_isPublished_sortOrder_idx").on(
index("portfolio_project_published_sort_idx").on(t.isPublished, t.sortOrder), table.categoryId,
table.isPublished,
table.sortOrder,
),
index("PortfolioProject_isPublished_sortOrder_idx").on(table.isPublished, table.sortOrder),
], ],
); );
export const portfolioSection = pgTable( export const portfolioSection = pgTable(
"portfolio_section", "PortfolioSection",
{ {
id: id(), id: id(),
projectId: text("project_id") projectId: text("projectId")
.notNull() .notNull()
.references(() => portfolioProject.id, { onDelete: "cascade" }), .references(() => portfolioProject.id, { onDelete: "cascade" }),
type: portfolioSectionType("type").notNull(), type: portfolioSectionTypeEnum("type").notNull(),
titleAr: text("title_ar").notNull(), titleAr: text("titleAr").notNull(),
titleEn: text("title_en").notNull(), titleEn: text("titleEn").notNull(),
titleDe: text("title_de").notNull(), titleDe: text("titleDe").notNull(),
bodyAr: text("body_ar").notNull(), bodyAr: text("bodyAr").notNull(),
bodyEn: text("body_en").notNull(), bodyEn: text("bodyEn").notNull(),
bodyDe: text("body_de").notNull(), bodyDe: text("bodyDe").notNull(),
imagePath: text("image_path"), imagePath: text("imagePath"),
linkUrl: text("link_url"), linkUrl: text("linkUrl"),
sortOrder: integer("sort_order").notNull().default(0), sortOrder: integer("sortOrder").notNull().default(0),
createdAt, createdAt: createdAt(),
updatedAt, updatedAt: updatedAt(),
}, },
(t) => [index("portfolio_section_project_sort_idx").on(t.projectId, t.sortOrder)], (table) => [index("PortfolioSection_projectId_sortOrder_idx").on(table.projectId, table.sortOrder)],
); );
export const portfolioAsset = pgTable( export const portfolioAsset = pgTable(
"portfolio_asset", "PortfolioAsset",
{ {
id: id(), id: id(),
projectId: text("project_id") projectId: text("projectId")
.notNull() .notNull()
.references(() => portfolioProject.id, { onDelete: "cascade" }), .references(() => portfolioProject.id, { onDelete: "cascade" }),
kind: portfolioAssetKind("kind").notNull(), kind: portfolioAssetKindEnum("kind").notNull(),
filePath: text("file_path").notNull(), filePath: text("filePath").notNull(),
altAr: text("alt_ar").notNull(), altAr: text("altAr").notNull(),
altEn: text("alt_en").notNull(), altEn: text("altEn").notNull(),
altDe: text("alt_de").notNull(), altDe: text("altDe").notNull(),
sortOrder: integer("sort_order").notNull().default(0), sortOrder: integer("sortOrder").notNull().default(0),
createdAt, createdAt: createdAt(),
updatedAt, updatedAt: updatedAt(),
}, },
(t) => [index("portfolio_asset_project_sort_idx").on(t.projectId, t.sortOrder)], (table) => [index("PortfolioAsset_projectId_sortOrder_idx").on(table.projectId, table.sortOrder)],
); );
export const mediaAsset = pgTable( export const mediaAsset = pgTable(
"media_asset", "MediaAsset",
{ {
id: id(), id: id(),
source: mediaSource("source").notNull(), source: mediaSourceEnum("source").notNull(),
kind: mediaKind("kind").notNull(), kind: mediaKindEnum("kind").notNull(),
url: text("url").notNull(), url: text("url").notNull(),
fileName: text("file_name").notNull(), fileName: text("fileName").notNull(),
label: text("label").notNull(), label: text("label").notNull(),
altText: text("alt_text"), altText: text("altText"),
mimeType: text("mime_type"), mimeType: text("mimeType"),
size: integer("size"), size: integer("size"),
createdAt, createdAt: createdAt(),
updatedAt, updatedAt: updatedAt(),
}, },
(t) => [index("media_asset_kind_created_idx").on(t.kind, t.createdAt)], (table) => [index("MediaAsset_kind_createdAt_idx").on(table.kind, table.createdAt)],
); );
export const mediaUsage = pgTable( export const mediaUsage = pgTable(
"media_usage", "MediaUsage",
{ {
id: id(), id: id(),
assetId: text("asset_id") assetId: text("assetId")
.notNull() .notNull()
.references(() => mediaAsset.id, { onDelete: "cascade" }), .references(() => mediaAsset.id, { onDelete: "cascade" }),
usageType: mediaUsageType("usage_type").notNull(), usageType: mediaUsageTypeEnum("usageType").notNull(),
entityType: text("entity_type").notNull(), entityType: text("entityType").notNull(),
entityId: text("entity_id").notNull(), entityId: text("entityId").notNull(),
fieldKey: text("field_key").notNull(), fieldKey: text("fieldKey").notNull(),
createdAt, createdAt: createdAt(),
updatedAt, updatedAt: updatedAt(),
}, },
(t) => [ (table) => [
uniqueIndex("media_usage_unique_slot").on(t.usageType, t.entityType, t.entityId, t.fieldKey), uniqueIndex("MediaUsage_usageType_entityType_entityId_fieldKey_key").on(
index("media_usage_asset_idx").on(t.assetId), table.usageType,
index("media_usage_entity_idx").on(t.entityType, t.entityId), table.entityType,
table.entityId,
table.fieldKey,
),
index("MediaUsage_assetId_idx").on(table.assetId),
index("MediaUsage_entityType_entityId_idx").on(table.entityType, table.entityId),
], ],
); );
// --- Relations (for the relational query API: db.query.*.findMany({ with })) -- // Relations (enable db.query.* `with:` includes).
export const categoryRelations = relations(category, ({ many }) => ({ export const categoryRelations = relations(category, ({ many }) => ({
projects: many(portfolioProject), projects: many(portfolioProject),
})); }));
@@ -239,3 +228,21 @@ export const mediaUsageRelations = relations(mediaUsage, ({ one }) => ({
references: [mediaAsset.id], references: [mediaAsset.id],
}), }),
})); }));
// Inferred row types (replace the old `@prisma/client` model type imports).
export type AppConfig = typeof appConfig.$inferSelect;
export type Category = typeof category.$inferSelect;
export type PortfolioProject = typeof portfolioProject.$inferSelect;
export type PortfolioSection = typeof portfolioSection.$inferSelect;
export type PortfolioAsset = typeof portfolioAsset.$inferSelect;
export type MediaAsset = typeof mediaAsset.$inferSelect;
export type MediaUsage = typeof mediaUsage.$inferSelect;
export {
MediaKind,
MediaSource,
MediaUsageType,
PortfolioAssetKind,
PortfolioProjectViewMode,
PortfolioSectionType,
} from "./enums";
+565
View File
@@ -0,0 +1,565 @@
import { and, eq } from "drizzle-orm";
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";
import {
appConfig,
category,
mediaAsset,
mediaUsage,
portfolioAsset,
portfolioProject,
portfolioSection,
} from "./schema";
const connectionString = (
process.env.DATABASE_URL ?? "postgresql://postgres:postgres@localhost:5432/moh_sass"
).split("?")[0];
const client = postgres(connectionString, { max: 1 });
const db = drizzle(client, { schema });
type MediaAssetInput = {
source: "UPLOAD" | "EXTERNAL";
kind: "IMAGE" | "DOCUMENT";
url: string;
fileName: string;
label: string;
altText: string | null;
mimeType: string | null;
size: number | null;
};
async function upsertMediaAsset(input: MediaAssetInput) {
const [existing] = await db
.select()
.from(mediaAsset)
.where(and(eq(mediaAsset.label, input.label), eq(mediaAsset.url, input.url)))
.limit(1);
if (existing) {
const [updated] = await db
.update(mediaAsset)
.set({ ...input, updatedAt: new Date() })
.where(eq(mediaAsset.id, existing.id))
.returning();
return updated;
}
const [created] = await db.insert(mediaAsset).values(input).returning();
return created;
}
async function upsertAppConfig(key: string, value: string) {
await db
.insert(appConfig)
.values({ key, value })
.onConflictDoUpdate({ target: appConfig.key, set: { value, updatedAt: new Date() } });
}
async function upsertCategory(values: typeof category.$inferInsert) {
const [row] = await db
.insert(category)
.values(values)
.onConflictDoUpdate({
target: category.slug,
set: {
nameAr: values.nameAr,
nameEn: values.nameEn,
nameDe: values.nameDe,
descriptionAr: values.descriptionAr,
descriptionEn: values.descriptionEn,
descriptionDe: values.descriptionDe,
sortOrder: values.sortOrder,
isActive: values.isActive,
updatedAt: new Date(),
},
})
.returning();
return row;
}
async function syncProjectContent(
projectId: string,
sections: Array<Omit<typeof portfolioSection.$inferInsert, "projectId">>,
assets: Array<Omit<typeof portfolioAsset.$inferInsert, "projectId">>,
) {
await db.delete(portfolioSection).where(eq(portfolioSection.projectId, projectId));
await db.delete(portfolioAsset).where(eq(portfolioAsset.projectId, projectId));
const createdSections = [];
for (const section of sections) {
const [row] = await db
.insert(portfolioSection)
.values({ projectId, ...section })
.returning();
createdSections.push(row);
}
const createdAssets = [];
for (const asset of assets) {
const [row] = await db
.insert(portfolioAsset)
.values({ projectId, ...asset })
.returning();
createdAssets.push(row);
}
return { createdSections, createdAssets };
}
async function syncProjectMediaUsages(
projectId: string,
mediaMap: {
coverAssetId: string | null | undefined;
sectionUsages: Array<{ fieldKey: string; assetId: string | null | undefined }>;
assetUsages: Array<{ fieldKey: string; assetId: string | null | undefined }>;
},
) {
await db
.delete(mediaUsage)
.where(and(eq(mediaUsage.entityType, "portfolio-project"), eq(mediaUsage.entityId, projectId)));
const usages: (typeof mediaUsage.$inferInsert)[] = [];
if (mediaMap.coverAssetId) {
usages.push({
assetId: mediaMap.coverAssetId,
usageType: "PORTFOLIO_COVER",
entityType: "portfolio-project",
entityId: projectId,
fieldKey: "cover",
});
}
for (const sectionUsage of mediaMap.sectionUsages) {
if (!sectionUsage.assetId) continue;
usages.push({
assetId: sectionUsage.assetId,
usageType: "PORTFOLIO_SECTION",
entityType: "portfolio-project",
entityId: projectId,
fieldKey: sectionUsage.fieldKey,
});
}
for (const assetUsage of mediaMap.assetUsages) {
if (!assetUsage.assetId) continue;
usages.push({
assetId: assetUsage.assetId,
usageType: "PORTFOLIO_ASSET",
entityType: "portfolio-project",
entityId: projectId,
fieldKey: assetUsage.fieldKey,
});
}
if (usages.length > 0) {
await db.insert(mediaUsage).values(usages);
}
}
const SITE_SETTINGS_VALUE = JSON.stringify({
titleTemplate: "{pageTitle} | moh-sass",
locales: {
ar: {
siteName: "moh-sass",
titleTemplate: "{pageTitle} | {siteName}",
siteDescription: "Multilingual Next.js base project",
subhead: "",
},
en: {
siteName: "moh-sass",
titleTemplate: "{pageTitle} | {siteName}",
siteDescription: "Multilingual Next.js base project",
subhead: "",
},
de: {
siteName: "moh-sass",
titleTemplate: "{pageTitle} | {siteName}",
siteDescription: "Multilingual Next.js base project",
subhead: "",
},
},
});
async function main() {
await db
.delete(mediaUsage)
.where(eq(mediaUsage.entityType, "portfolio-project"));
await db.delete(portfolioSection);
await db.delete(portfolioAsset);
await db.delete(portfolioProject);
await db.delete(category);
await upsertAppConfig("siteName", "moh-sass");
await upsertAppConfig("site_settings", SITE_SETTINGS_VALUE);
const brandCategory = await upsertCategory({
slug: "branding",
nameAr: "الهوية البصرية",
nameEn: "Branding",
nameDe: "Branding",
descriptionAr: "مشاريع هوية بصرية وشعارات وأنظمة علامة.",
descriptionEn: "Brand identity, logo, and design system work.",
descriptionDe: "Branding, Logos und visuelle Systeme.",
sortOrder: 1,
isActive: true,
});
const webCategory = await upsertCategory({
slug: "web-experiences",
nameAr: "تجارب الويب",
nameEn: "Web Experiences",
nameDe: "Web Experiences",
descriptionAr: "مواقع وصفحات هبوط وتجارب رقمية سريعة.",
descriptionEn: "Websites, landing pages, and digital experiences.",
descriptionDe: "Webseiten, Landingpages und digitale Erlebnisse.",
sortOrder: 2,
isActive: true,
});
const commerceCategory = await upsertCategory({
slug: "commerce",
nameAr: "التجارة الرقمية",
nameEn: "Commerce",
nameDe: "Commerce",
descriptionAr: "متاجر وتجارب شراء رقمية مع تركيز على الوضوح والتحويل.",
descriptionEn: "Commerce experiences with a focus on clarity and conversion.",
descriptionDe: "Commerce-Projekte mit Fokus auf Klarheit und Conversion.",
sortOrder: 3,
isActive: true,
});
const gridCover = await upsertMediaAsset({
source: "UPLOAD",
kind: "IMAGE",
url: "/uploads/portfolio/demo-cover.svg",
fileName: "demo-cover.svg",
label: "Portfolio Grid Cover",
altText: "Portfolio Grid Cover",
mimeType: "image/svg+xml",
size: 1024,
});
const storyCover = await upsertMediaAsset({
source: "UPLOAD",
kind: "IMAGE",
url: "/uploads/portfolio/demo-cover.svg",
fileName: "demo-cover.svg",
label: "Portfolio Story Cover",
altText: "Portfolio Story Cover",
mimeType: "image/svg+xml",
size: 1024,
});
const caseStudyCover = await upsertMediaAsset({
source: "UPLOAD",
kind: "IMAGE",
url: "/uploads/portfolio/demo-cover.svg",
fileName: "demo-cover.svg",
label: "Portfolio Case Study Cover",
altText: "Portfolio Case Study Cover",
mimeType: "image/svg+xml",
size: 1024,
});
const projects = [
{
slug: "grid-product-launch",
categoryId: commerceCategory.id,
viewMode: "GRID" as const,
titleAr: "إطلاق منتج رقمي",
titleEn: "Grid Product Launch",
titleDe: "Grid Product Launch",
summaryAr: "مثال عرض شبكي لمشروع سريع مع أقسام قصيرة وأصول داعمة.",
summaryEn: "Grid view example for a fast product launch page.",
summaryDe: "Grid-Ansicht als Beispiel fuer einen schnellen Produktlaunch.",
clientName: "Launch Studio",
projectYear: 2026,
serviceLabelAr: "تجربة إطلاق",
serviceLabelEn: "Launch Experience",
serviceLabelDe: "Launch Experience",
previewUrl: "https://example.com/preview/grid-product-launch",
coverImagePath: gridCover.url,
isFeatured: true,
isPublished: true,
publishedAt: new Date("2026-01-12T09:00:00.000Z"),
sortOrder: 1,
coverAssetId: gridCover.id,
sections: [
{
type: "RICH_TEXT" as const,
titleAr: "الفكرة",
titleEn: "Concept",
titleDe: "Konzept",
bodyAr: "واجهة سريعة لعرض المنتج والتركيز على الرسالة الأساسية.",
bodyEn: "A fast modular presentation focused on the main launch message.",
bodyDe: "Eine schnelle modulare Darstellung mit Fokus auf die Hauptbotschaft.",
imagePath: null,
linkUrl: null,
sortOrder: 0,
mediaAssetId: null,
},
{
type: "GALLERY" as const,
titleAr: "الصورة الرئيسية",
titleEn: "Hero Visual",
titleDe: "Hero Visual",
bodyAr: "",
bodyEn: "",
bodyDe: "",
imagePath: gridCover.url,
linkUrl: null,
sortOrder: 1,
mediaAssetId: gridCover.id,
},
],
assets: [
{
kind: "IMAGE" as const,
filePath: gridCover.url,
altAr: "غلاف مشروع Grid",
altEn: "Grid project cover",
altDe: "Grid Projekt Cover",
sortOrder: 0,
mediaAssetId: gridCover.id,
},
],
},
{
slug: "campaign-site",
categoryId: webCategory.id,
viewMode: "STORY" as const,
titleAr: "موقع حملة",
titleEn: "Campaign Site",
titleDe: "Campaign Site",
summaryAr: "مثال عرض قصصي لمشروع ويب مع تسلسل سردي أوضح.",
summaryEn: "Story view example for a launch campaign website.",
summaryDe: "Story-Ansicht als Beispiel fuer eine Kampagnenseite.",
clientName: "Launch Client",
projectYear: 2024,
serviceLabelAr: "موقع تسويقي",
serviceLabelEn: "Marketing Website",
serviceLabelDe: "Marketing Website",
previewUrl: "https://example.com/preview/campaign-site",
coverImagePath: storyCover.url,
isFeatured: false,
isPublished: true,
publishedAt: new Date("2024-09-05T09:00:00.000Z"),
sortOrder: 2,
coverAssetId: storyCover.id,
sections: [
{
type: "RICH_TEXT" as const,
titleAr: "السياق",
titleEn: "Context",
titleDe: "Kontext",
bodyAr: "الحملة احتاجت صفحة مرنة وسريعة تتبدل بين أكثر من مرحلة.",
bodyEn: "The campaign needed a flexible page that could adapt across phases.",
bodyDe: "Die Kampagne brauchte eine flexible Seite fuer mehrere Phasen.",
imagePath: null,
linkUrl: null,
sortOrder: 0,
mediaAssetId: null,
},
{
type: "GALLERY" as const,
titleAr: "العرض البصري",
titleEn: "Visual Flow",
titleDe: "Visueller Ablauf",
bodyAr: "",
bodyEn: "",
bodyDe: "",
imagePath: storyCover.url,
linkUrl: null,
sortOrder: 1,
mediaAssetId: storyCover.id,
},
{
type: "LINK" as const,
titleAr: "المعاينة",
titleEn: "Preview",
titleDe: "Vorschau",
bodyAr: "رابط العرض المباشر.",
bodyEn: "Direct preview link.",
bodyDe: "Direkter Vorschau-Link.",
imagePath: null,
linkUrl: "https://example.com/preview/campaign-site",
sortOrder: 2,
mediaAssetId: null,
},
],
assets: [
{
kind: "IMAGE" as const,
filePath: storyCover.url,
altAr: "غلاف مشروع Story",
altEn: "Story project cover",
altDe: "Story Projekt Cover",
sortOrder: 0,
mediaAssetId: storyCover.id,
},
],
},
{
slug: "brand-redesign",
categoryId: brandCategory.id,
viewMode: "CASE_STUDY" as const,
titleAr: "إعادة تصميم الهوية",
titleEn: "Brand Redesign",
titleDe: "Brand Redesign",
summaryAr: "مثال عرض دراسة حالة يركز على التحدي والحل والنتيجة.",
summaryEn: "Case study example focused on challenge, solution, and outcome.",
summaryDe: "Case-Study-Ansicht mit Fokus auf Herausforderung, Loesung und Ergebnis.",
clientName: "Studio Client",
projectYear: 2025,
serviceLabelAr: "هوية بصرية",
serviceLabelEn: "Brand Identity",
serviceLabelDe: "Brand Identity",
previewUrl: "https://example.com/preview/brand-redesign",
coverImagePath: caseStudyCover.url,
isFeatured: true,
isPublished: true,
publishedAt: new Date("2025-01-10T09:00:00.000Z"),
sortOrder: 3,
coverAssetId: caseStudyCover.id,
sections: [
{
type: "RICH_TEXT" as const,
titleAr: "التحدي",
titleEn: "Challenge",
titleDe: "Herausforderung",
bodyAr: "كان المطلوب تحديث الهوية بدون خسارة التعرف البصري الحالي.",
bodyEn: "The brief required a refreshed identity without losing recognition.",
bodyDe: "Die Marke sollte modernisiert werden, ohne die Wiedererkennbarkeit zu verlieren.",
imagePath: null,
linkUrl: null,
sortOrder: 0,
mediaAssetId: null,
},
{
type: "RICH_TEXT" as const,
titleAr: "الحل",
titleEn: "Solution",
titleDe: "Loesung",
bodyAr: "تم بناء نظام مرئي أوضح مع قواعد استخدام قابلة للتوسع.",
bodyEn: "A clearer visual system with scalable usage rules was created.",
bodyDe: "Es wurde ein klareres visuelles System mit skalierbaren Regeln aufgebaut.",
imagePath: null,
linkUrl: null,
sortOrder: 1,
mediaAssetId: null,
},
{
type: "GALLERY" as const,
titleAr: "التنفيذ البصري",
titleEn: "Visual Execution",
titleDe: "Visuelle Umsetzung",
bodyAr: "",
bodyEn: "",
bodyDe: "",
imagePath: caseStudyCover.url,
linkUrl: null,
sortOrder: 2,
mediaAssetId: caseStudyCover.id,
},
],
assets: [
{
kind: "IMAGE" as const,
filePath: caseStudyCover.url,
altAr: "غلاف مشروع Case Study",
altEn: "Case study project cover",
altDe: "Case Study Projekt Cover",
sortOrder: 0,
mediaAssetId: caseStudyCover.id,
},
],
},
];
for (const projectConfig of projects) {
const { sections, assets, coverAssetId, ...projectValues } = projectConfig;
const [project] = await db
.insert(portfolioProject)
.values(projectValues)
.onConflictDoUpdate({
target: portfolioProject.slug,
set: {
categoryId: projectValues.categoryId,
viewMode: projectValues.viewMode,
titleAr: projectValues.titleAr,
titleEn: projectValues.titleEn,
titleDe: projectValues.titleDe,
summaryAr: projectValues.summaryAr,
summaryEn: projectValues.summaryEn,
summaryDe: projectValues.summaryDe,
clientName: projectValues.clientName,
projectYear: projectValues.projectYear,
serviceLabelAr: projectValues.serviceLabelAr,
serviceLabelEn: projectValues.serviceLabelEn,
serviceLabelDe: projectValues.serviceLabelDe,
previewUrl: projectValues.previewUrl,
coverImagePath: projectValues.coverImagePath,
isFeatured: projectValues.isFeatured,
isPublished: projectValues.isPublished,
publishedAt: projectValues.publishedAt,
sortOrder: projectValues.sortOrder,
updatedAt: new Date(),
},
})
.returning();
const created = await syncProjectContent(
project.id,
sections.map((section) => ({
type: section.type,
titleAr: section.titleAr,
titleEn: section.titleEn,
titleDe: section.titleDe,
bodyAr: section.bodyAr,
bodyEn: section.bodyEn,
bodyDe: section.bodyDe,
imagePath: section.imagePath,
linkUrl: section.linkUrl,
sortOrder: section.sortOrder,
})),
assets.map((asset) => ({
kind: asset.kind,
filePath: asset.filePath,
altAr: asset.altAr,
altEn: asset.altEn,
altDe: asset.altDe,
sortOrder: asset.sortOrder,
})),
);
await syncProjectMediaUsages(project.id, {
coverAssetId,
sectionUsages: created.createdSections.map((sectionRow, index) => ({
fieldKey: sectionRow.id,
assetId: sections[index]?.mediaAssetId,
})),
assetUsages: created.createdAssets.map((assetRow, index) => ({
fieldKey: assetRow.id,
assetId: assets[index]?.mediaAssetId,
})),
});
}
}
main()
.then(async () => {
await client.end();
})
.catch(async (error) => {
console.error("Seed failed:", error);
await client.end();
process.exit(1);
});
+2 -3
View File
@@ -125,8 +125,7 @@ export async function createStandaloneMediaAsset(input: {
uploadFile: FormDataEntryValue | null; uploadFile: FormDataEntryValue | null;
}) { }) {
if (input.uploadFile instanceof File && input.uploadFile.size > 0) { if (input.uploadFile instanceof File && input.uploadFile.size > 0) {
const kind = getKindFromUploadFile(input.uploadFile); const savedFile = await saveMediaUpload(input.uploadFile, input.kind.toLowerCase());
const savedFile = await saveMediaUpload(input.uploadFile, kind.toLowerCase());
const derivedLabel = input.uploadFile.name.replace(/\.[^.]+$/, "").trim(); const derivedLabel = input.uploadFile.name.replace(/\.[^.]+$/, "").trim();
const trimmedLabel = input.label.trim() || derivedLabel || "Media asset"; const trimmedLabel = input.label.trim() || derivedLabel || "Media asset";
@@ -136,7 +135,7 @@ export async function createStandaloneMediaAsset(input: {
return createMediaAsset({ return createMediaAsset({
source: MediaSource.UPLOAD, source: MediaSource.UPLOAD,
kind, kind: input.kind,
url: savedFile.url, url: savedFile.url,
fileName: savedFile.fileName, fileName: savedFile.fileName,
label: trimmedLabel, label: trimmedLabel,
+5 -61
View File
@@ -37,65 +37,16 @@ export function resolveMediaUploadPath(filePath: string) {
throw new Error("Only managed media uploads can be resolved."); throw new Error("Only managed media uploads can be resolved.");
} }
const relativePath = filePath.slice("/uploads/media/".length); const relativePath = filePath.replace("/uploads/media/", "");
if (!relativePath || relativePath.includes("\0")) {
throw new Error("Resolved media upload path escapes the upload root.");
}
const absolutePath = path.resolve(MEDIA_UPLOAD_ROOT, relativePath); const absolutePath = path.resolve(MEDIA_UPLOAD_ROOT, relativePath);
// `startsWith(root)` alone would accept a sibling directory such as if (!absolutePath.startsWith(MEDIA_UPLOAD_ROOT)) {
// `.../uploads/media-evil/...`; require the separator so only true children pass.
if (absolutePath !== MEDIA_UPLOAD_ROOT && !absolutePath.startsWith(MEDIA_UPLOAD_ROOT + path.sep)) {
throw new Error("Resolved media upload path escapes the upload root.");
}
if (absolutePath === MEDIA_UPLOAD_ROOT) {
throw new Error("Resolved media upload path escapes the upload root."); throw new Error("Resolved media upload path escapes the upload root.");
} }
return absolutePath; return absolutePath;
} }
const MAGIC_SIGNATURES: Record<string, Array<{ offset: number; bytes: number[] }>> = {
".png": [{ offset: 0, bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] }],
".jpg": [{ offset: 0, bytes: [0xff, 0xd8, 0xff] }],
".gif": [{ offset: 0, bytes: [0x47, 0x49, 0x46, 0x38] }],
".webp": [
{ offset: 0, bytes: [0x52, 0x49, 0x46, 0x46] },
{ offset: 8, bytes: [0x57, 0x45, 0x42, 0x50] },
],
".pdf": [{ offset: 0, bytes: [0x25, 0x50, 0x44, 0x46] }],
".ico": [{ offset: 0, bytes: [0x00, 0x00, 0x01, 0x00] }],
};
const SVG_FORBIDDEN_PATTERN = /<script[\s>]|javascript:|on[a-z]+\s*=|<foreignObject|<iframe|<embed|<object|xlink:href\s*=\s*["']\s*(?!#|data:image\/)/i;
/**
* Verify that the file bytes match the extension derived from the declared
* MIME type. The browser-supplied `file.type` is untrusted: without this check
* an HTML/JS payload could be stored as `.png` and served from our origin.
*/
export function isMediaContentValid(extension: string, buffer: Buffer): boolean {
if (extension === ".svg") {
const head = buffer.subarray(0, 4096).toString("utf8").trimStart();
const looksLikeSvg = head.startsWith("<svg") || (head.startsWith("<?xml") && /<svg[\s>]/i.test(head));
return looksLikeSvg && !SVG_FORBIDDEN_PATTERN.test(buffer.toString("utf8"));
}
const signatures = MAGIC_SIGNATURES[extension];
if (!signatures) {
return false;
}
return signatures.every(({ offset, bytes }) =>
bytes.every((byte, index) => buffer[offset + index] === byte),
);
}
export async function removeManagedMediaFile(filePath: string | null | undefined) { export async function removeManagedMediaFile(filePath: string | null | undefined) {
if (!isManagedMediaFilePath(filePath)) { if (!isManagedMediaFilePath(filePath)) {
return false; return false;
@@ -125,23 +76,16 @@ export async function saveMediaUpload(file: File, folder: string) {
throw new Error("File is too large."); throw new Error("File is too large.");
} }
const buffer = Buffer.from(await file.arrayBuffer());
if (!isMediaContentValid(extension, buffer)) {
throw new Error("File content does not match its declared type.");
}
const safeFolder = sanitizeBaseName(folder) || "misc";
const safeBaseName = sanitizeBaseName(file.name.replace(/\.[^.]+$/, "")) || "asset"; const safeBaseName = sanitizeBaseName(file.name.replace(/\.[^.]+$/, "")) || "asset";
const finalName = `${safeBaseName}-${randomUUID().slice(0, 8)}${extension}`; const finalName = `${safeBaseName}-${randomUUID().slice(0, 8)}${extension}`;
const targetDir = path.join(MEDIA_UPLOAD_ROOT, safeFolder); const targetDir = path.join(MEDIA_UPLOAD_ROOT, folder);
const targetPath = path.join(targetDir, finalName); const targetPath = path.join(targetDir, finalName);
await mkdir(targetDir, { recursive: true }); await mkdir(targetDir, { recursive: true });
await writeFile(targetPath, buffer); await writeFile(targetPath, Buffer.from(await file.arrayBuffer()));
return { return {
url: `/uploads/media/${safeFolder}/${finalName}`, url: `/uploads/media/${folder}/${finalName}`,
fileName: finalName, fileName: finalName,
mimeType: file.type, mimeType: file.type,
size: file.size, size: file.size,
+1 -3
View File
@@ -30,9 +30,7 @@ export const mediaFieldInputSchema = z
}); });
} }
const isRootRelative = value.url.startsWith("/") && !value.url.startsWith("//"); if (value.url && !/^https?:\/\//.test(value.url) && !value.url.startsWith("/")) {
if (value.url && !/^https?:\/\//i.test(value.url) && !isRootRelative) {
context.addIssue({ context.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
path: ["url"], path: ["url"],
+41 -17
View File
@@ -1,17 +1,17 @@
import { and, desc, eq } from "drizzle-orm"; import { and, count, desc, eq } from "drizzle-orm";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { mediaAsset, mediaUsage } from "@/lib/db/schema"; import { mediaAsset, mediaUsage } from "@/lib/db/schema";
import type { MediaAsset, MediaUsage } from "@/lib/db/schema";
import type { MediaKind, MediaSource, MediaUsageType } from "@/lib/db/enums"; import type { MediaKind, MediaSource, MediaUsageType } from "@/lib/db/enums";
type MediaAssetRow = typeof mediaAsset.$inferSelect;
type MediaUsageRow = typeof mediaUsage.$inferSelect;
export type MediaAssetView = Pick< export type MediaAssetView = Pick<
MediaAssetRow, MediaAsset,
"id" | "source" | "kind" | "url" | "fileName" | "label" | "altText" | "mimeType" | "size" | "createdAt" "id" | "source" | "kind" | "url" | "fileName" | "label" | "altText" | "mimeType" | "size" | "createdAt"
> & { > & {
usages: Array<Pick<MediaUsageRow, "id" | "usageType" | "entityType" | "entityId" | "fieldKey">>; usages: Array<
Pick<MediaUsage, "id" | "usageType" | "entityType" | "entityId" | "fieldKey">
>;
}; };
export type MediaOption = Pick<MediaAssetView, "id" | "kind" | "url" | "label" | "source">; export type MediaOption = Pick<MediaAssetView, "id" | "kind" | "url" | "label" | "source">;
@@ -22,7 +22,11 @@ export type PortfolioMediaBindings = {
assetIds: Record<string, string>; assetIds: Record<string, string>;
}; };
function mapMediaAsset(asset: MediaAssetRow & { usages: MediaUsageRow[] }): MediaAssetView { function mapMediaAsset(
asset: MediaAsset & {
usages: MediaUsage[];
},
): MediaAssetView {
return { return {
id: asset.id, id: asset.id,
source: asset.source, source: asset.source,
@@ -46,15 +50,19 @@ function mapMediaAsset(asset: MediaAssetRow & { usages: MediaUsageRow[] }): Medi
export async function getAdminMediaAssets() { export async function getAdminMediaAssets() {
const assets = await db.query.mediaAsset.findMany({ const assets = await db.query.mediaAsset.findMany({
with: { usages: { orderBy: [desc(mediaUsage.createdAt)] } }, with: {
orderBy: [desc(mediaAsset.createdAt)], usages: {
orderBy: (usage, { desc: descOrder }) => [descOrder(usage.createdAt)],
},
},
orderBy: (asset, { desc: descOrder }) => [descOrder(asset.createdAt)],
}); });
return assets.map(mapMediaAsset); return assets.map(mapMediaAsset);
} }
export async function getMediaOptions(filters?: { kind?: MediaKind }): Promise<MediaOption[]> { export async function getMediaOptions(filters?: { kind?: MediaKind }) {
return db const assets = await db
.select({ .select({
id: mediaAsset.id, id: mediaAsset.id,
kind: mediaAsset.kind, kind: mediaAsset.kind,
@@ -65,12 +73,16 @@ export async function getMediaOptions(filters?: { kind?: MediaKind }): Promise<M
.from(mediaAsset) .from(mediaAsset)
.where(filters?.kind ? eq(mediaAsset.kind, filters.kind) : undefined) .where(filters?.kind ? eq(mediaAsset.kind, filters.kind) : undefined)
.orderBy(desc(mediaAsset.createdAt)); .orderBy(desc(mediaAsset.createdAt));
return assets;
} }
export async function getMediaAssetById(id: string) { export async function getMediaAssetById(id: string) {
const asset = await db.query.mediaAsset.findFirst({ const asset = await db.query.mediaAsset.findFirst({
where: eq(mediaAsset.id, id), where: eq(mediaAsset.id, id),
with: { usages: true }, with: {
usages: true,
},
}); });
return asset ? mapMediaAsset(asset) : null; return asset ? mapMediaAsset(asset) : null;
@@ -86,7 +98,7 @@ export async function createMediaAsset(input: {
mimeType?: string | null; mimeType?: string | null;
size?: number | null; size?: number | null;
}) { }) {
const [created] = await db const [asset] = await db
.insert(mediaAsset) .insert(mediaAsset)
.values({ .values({
source: input.source, source: input.source,
@@ -100,7 +112,7 @@ export async function createMediaAsset(input: {
}) })
.returning(); .returning();
return created; return asset;
} }
export async function replaceEntityMediaUsages(input: { export async function replaceEntityMediaUsages(input: {
@@ -115,7 +127,12 @@ export async function replaceEntityMediaUsages(input: {
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
await tx await tx
.delete(mediaUsage) .delete(mediaUsage)
.where(and(eq(mediaUsage.entityType, input.entityType), eq(mediaUsage.entityId, input.entityId))); .where(
and(
eq(mediaUsage.entityType, input.entityType),
eq(mediaUsage.entityId, input.entityId),
),
);
if (input.usages.length === 0) { if (input.usages.length === 0) {
return; return;
@@ -147,7 +164,9 @@ export async function getPortfolioMediaBindings(projectId: string): Promise<Port
fieldKey: mediaUsage.fieldKey, fieldKey: mediaUsage.fieldKey,
}) })
.from(mediaUsage) .from(mediaUsage)
.where(and(eq(mediaUsage.entityType, "portfolio-project"), eq(mediaUsage.entityId, projectId))); .where(
and(eq(mediaUsage.entityType, "portfolio-project"), eq(mediaUsage.entityId, projectId)),
);
return usages.reduce<PortfolioMediaBindings>( return usages.reduce<PortfolioMediaBindings>(
(result, usage) => { (result, usage) => {
@@ -174,5 +193,10 @@ export async function getPortfolioMediaBindings(projectId: string): Promise<Port
} }
export async function countMediaUsageReferences(assetId: string) { export async function countMediaUsageReferences(assetId: string) {
return db.$count(mediaUsage, eq(mediaUsage.assetId, assetId)); const [row] = await db
.select({ value: count() })
.from(mediaUsage)
.where(eq(mediaUsage.assetId, assetId));
return row?.value ?? 0;
} }
+25 -208
View File
@@ -11,50 +11,17 @@ import {
getSiteSettings, getSiteSettings,
getSiteSettingsMediaBindings, getSiteSettingsMediaBindings,
} from "./app-config"; } from "./app-config";
import { AppLocale, getLocalizedPath, getLocalizedPathWithDefault, resolveLocale } from "./locale";
import { buildSiteIconUrls } from "./site-icons";
export function getSiteUrl(): URL { function getSiteUrl(): URL {
return new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "https://mohfarawati.de"); return new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "https://mohfarawati.de");
} }
export function toAbsoluteUrl(pathname: string): string { function toAbsoluteUrl(pathname: string): string {
return new URL(pathname, getSiteUrl()).toString(); return new URL(pathname, getSiteUrl()).toString();
} }
const NOINDEX_ROBOTS: Metadata["robots"] = {
index: false,
follow: false,
nocache: true,
googleBot: { index: false, follow: false, noimageindex: true },
};
const INDEX_ROBOTS: Metadata["robots"] = {
index: true,
follow: true,
googleBot: { index: true, follow: true, "max-image-preview": "large", "max-snippet": -1, "max-video-preview": -1 },
};
export function buildRobotsMetadata(seo: SeoSettings, noIndex = false): Metadata["robots"] {
return seo.allowIndexing && !noIndex ? INDEX_ROBOTS : NOINDEX_ROBOTS;
}
function buildVerification(seo: SeoSettings): Metadata["verification"] {
const verification: NonNullable<Metadata["verification"]> = {};
if (seo.googleSiteVerification) {
verification.google = seo.googleSiteVerification;
}
if (seo.bingSiteVerification) {
verification.other = { "msvalidate.01": seo.bingSiteVerification };
}
return Object.keys(verification).length > 0 ? verification : undefined;
}
import { AppLocale, getLocalizedPath, getLocalizedPathWithDefault, resolveLocale } from "./locale";
import { getSeoSettings } from "./app-config";
import { buildDefaultSeoSettings, toOpenGraphLocale, type SeoSettings } from "./seo-settings";
import { buildSiteIconUrls } from "./site-icons";
export function buildLocaleAlternates(pathname: string, defaultLocale: AppLocale) { export function buildLocaleAlternates(pathname: string, defaultLocale: AppLocale) {
const languages = Object.fromEntries( const languages = Object.fromEntries(
appLocales.map((locale) => [locale, toAbsoluteUrl(getLocalizedPathWithDefault(locale, pathname, defaultLocale))]), appLocales.map((locale) => [locale, toAbsoluteUrl(getLocalizedPathWithDefault(locale, pathname, defaultLocale))]),
@@ -79,7 +46,7 @@ export function applyTitleTemplateFn(title: string, template: string, siteName:
.replace(PAGE_TITLE_TOKEN, title); .replace(PAGE_TITLE_TOKEN, title);
} }
function buildMetadataImages(imageUrl?: string | null, alt?: string) { function buildMetadataImages(imageUrl?: string | null) {
if (!imageUrl) { if (!imageUrl) {
return undefined; return undefined;
} }
@@ -87,29 +54,22 @@ function buildMetadataImages(imageUrl?: string | null, alt?: string) {
return [ return [
{ {
url: toAbsoluteUrl(imageUrl), url: toAbsoluteUrl(imageUrl),
alt,
}, },
]; ];
} }
export async function buildAppMetadata(): Promise<Metadata> { export async function buildAppMetadata(): Promise<Metadata> {
const [settings, bindings, seo] = await Promise.all([ const [settings, bindings] = await Promise.all([getSiteSettings(), getSiteSettingsMediaBindings()]);
getSiteSettings(),
getSiteSettingsMediaBindings(),
getSeoSettings(),
]);
return buildAppMetadataFromConfig(settings, bindings, seo); return buildAppMetadataFromConfig(settings, bindings);
} }
export function buildAppMetadataFromConfig( export function buildAppMetadataFromConfig(
settings: SiteSettings, settings: SiteSettings,
bindings: SiteSettingsMediaBindings, bindings: SiteSettingsMediaBindings,
seo: SeoSettings = buildDefaultSeoSettings(),
): Metadata { ): Metadata {
const defaultLocaleSettings = settings.locales[settings.defaultLocale]; const defaultLocaleSettings = settings.locales[settings.defaultLocale];
const openGraphImages = buildMetadataImages(bindings.defaultOgImage?.url, defaultLocaleSettings.siteName); const openGraphImages = buildMetadataImages(bindings.defaultOgImage?.url);
const keywords = seo.locales[settings.defaultLocale].keywords;
const siteIconUrls = buildSiteIconUrls({ const siteIconUrls = buildSiteIconUrls({
siteName: defaultLocaleSettings.siteName, siteName: defaultLocaleSettings.siteName,
faviconVersion: bindings.favicon?.version, faviconVersion: bindings.favicon?.version,
@@ -121,10 +81,6 @@ export function buildAppMetadataFromConfig(
title: defaultLocaleSettings.siteName, title: defaultLocaleSettings.siteName,
description: defaultLocaleSettings.siteDescription, description: defaultLocaleSettings.siteDescription,
applicationName: defaultLocaleSettings.siteName, applicationName: defaultLocaleSettings.siteName,
keywords: keywords ? keywords.split(",").map((keyword) => keyword.trim()).filter(Boolean) : undefined,
robots: buildRobotsMetadata(seo),
verification: buildVerification(seo),
formatDetection: { telephone: false },
manifest: siteIconUrls.manifestHref, manifest: siteIconUrls.manifestHref,
icons: { icons: {
icon: [{ url: siteIconUrls.faviconHref }], icon: [{ url: siteIconUrls.faviconHref }],
@@ -136,8 +92,7 @@ export function buildAppMetadataFromConfig(
description: defaultLocaleSettings.siteDescription, description: defaultLocaleSettings.siteDescription,
url: toAbsoluteUrl(getLocalizedPathWithDefault(settings.defaultLocale, "/", settings.defaultLocale)), url: toAbsoluteUrl(getLocalizedPathWithDefault(settings.defaultLocale, "/", settings.defaultLocale)),
siteName: defaultLocaleSettings.siteName, siteName: defaultLocaleSettings.siteName,
locale: toOpenGraphLocale(settings.defaultLocale), locale: settings.defaultLocale,
alternateLocale: appLocales.filter((locale) => locale !== settings.defaultLocale).map(toOpenGraphLocale),
type: "website", type: "website",
images: openGraphImages, images: openGraphImages,
}, },
@@ -145,25 +100,12 @@ export function buildAppMetadataFromConfig(
card: openGraphImages ? "summary_large_image" : "summary", card: openGraphImages ? "summary_large_image" : "summary",
title: defaultLocaleSettings.siteName, title: defaultLocaleSettings.siteName,
description: defaultLocaleSettings.siteDescription, description: defaultLocaleSettings.siteDescription,
site: seo.twitterHandle || undefined,
creator: seo.twitterHandle || undefined,
images: openGraphImages?.map((image) => image.url), images: openGraphImages?.map((image) => image.url),
}, },
}; };
} }
type LocalizedMetadataOptions = { type LocalizedMetadataInput = {
/** Page-specific share image (e.g. a project cover). Falls back to the default OG image. */
image?: string | null;
/** Force `noindex` (thank-you pages, coming-soon, etc.). */
noIndex?: boolean;
/** Open Graph object type. Portfolio projects use `article`. */
type?: "website" | "article";
publishedTime?: Date | null;
modifiedTime?: Date | null;
};
type LocalizedMetadataInput = LocalizedMetadataOptions & {
locale: string; locale: string;
pathname: string; pathname: string;
title: string; title: string;
@@ -177,190 +119,65 @@ export async function buildLocalizedMetadata({
title, title,
description, description,
applyTitleTemplate, applyTitleTemplate,
...options
}: LocalizedMetadataInput): Promise<Metadata> { }: LocalizedMetadataInput): Promise<Metadata> {
const [settings, bindings, seo] = await Promise.all([ const [settings, bindings] = await Promise.all([getSiteSettings(), getSiteSettingsMediaBindings()]);
getSiteSettings(),
getSiteSettingsMediaBindings(),
getSeoSettings(),
]);
const localeKey = resolveLocale(locale, settings.defaultLocale); const localeKey = resolveLocale(locale, settings.defaultLocale);
return buildLocalizedMetadataFromConfig({ return buildLocalizedMetadataFromConfig({
settings, settings,
bindings, bindings,
seo,
locale: localeKey, locale: localeKey,
pathname, pathname,
title, title,
description, description,
applyTitleTemplate, applyTitleTemplate,
...options,
}); });
} }
export function buildLocalizedMetadataFromConfig( export function buildLocalizedMetadataFromConfig(input: {
input: LocalizedMetadataOptions & { settings: SiteSettings;
settings: SiteSettings; bindings: SiteSettingsMediaBindings;
bindings: SiteSettingsMediaBindings; locale: AppLocale;
seo?: SeoSettings; pathname: string;
locale: AppLocale; title: string;
pathname: string; description?: string;
title: string; applyTitleTemplate?: boolean;
description?: string; }): Metadata {
applyTitleTemplate?: boolean;
},
): Metadata {
const { const {
settings, settings,
bindings, bindings,
seo = buildDefaultSeoSettings(),
locale, locale,
pathname, pathname,
title, title,
description, description,
applyTitleTemplate = true, applyTitleTemplate = true,
image,
noIndex = false,
type = "website",
publishedTime,
modifiedTime,
} = input; } = input;
const localeKey = resolveLocale(locale, settings.defaultLocale); const localeKey = resolveLocale(locale, settings.defaultLocale);
const localeSettings = settings.locales[localeKey]; const localeSettings = settings.locales[localeKey];
const resolvedDescription = (description?.trim() || localeSettings.siteDescription).slice(0, 300); const resolvedDescription = description?.trim() || localeSettings.siteDescription;
const resolvedTitle = applyTitleTemplate const resolvedTitle = applyTitleTemplate
? applyTitleTemplateFn(title, localeSettings.titleTemplate, localeSettings.siteName) ? applyTitleTemplateFn(title, localeSettings.titleTemplate, localeSettings.siteName)
: title; : title;
const openGraphImages = buildMetadataImages(image || bindings.defaultOgImage?.url, title); const openGraphImages = buildMetadataImages(bindings.defaultOgImage?.url);
const keywords = seo.locales[localeKey].keywords;
return { return {
title: resolvedTitle, title: resolvedTitle,
description: resolvedDescription, description: resolvedDescription,
keywords: keywords ? keywords.split(",").map((keyword) => keyword.trim()).filter(Boolean) : undefined,
robots: buildRobotsMetadata(seo, noIndex),
alternates: buildLocaleAlternates(pathname, settings.defaultLocale), alternates: buildLocaleAlternates(pathname, settings.defaultLocale),
openGraph: { openGraph: {
title: resolvedTitle, title: resolvedTitle,
description: resolvedDescription, description: resolvedDescription,
url: toAbsoluteUrl(getLocalizedPath(localeKey, pathname, settings.defaultLocale)), url: toAbsoluteUrl(getLocalizedPath(localeKey, pathname, settings.defaultLocale)),
siteName: localeSettings.siteName, siteName: localeSettings.siteName,
locale: toOpenGraphLocale(localeKey), locale: localeKey,
alternateLocale: appLocales.filter((entry) => entry !== localeKey).map(toOpenGraphLocale), type: "website",
images: openGraphImages, images: openGraphImages,
...(type === "article"
? {
type: "article" as const,
publishedTime: publishedTime?.toISOString(),
modifiedTime: (modifiedTime ?? publishedTime)?.toISOString(),
}
: { type: "website" as const }),
}, },
twitter: { twitter: {
card: openGraphImages ? "summary_large_image" : "summary", card: openGraphImages ? "summary_large_image" : "summary",
title: resolvedTitle, title: resolvedTitle,
description: resolvedDescription, description: resolvedDescription,
site: seo.twitterHandle || undefined, images: openGraphImages?.map((image) => image.url),
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");
}
+4 -12
View File
@@ -136,21 +136,13 @@ export function getPortfolioWizardProgress(
}, },
{ {
key: "sections", key: "sections",
// Optional: no sections is valid. If sections were added, each must be ready. complete: input.sections.length > 0 && completedSections === input.sections.length,
complete: completedSections === input.sections.length, summary: `${completedSections}/${input.sections.length} sections ready.`,
summary:
input.sections.length === 0
? "Optional — no sections added."
: `${completedSections}/${input.sections.length} sections ready.`,
}, },
{ {
key: "assets", key: "assets",
// Optional: no gallery assets is valid. If assets were added, each must be ready. complete: input.assets.length > 0 && completedAssets === input.assets.length,
complete: completedAssets === input.assets.length, summary: `${completedAssets}/${input.assets.length} assets ready.`,
summary:
input.assets.length === 0
? "Optional — no gallery assets added."
: `${completedAssets}/${input.assets.length} assets ready.`,
}, },
]; ];
} }
-18
View File
@@ -123,21 +123,3 @@ export const projectInputSchema = z.object({
sections: z.array(sectionInputSchema), sections: z.array(sectionInputSchema),
assets: z.array(assetInputSchema), 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,
});
+105 -62
View File
@@ -1,22 +1,71 @@
import { cache } from "react";
import { and, asc, desc, eq } from "drizzle-orm"; import { and, asc, desc, eq } from "drizzle-orm";
import { cache } from "react";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { import { category, portfolioProject } from "@/lib/db/schema";
category as categoryTable, import type { Category, PortfolioAsset, PortfolioProject, PortfolioSection } from "@/lib/db/schema";
portfolioAsset,
portfolioProject,
portfolioSection,
} from "@/lib/db/schema";
import type { PortfolioProjectViewMode } from "@/lib/db/enums"; import type { PortfolioProjectViewMode } from "@/lib/db/enums";
import { getPortfolioMediaBindings } from "@/lib/media"; import { getPortfolioMediaBindings } from "@/lib/media";
import type { AppLocale } from "@/lib/locale"; import type { AppLocale } from "@/lib/locale";
type CategoryRecord = typeof categoryTable.$inferSelect; type CategoryRecord = Pick<
type SectionRecord = typeof portfolioSection.$inferSelect; Category,
type AssetRecord = typeof portfolioAsset.$inferSelect; | "id"
type ProjectRecord = typeof portfolioProject.$inferSelect; | "slug"
| "nameAr"
| "nameEn"
| "nameDe"
| "descriptionAr"
| "descriptionEn"
| "descriptionDe"
| "sortOrder"
| "isActive"
>;
type SectionRecord = Pick<
PortfolioSection,
| "id"
| "type"
| "titleAr"
| "titleEn"
| "titleDe"
| "bodyAr"
| "bodyEn"
| "bodyDe"
| "imagePath"
| "linkUrl"
| "sortOrder"
>;
type AssetRecord = Pick<
PortfolioAsset,
"id" | "kind" | "filePath" | "altAr" | "altEn" | "altDe" | "sortOrder"
>;
type ProjectRecord = Pick<
PortfolioProject,
| "id"
| "slug"
| "viewMode"
| "titleAr"
| "titleEn"
| "titleDe"
| "summaryAr"
| "summaryEn"
| "summaryDe"
| "clientName"
| "projectYear"
| "serviceLabelAr"
| "serviceLabelEn"
| "serviceLabelDe"
| "previewUrl"
| "coverImagePath"
| "isFeatured"
| "isPublished"
| "publishedAt"
| "sortOrder"
>;
export type LocalizedContent = { export type LocalizedContent = {
ar: string; ar: string;
@@ -189,31 +238,35 @@ export function getLocalizedValue(
export async function getAdminPortfolioCategories() { export async function getAdminPortfolioCategories() {
const categories = await db.query.category.findMany({ const categories = await db.query.category.findMany({
orderBy: [asc(categoryTable.sortOrder), asc(categoryTable.createdAt)], with: {
with: { projects: { columns: { id: true } } }, projects: {
columns: { id: true },
},
},
orderBy: [asc(category.sortOrder), asc(category.createdAt)],
}); });
return categories.map((category) => ({ return categories.map((record) => ({
...mapCategory(category), ...mapCategory(record),
projectCount: category.projects.length, projectCount: record.projects.length,
})); }));
} }
export async function getActivePortfolioCategories() { export async function getActivePortfolioCategories() {
const categories = await db.query.category.findMany({ const categories = await db.query.category.findMany({
where: eq(categoryTable.isActive, true), where: eq(category.isActive, true),
orderBy: [asc(categoryTable.sortOrder), asc(categoryTable.createdAt)], orderBy: [asc(category.sortOrder), asc(category.createdAt)],
}); });
return categories.map(mapCategory); return categories.map(mapCategory);
} }
export async function getActivePortfolioCategoryBySlug(slug: string) { export async function getActivePortfolioCategoryBySlug(slug: string) {
const category = await db.query.category.findFirst({ const record = await db.query.category.findFirst({
where: and(eq(categoryTable.slug, slug), eq(categoryTable.isActive, true)), where: and(eq(category.slug, slug), eq(category.isActive, true)),
}); });
return category ? mapCategory(category) : null; return record ? mapCategory(record) : null;
} }
export async function getAdminPortfolioProjects(filters?: { export async function getAdminPortfolioProjects(filters?: {
@@ -221,20 +274,24 @@ export async function getAdminPortfolioProjects(filters?: {
status?: "all" | "draft" | "published"; status?: "all" | "draft" | "published";
}) { }) {
const conditions = [ const conditions = [
...(filters?.categoryId ? [eq(portfolioProject.categoryId, filters.categoryId)] : []), filters?.categoryId ? eq(portfolioProject.categoryId, filters.categoryId) : undefined,
...(filters?.status === "draft" filters?.status === "draft"
? [eq(portfolioProject.isPublished, false)] ? eq(portfolioProject.isPublished, false)
: filters?.status === "published" : filters?.status === "published"
? [eq(portfolioProject.isPublished, true)] ? eq(portfolioProject.isPublished, true)
: []), : undefined,
]; ].filter(Boolean);
const projects = await db.query.portfolioProject.findMany({ const projects = await db.query.portfolioProject.findMany({
where: conditions.length ? and(...conditions) : undefined, where: conditions.length ? and(...conditions) : undefined,
with: { with: {
category: true, category: true,
sections: { orderBy: [asc(portfolioSection.sortOrder), asc(portfolioSection.createdAt)] }, sections: {
assets: { orderBy: [asc(portfolioAsset.sortOrder), asc(portfolioAsset.createdAt)] }, orderBy: (section, { asc: ascOrder }) => [ascOrder(section.sortOrder), ascOrder(section.createdAt)],
},
assets: {
orderBy: (asset, { asc: ascOrder }) => [ascOrder(asset.sortOrder), ascOrder(asset.createdAt)],
},
}, },
orderBy: [asc(portfolioProject.sortOrder), desc(portfolioProject.createdAt)], orderBy: [asc(portfolioProject.sortOrder), desc(portfolioProject.createdAt)],
}); });
@@ -247,8 +304,12 @@ export async function getPublishedPortfolioProjects(filters?: { categorySlug?: s
where: eq(portfolioProject.isPublished, true), where: eq(portfolioProject.isPublished, true),
with: { with: {
category: true, category: true,
sections: { orderBy: [asc(portfolioSection.sortOrder), asc(portfolioSection.createdAt)] }, sections: {
assets: { orderBy: [asc(portfolioAsset.sortOrder), asc(portfolioAsset.createdAt)] }, orderBy: (section, { asc: ascOrder }) => [ascOrder(section.sortOrder), ascOrder(section.createdAt)],
},
assets: {
orderBy: (asset, { asc: ascOrder }) => [ascOrder(asset.sortOrder), ascOrder(asset.createdAt)],
},
}, },
orderBy: [ orderBy: [
asc(portfolioProject.sortOrder), asc(portfolioProject.sortOrder),
@@ -257,8 +318,6 @@ export async function getPublishedPortfolioProjects(filters?: { categorySlug?: s
], ],
}); });
// Prisma filtered on the related category (active + optional slug); the
// relational query filters the main table only, so narrow here.
return projects return projects
.filter( .filter(
(project) => (project) =>
@@ -273,8 +332,12 @@ export const getPublishedPortfolioProjectBySlug = cache(async function (slug: st
where: and(eq(portfolioProject.slug, slug), eq(portfolioProject.isPublished, true)), where: and(eq(portfolioProject.slug, slug), eq(portfolioProject.isPublished, true)),
with: { with: {
category: true, category: true,
sections: { orderBy: [asc(portfolioSection.sortOrder), asc(portfolioSection.createdAt)] }, sections: {
assets: { orderBy: [asc(portfolioAsset.sortOrder), asc(portfolioAsset.createdAt)] }, orderBy: (section, { asc: ascOrder }) => [ascOrder(section.sortOrder), ascOrder(section.createdAt)],
},
assets: {
orderBy: (asset, { asc: ascOrder }) => [ascOrder(asset.sortOrder), ascOrder(asset.createdAt)],
},
}, },
}); });
@@ -285,37 +348,17 @@ export const getPublishedPortfolioProjectBySlug = cache(async function (slug: st
return mapProject(project); return mapProject(project);
}); });
export type ResolvedPortfolioSlug =
| { kind: "category"; category: PortfolioCategoryView }
| { kind: "project"; project: PortfolioProjectView }
| null;
/**
* Resolves a `/portfolio/[slug]` segment to either a category or a project.
* Categories take precedence so `/portfolio/web` shows the category listing;
* a project slug only wins when no active category shares that slug.
*/
export async function resolvePortfolioSlug(slug: string): Promise<ResolvedPortfolioSlug> {
const category = await getActivePortfolioCategoryBySlug(slug);
if (category) {
return { kind: "category", category };
}
const project = await getPublishedPortfolioProjectBySlug(slug);
if (project) {
return { kind: "project", project };
}
return null;
}
export async function getAdminPortfolioProjectById(id: string) { export async function getAdminPortfolioProjectById(id: string) {
const project = await db.query.portfolioProject.findFirst({ const project = await db.query.portfolioProject.findFirst({
where: eq(portfolioProject.id, id), where: eq(portfolioProject.id, id),
with: { with: {
category: true, category: true,
sections: { orderBy: [asc(portfolioSection.sortOrder), asc(portfolioSection.createdAt)] }, sections: {
assets: { orderBy: [asc(portfolioAsset.sortOrder), asc(portfolioAsset.createdAt)] }, orderBy: (section, { asc: ascOrder }) => [ascOrder(section.sortOrder), ascOrder(section.createdAt)],
},
assets: {
orderBy: (asset, { asc: ascOrder }) => [ascOrder(asset.sortOrder), ascOrder(asset.createdAt)],
},
}, },
}); });
-124
View File
@@ -1,124 +0,0 @@
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
@@ -1,145 +0,0 @@
import type { AppLocale } from "./locale";
/**
* Site-wide SEO configuration stored as one JSON blob in `app_config`
* (key `seo_settings`). Everything here is pure: parsing/normalizing only.
* Read/write goes through `lib/app-config.ts`.
*/
export const SEO_SETTINGS_KEY = "seo_settings";
export type SeoStructuredDataType = "Person" | "Organization";
export type SeoLocaleSettings = {
/** Comma-separated keywords (optional, low SEO weight but harmless). */
keywords: string;
};
export type SeoSettings = {
/** Master switch: false → robots disallow all + `noindex` on every page. */
allowIndexing: boolean;
/** `google-site-verification` meta value. */
googleSiteVerification: string;
/** `msvalidate.01` meta value (Bing Webmaster). */
bingSiteVerification: string;
/** `@handle` used for twitter:site / twitter:creator. */
twitterHandle: string;
/** Publisher shape used for JSON-LD on the home page. */
structuredDataType: SeoStructuredDataType;
/** Name shown in JSON-LD (falls back to the site name when empty). */
structuredDataName: string;
/** Person job title / Organization tagline used in JSON-LD. */
structuredDataJobTitle: string;
/** Social profile URLs for `sameAs` in JSON-LD. */
sameAs: string[];
locales: Record<AppLocale, SeoLocaleSettings>;
};
export function buildDefaultSeoSettings(): SeoSettings {
return {
allowIndexing: true,
googleSiteVerification: "",
bingSiteVerification: "",
twitterHandle: "",
structuredDataType: "Person",
structuredDataName: "",
structuredDataJobTitle: "",
sameAs: [],
locales: {
ar: { keywords: "" },
en: { keywords: "" },
de: { keywords: "" },
},
};
}
function normalizeText(value: unknown, maxLength = 500): string {
return typeof value === "string" ? value.trim().slice(0, maxLength) : "";
}
/** Meta verification tokens are alphanumeric with `-` / `_`; anything else is dropped. */
export function normalizeVerificationToken(value: unknown): string {
const text = normalizeText(value, 200);
return /^[A-Za-z0-9_-]+$/.test(text) ? text : "";
}
export function normalizeTwitterHandle(value: unknown): string {
const text = normalizeText(value, 60).replace(/^https?:\/\/(www\.)?(twitter|x)\.com\//i, "").replace(/^@+/, "");
return /^[A-Za-z0-9_]{1,15}$/.test(text) ? `@${text}` : "";
}
export function normalizeSameAs(value: unknown): string[] {
const rawList = Array.isArray(value)
? value
: typeof value === "string"
? value.split(/[\n,]+/)
: [];
const urls = rawList
.map((entry) => normalizeText(entry, 500))
.filter((entry) => /^https:\/\/[^\s]+$/i.test(entry));
return Array.from(new Set(urls)).slice(0, 20);
}
export function normalizeStructuredDataType(value: unknown): SeoStructuredDataType {
return value === "Organization" ? "Organization" : "Person";
}
export function normalizeKeywords(value: unknown): string {
const text = normalizeText(value, 1000);
return text
.split(",")
.map((keyword) => keyword.trim())
.filter(Boolean)
.slice(0, 30)
.join(", ");
}
export function parseSeoSettingsValue(rawValue: string | null | undefined): SeoSettings {
const defaults = buildDefaultSeoSettings();
if (!rawValue) {
return defaults;
}
try {
const parsed = JSON.parse(rawValue) as Record<string, unknown>;
const locales =
parsed.locales && typeof parsed.locales === "object"
? (parsed.locales as Record<string, Record<string, unknown> | undefined>)
: {};
return {
allowIndexing: parsed.allowIndexing !== false,
googleSiteVerification: normalizeVerificationToken(parsed.googleSiteVerification),
bingSiteVerification: normalizeVerificationToken(parsed.bingSiteVerification),
twitterHandle: normalizeTwitterHandle(parsed.twitterHandle),
structuredDataType: normalizeStructuredDataType(parsed.structuredDataType),
structuredDataName: normalizeText(parsed.structuredDataName, 120),
structuredDataJobTitle: normalizeText(parsed.structuredDataJobTitle, 160),
sameAs: normalizeSameAs(parsed.sameAs),
locales: {
ar: { keywords: normalizeKeywords(locales.ar?.keywords) },
en: { keywords: normalizeKeywords(locales.en?.keywords) },
de: { keywords: normalizeKeywords(locales.de?.keywords) },
},
};
} catch {
return defaults;
}
}
/** Map an app locale to the Open Graph `og:locale` format. */
export function toOpenGraphLocale(locale: AppLocale): string {
switch (locale) {
case "ar":
return "ar_AR";
case "en":
return "en_US";
default:
return "de_DE";
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ import { isManagedMediaFilePath, resolveMediaUploadPath } from "./media-storage"
const INTERNAL_FAVICON_PATH = "/favicon.ico"; const INTERNAL_FAVICON_PATH = "/favicon.ico";
const INTERNAL_APPLE_ICON_PATH = "/apple-icon.png"; const INTERNAL_APPLE_ICON_PATH = "/apple-icon.png";
export const INTERNAL_MANIFEST_PATH = "/manifest.webmanifest"; const INTERNAL_MANIFEST_PATH = "/manifest.webmanifest";
const DEFAULT_ICON_VERSION = "default"; const DEFAULT_ICON_VERSION = "default";
const TRANSPARENT_PNG_BASE64 = const TRANSPARENT_PNG_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9sot5WQAAAAASUVORK5CYII="; "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9sot5WQAAAAASUVORK5CYII=";
+8 -84
View File
@@ -21,10 +21,16 @@
} }
}, },
"comingSoon": { "comingSoon": {
"badge": "تحديث مهني",
"kicker": "الموقع قيد إعادة البناء",
"titleLineOne": "الموقع", "titleLineOne": "الموقع",
"titleLineTwo": "قيد التطوير", "titleLineTwo": "قيد التطوير",
"titleLineThree": "وسيعود قريباً", "titleLineThree": "وسيعود قريباً",
"description": "أعيد بناء الموقع ليعرض الخدمات، الأعمال المختارة، وطريقة التعاون بشكل مباشر ومنظم.", "description": "أعيد بناء الموقع ليعرض الخدمات، الأعمال المختارة، وطريقة التعاون بشكل مباشر ومنظم.",
"primaryCta": "ابدأ مشروعاً",
"secondaryCta": "العودة للرئيسية",
"status": "قيد التطوير · يعود قريباً",
"countdownLabel": "الإطلاق خلال",
"unitDays": "أيام", "unitDays": "أيام",
"unitHours": "ساعات", "unitHours": "ساعات",
"unitMinutes": "دقائق", "unitMinutes": "دقائق",
@@ -264,91 +270,9 @@
}, },
"aboutPage": { "aboutPage": {
"title": "من أنا", "title": "من أنا",
"description": "أنا مطور Full-Stack ومصمم جرافيك مقيم في برلين. بتنقل من الفكرة للكود - هوية بصرية وتصميم واجهات والهندسة يلي بتطلعهم عالنور.", "description": "نظرة مركزة على دراستي، دوري الحالي، ونوع شغل الواجهات الذي أقدمه.",
"heroEyebrow": "من أنا", "heroEyebrow": "من أنا",
"story": { "placeholder": "محتوى صفحة من أنا سينضاف هون قريباً."
"eyebrow": "مين أنا",
"title": "بابني منتجات وبصمم كيف بتبين وكيف حاسس فيها المستخدم.",
"paragraphs": [
"أغلب المطورين بسلموا التصميم لغيرهم، وأغلب المصممين بسلموا الكود لغيرهم. أنا بعمل الاثنين - يعني فجوة أقل بين شكل المنتج وطريقة اشتغاله فعلياً.",
"خلفيتي بتغطي الهوية البصرية والتصميم بقد ما بتغطي TypeScript وأنظمة Backend، فبصمم وأنا حاسب حساب التنفيذ، وببني وأنا محافظ على الحس البصري."
]
},
"capabilities": {
"eyebrow": "شو بعمل",
"title": "تطوير وتصميم، بشخص واحد.",
"description": "اختصاصين، جهة تواصل وحدة - من الهوية البصرية لحتى الكود الجاهز للإنتاج.",
"items": [
{
"title": "تطوير الواجهات",
"description": "واجهات Next.js وReact وTypeScript مبنية للسرعة والوضوح وقابلية الصيانة على المدى الطويل."
},
{
"title": "Backend والبيانات",
"description": "Node.js وPostgreSQL وPrisma - أنظمة موثوقة شغالة وراء الواجهة."
},
{
"title": "تصميم بصري وهوية",
"description": "هوية وتصميم UI وأنظمة تخطيط مصممة بـ Figma وIllustrator."
},
{
"title": "أتمتة وأدوات",
"description": "سير عمل داخلي وأدوات بتشيل الشغل اليدوي المتكرر."
}
]
},
"tools": {
"eyebrow": "الأدوات",
"title": "الأدوات اليومية",
"items": [
"Figma",
"Adobe Illustrator",
"Photoshop",
"Next.js",
"TypeScript",
"Tailwind CSS",
"Docker"
]
},
"process": {
"eyebrow": "كيف بشتغل",
"title": "آلية عمل بتخلي التصميم والتطوير ماشيين مع بعض.",
"description": "أربع خطوات، بلا فجوة بين شكل المنتج وطريقة بناءه.",
"steps": [
{
"title": "Discover",
"description": "بفهم المشكلة والجمهور والقيود قبل ما افتح أي أداة."
},
{
"title": "Design",
"description": "الاتجاه البصري وبنية الـ UX بيتحددوا مع بعض، مش الواحد بعد التاني."
},
{
"title": "Build",
"description": "بنفذ بنفس العناية يلي كانت بالتصميم - كود نظيف وجاهز للإنتاج."
},
{
"title": "Refine",
"description": "بصقل التفاصيل، بجرب النتيجة، وبطلق المنتج بثقة."
}
]
},
"personalNote": {
"eyebrow": "برا الشغل",
"text": "مقيم ببرلين. لما ما كون مصمم أو مبرمج، غالباً عم بحاول افهم الفرق بين الشغلتين."
},
"contactCta": {
"eyebrow": "ابدأ الحديث",
"title": "عندك مشروع محتاج تصميم وكود مع بعض؟",
"description": "هات الملخص أو السكتش الأولي أو بس المشكلة. أقدر أساعد أشكل الاتجاه وأصمم النظام وأبنيه.",
"contactCta": "تواصل معي",
"githubCta": "GitHub",
"emailLabel": "البريد الإلكتروني",
"emailValue": "hello@moh-sass.dev",
"availabilityLabel": "التوفر",
"availabilityValue": "مفتوح لشغل منتجات مركز",
"githubHref": "https://github.com/mohfarawati"
}
}, },
"contactPage": { "contactPage": {
"title": "تواصل", "title": "تواصل",
+8 -84
View File
@@ -21,10 +21,16 @@
} }
}, },
"comingSoon": { "comingSoon": {
"badge": "Professionelles Update",
"kicker": "Die Website wird neu aufgebaut",
"titleLineOne": "Website", "titleLineOne": "Website",
"titleLineTwo": "in Bearbeitung", "titleLineTwo": "in Bearbeitung",
"titleLineThree": "bald wieder online", "titleLineThree": "bald wieder online",
"description": "Ich baue die Website neu auf, damit Leistungen, ausgewählte Arbeiten und Zusammenarbeit klarer, direkter und ohne Wiederholungen sichtbar werden.", "description": "Ich baue die Website neu auf, damit Leistungen, ausgewählte Arbeiten und Zusammenarbeit klarer, direkter und ohne Wiederholungen sichtbar werden.",
"primaryCta": "Projekt starten",
"secondaryCta": "Zur Startseite",
"status": "In Entwicklung · bald zurück",
"countdownLabel": "Start in",
"unitDays": "Tage", "unitDays": "Tage",
"unitHours": "Stunden", "unitHours": "Stunden",
"unitMinutes": "Minuten", "unitMinutes": "Minuten",
@@ -264,91 +270,9 @@
}, },
"aboutPage": { "aboutPage": {
"title": "Über mich", "title": "Über mich",
"description": "Ich bin Full-Stack-Entwickler und Grafikdesigner mit Sitz in Berlin. Ich bewege mich von der Idee bis zum Code — visuelle Identität, Interface-Design und die Technik, die es live bringt.", "description": "Ein fokussierter Überblick über meine Ausbildung, meine aktuelle Rolle und die Art von Frontend Arbeit, die ich liefere.",
"heroEyebrow": "Über mich", "heroEyebrow": "Über mich",
"story": { "placeholder": "Der Inhalt der About Seite kommt bald hier hin."
"eyebrow": "Wer ich bin",
"title": "Ich baue Produkte und gestalte, wie sie aussehen und sich anfühlen.",
"paragraphs": [
"Die meisten Entwickler geben Design ab. Die meisten Designer geben Code ab. Ich mache beides — dadurch gibt es weniger Lücken zwischen dem, wie ein Produkt aussieht, und dem, wie es tatsächlich funktioniert.",
"Mein Hintergrund reicht genauso weit in Marken- und visuelles Design wie in TypeScript und Backend-Systeme, deshalb gestalte ich mit Blick auf die Umsetzung und baue mit erhaltenem visuellem Urteilsvermögen."
]
},
"capabilities": {
"eyebrow": "Was ich mache",
"title": "Entwicklung und Design, aus einer Hand.",
"description": "Zwei Disziplinen, ein Ansprechpartner — von der visuellen Identität bis zum produktionsreifen Code.",
"items": [
{
"title": "Frontend-Entwicklung",
"description": "Next.js-, React- und TypeScript-Interfaces, gebaut für Geschwindigkeit, Klarheit und langfristige Wartbarkeit."
},
{
"title": "Backend & Daten",
"description": "Node.js, PostgreSQL und Prisma — verlässliche Systeme hinter dem Interface."
},
{
"title": "Visuelles & Marken-Design",
"description": "Identität, UI-Design und Layout-Systeme, gestaltet in Figma und Illustrator."
},
{
"title": "Automatisierung & Tooling",
"description": "Interne Workflows und Tools, die manuelle, sich wiederholende Arbeit entfernen."
}
]
},
"tools": {
"eyebrow": "Tools",
"title": "Täglicher Stack",
"items": [
"Figma",
"Adobe Illustrator",
"Photoshop",
"Next.js",
"TypeScript",
"Tailwind CSS",
"Docker"
]
},
"process": {
"eyebrow": "Wie ich arbeite",
"title": "Ein Prozess, der Design und Entwicklung im Takt hält.",
"description": "Vier Schritte, keine Übergabe-Lücke zwischen Aussehen und Umsetzung.",
"steps": [
{
"title": "Discover",
"description": "Problem, Zielgruppe und Rahmenbedingungen klären, bevor überhaupt ein Tool geöffnet wird."
},
{
"title": "Design",
"description": "Visuelle Richtung und UX-Struktur werden gemeinsam erarbeitet, nicht nacheinander."
},
{
"title": "Build",
"description": "Umsetzung mit derselben Sorgfalt wie im Design — sauberer, produktionsreifer Code."
},
{
"title": "Refine",
"description": "Details verfeinern, das Ergebnis testen und mit Zuversicht ausliefern."
}
]
},
"personalNote": {
"eyebrow": "Feierabend",
"text": "Ansässig in Berlin. Wenn ich nicht gerade designe oder code, untersuche ich wahrscheinlich den Unterschied zwischen beidem."
},
"contactCta": {
"eyebrow": "Gespräch starten",
"title": "Ein Projekt, das Design und Code gleichzeitig braucht?",
"description": "Bring das Briefing, die grobe Skizze oder einfach das Problem mit. Ich kann die Richtung formen, das System entwerfen und es bauen.",
"contactCta": "Kontakt aufnehmen",
"githubCta": "GitHub",
"emailLabel": "E-Mail",
"emailValue": "hello@moh-sass.dev",
"availabilityLabel": "Verfügbarkeit",
"availabilityValue": "Offen für fokussierte Produktarbeit",
"githubHref": "https://github.com/mohfarawati"
}
}, },
"contactPage": { "contactPage": {
"title": "Kontakt", "title": "Kontakt",
+8 -84
View File
@@ -21,10 +21,16 @@
} }
}, },
"comingSoon": { "comingSoon": {
"badge": "Professional update",
"kicker": "The site is being rebuilt",
"titleLineOne": "Website", "titleLineOne": "Website",
"titleLineTwo": "under development", "titleLineTwo": "under development",
"titleLineThree": "returning soon", "titleLineThree": "returning soon",
"description": "I am rebuilding the site to present services, selected work, and collaboration details with sharper structure and less noise.", "description": "I am rebuilding the site to present services, selected work, and collaboration details with sharper structure and less noise.",
"primaryCta": "Start a project",
"secondaryCta": "Back to homepage",
"status": "In development · back soon",
"countdownLabel": "Launching in",
"unitDays": "Days", "unitDays": "Days",
"unitHours": "Hours", "unitHours": "Hours",
"unitMinutes": "Minutes", "unitMinutes": "Minutes",
@@ -264,91 +270,9 @@
}, },
"aboutPage": { "aboutPage": {
"title": "About", "title": "About",
"description": "I'm a full-stack developer and graphic designer based in Berlin. I move from concept to code — visual identity, interface design, and the engineering that ships it.", "description": "A focused overview of my education, current role, and the kind of frontend work I deliver.",
"heroEyebrow": "About", "heroEyebrow": "About",
"story": { "placeholder": "About page content will be added here soon."
"eyebrow": "Who I am",
"title": "I build products and design the way they look and feel.",
"paragraphs": [
"Most developers hand off design. Most designers hand off code. I do both — which means fewer gaps between how a product looks and how it actually works.",
"My background spans brand and visual design as much as it spans TypeScript and backend systems, so I design with implementation in mind and build with visual judgment intact."
]
},
"capabilities": {
"eyebrow": "What I do",
"title": "Development and design, handled by the same person.",
"description": "Two disciplines, one point of contact — from visual identity to production code.",
"items": [
{
"title": "Frontend Development",
"description": "Next.js, React, and TypeScript interfaces built for speed, clarity, and long-term maintainability."
},
{
"title": "Backend & Data",
"description": "Node.js, PostgreSQL, and Prisma — reliable systems working behind the interface."
},
{
"title": "Visual & Brand Design",
"description": "Identity, UI design, and layout systems crafted in Figma and Illustrator."
},
{
"title": "Automation & Tooling",
"description": "Internal workflows and tools that remove manual, repetitive work."
}
]
},
"tools": {
"eyebrow": "Tools",
"title": "Daily stack",
"items": [
"Figma",
"Adobe Illustrator",
"Photoshop",
"Next.js",
"TypeScript",
"Tailwind CSS",
"Docker"
]
},
"process": {
"eyebrow": "How I work",
"title": "A process that keeps design and engineering in sync.",
"description": "Four steps, no handoff gap between how it looks and how it's built.",
"steps": [
{
"title": "Discover",
"description": "Understand the problem, audience, and constraints before opening any tool."
},
{
"title": "Design",
"description": "Visual direction and UX structure worked out together, not in sequence."
},
{
"title": "Build",
"description": "Implement with the same care the design had — clean, production-ready code."
},
{
"title": "Refine",
"description": "Polish detail, test the result, and ship with confidence."
}
]
},
"personalNote": {
"eyebrow": "Off the clock",
"text": "Based in Berlin. When I'm not designing or coding, I'm probably studying the difference between the two."
},
"contactCta": {
"eyebrow": "Start the conversation",
"title": "Have a project that needs both design and code?",
"description": "Bring the brief, the rough sketch, or just the problem. I can shape the direction, design the system, and build it.",
"contactCta": "Contact me",
"githubCta": "GitHub",
"emailLabel": "Email",
"emailValue": "hello@moh-sass.dev",
"availabilityLabel": "Availability",
"availabilityValue": "Open for focused product work",
"githubHref": "https://github.com/mohfarawati"
}
}, },
"contactPage": { "contactPage": {
"title": "Contact", "title": "Contact",
+35 -1988
View File
File diff suppressed because it is too large Load Diff
+8 -15
View File
@@ -3,19 +3,19 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"prepare": "git config core.hooksPath .githooks || true", "dev": "next dev",
"dev": "next dev -p 3014",
"build": "next build --webpack", "build": "next build --webpack",
"start": "next start", "start": "next start",
"lint": "eslint .", "lint": "eslint .",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push", "db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio" "db:studio": "drizzle-kit studio",
"db:seed": "tsx lib/db/seed.ts"
}, },
"dependencies": { "dependencies": {
"@paralleldrive/cuid2": "^2.2.2",
"@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
@@ -25,7 +25,7 @@
"@types/nodemailer": "^7.0.11", "@types/nodemailer": "^7.0.11",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.44.5",
"framer-motion": "^12.35.0", "framer-motion": "^12.35.0",
"gsap": "^3.15.0", "gsap": "^3.15.0",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
@@ -33,8 +33,7 @@
"next-intl": "^4.8.3", "next-intl": "^4.8.3",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nodemailer": "^8.0.1", "nodemailer": "^8.0.1",
"pg": "^8.20.0", "postgres": "^3.4.5",
"postgres": "^3.4.9",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-hook-form": "^7.71.2", "react-hook-form": "^7.71.2",
@@ -42,22 +41,16 @@
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@electric-sql/pglite": "^0.5.4",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^7.0.0",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.3",
"@types/node": "^20", "@types/node": "^20",
"@types/pg": "^8.18.0",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"drizzle-kit": "^0.31.10", "drizzle-kit": "^0.31.4",
"eslint": "^9.39.4", "eslint": "^9.39.4",
"eslint-config-next": "^16.1.6", "eslint-config-next": "^16.1.6",
"jsdom": "^30.0.1",
"postcss": "^8", "postcss": "^8",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"tsx": "^4.19.2",
"typescript": "^5", "typescript": "^5",
"vitest": "^3.2.4" "vitest": "^3.2.4"
} }
+3 -2
View File
@@ -16,7 +16,6 @@ import {
isLegacyAdminPath, isLegacyAdminPath,
toInternalAdminPath, toInternalAdminPath,
} from "./lib/admin-routing"; } from "./lib/admin-routing";
import { ADMIN_SESSION_COOKIE, verifyAdminSessionToken } from "./lib/admin-session-token";
import { import {
FALLBACK_LOCALE, FALLBACK_LOCALE,
getLocalizedPathWithDefault, getLocalizedPathWithDefault,
@@ -24,6 +23,8 @@ import {
stripLocalePrefix, stripLocalePrefix,
} from "./lib/locale"; } from "./lib/locale";
const ADMIN_SESSION_COOKIE = "moh_admin_session";
type SiteRuntimeState = { type SiteRuntimeState = {
defaultLocale: (typeof appLocales)[number]; defaultLocale: (typeof appLocales)[number];
maintenanceEnabled: boolean; maintenanceEnabled: boolean;
@@ -219,7 +220,7 @@ export default async function middleware(request: NextRequest) {
if ( if (
siteRuntimeState.maintenanceEnabled && siteRuntimeState.maintenanceEnabled &&
!verifyAdminSessionToken(request.cookies.get(ADMIN_SESSION_COOKIE)?.value) && !request.cookies.has(ADMIN_SESSION_COOKIE) &&
!isComingSoonPath(pathname) !isComingSoonPath(pathname)
) { ) {
const locale = getPathLocale(pathname, configuredDefaultLocale); const locale = getPathLocale(pathname, configuredDefaultLocale);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

-605
View File
@@ -1,605 +0,0 @@
const { PrismaPg } = require("@prisma/adapter-pg");
const { PrismaClient } = require("@prisma/client");
const { Pool } = require("pg");
const connectionString =
process.env.DATABASE_URL ||
"postgresql://postgres:postgres@localhost:5432/moh_sass?schema=public";
const pool = new Pool({ connectionString });
const prisma = new PrismaClient({ adapter: new PrismaPg(pool) });
async function upsertMediaAsset(input) {
const existing = await prisma.mediaAsset.findFirst({
where: {
label: input.label,
url: input.url,
},
});
if (existing) {
return prisma.mediaAsset.update({
where: { id: existing.id },
data: input,
});
}
return prisma.mediaAsset.create({
data: input,
});
}
async function syncProjectContent(projectId, sections, assets) {
await prisma.portfolioSection.deleteMany({
where: { projectId },
});
await prisma.portfolioAsset.deleteMany({
where: { projectId },
});
const createdSections = [];
for (const section of sections) {
const createdSection = await prisma.portfolioSection.create({
data: {
projectId,
...section,
},
});
createdSections.push(createdSection);
}
const createdAssets = [];
for (const asset of assets) {
const createdAsset = await prisma.portfolioAsset.create({
data: {
projectId,
...asset,
},
});
createdAssets.push(createdAsset);
}
return { createdSections, createdAssets };
}
async function syncProjectMediaUsages(projectId, mediaMap) {
await prisma.mediaUsage.deleteMany({
where: {
entityType: "portfolio-project",
entityId: projectId,
},
});
const usages = [];
if (mediaMap.coverAssetId) {
usages.push({
assetId: mediaMap.coverAssetId,
usageType: "PORTFOLIO_COVER",
entityType: "portfolio-project",
entityId: projectId,
fieldKey: "cover",
});
}
for (const sectionUsage of mediaMap.sectionUsages) {
usages.push({
assetId: sectionUsage.assetId,
usageType: "PORTFOLIO_SECTION",
entityType: "portfolio-project",
entityId: projectId,
fieldKey: sectionUsage.fieldKey,
});
}
for (const assetUsage of mediaMap.assetUsages) {
usages.push({
assetId: assetUsage.assetId,
usageType: "PORTFOLIO_ASSET",
entityType: "portfolio-project",
entityId: projectId,
fieldKey: assetUsage.fieldKey,
});
}
if (usages.length > 0) {
await prisma.mediaUsage.createMany({ data: usages });
}
}
async function main() {
await prisma.mediaUsage.deleteMany({
where: {
entityType: "portfolio-project",
},
});
await prisma.portfolioSection.deleteMany();
await prisma.portfolioAsset.deleteMany();
await prisma.portfolioProject.deleteMany();
await prisma.category.deleteMany();
await prisma.appConfig.upsert({
where: { key: "siteName" },
update: { value: "moh-sass" },
create: { key: "siteName", value: "moh-sass" },
});
await prisma.appConfig.upsert({
where: { key: "site_settings" },
update: {
value: JSON.stringify({
titleTemplate: "{pageTitle} | moh-sass",
locales: {
ar: {
siteName: "moh-sass",
titleTemplate: "{pageTitle} | {siteName}",
siteDescription: "Multilingual Next.js base project",
subhead: "",
},
en: {
siteName: "moh-sass",
titleTemplate: "{pageTitle} | {siteName}",
siteDescription: "Multilingual Next.js base project",
subhead: "",
},
de: {
siteName: "moh-sass",
titleTemplate: "{pageTitle} | {siteName}",
siteDescription: "Multilingual Next.js base project",
subhead: "",
},
},
}),
},
create: {
key: "site_settings",
value: JSON.stringify({
titleTemplate: "{pageTitle} | moh-sass",
locales: {
ar: {
siteName: "moh-sass",
titleTemplate: "{pageTitle} | {siteName}",
siteDescription: "Multilingual Next.js base project",
subhead: "",
},
en: {
siteName: "moh-sass",
titleTemplate: "{pageTitle} | {siteName}",
siteDescription: "Multilingual Next.js base project",
subhead: "",
},
de: {
siteName: "moh-sass",
titleTemplate: "{pageTitle} | {siteName}",
siteDescription: "Multilingual Next.js base project",
subhead: "",
},
},
}),
},
});
const brandCategory = await prisma.category.upsert({
where: { slug: "branding" },
update: {
nameAr: "الهوية البصرية",
nameEn: "Branding",
nameDe: "Branding",
descriptionAr: "مشاريع هوية بصرية وشعارات وأنظمة علامة.",
descriptionEn: "Brand identity, logo, and design system work.",
descriptionDe: "Branding, Logos und visuelle Systeme.",
sortOrder: 1,
isActive: true,
},
create: {
slug: "branding",
nameAr: "الهوية البصرية",
nameEn: "Branding",
nameDe: "Branding",
descriptionAr: "مشاريع هوية بصرية وشعارات وأنظمة علامة.",
descriptionEn: "Brand identity, logo, and design system work.",
descriptionDe: "Branding, Logos und visuelle Systeme.",
sortOrder: 1,
isActive: true,
},
});
const webCategory = await prisma.category.upsert({
where: { slug: "web-experiences" },
update: {
nameAr: "تجارب الويب",
nameEn: "Web Experiences",
nameDe: "Web Experiences",
descriptionAr: "مواقع وصفحات هبوط وتجارب رقمية سريعة.",
descriptionEn: "Websites, landing pages, and digital experiences.",
descriptionDe: "Webseiten, Landingpages und digitale Erlebnisse.",
sortOrder: 2,
isActive: true,
},
create: {
slug: "web-experiences",
nameAr: "تجارب الويب",
nameEn: "Web Experiences",
nameDe: "Web Experiences",
descriptionAr: "مواقع وصفحات هبوط وتجارب رقمية سريعة.",
descriptionEn: "Websites, landing pages, and digital experiences.",
descriptionDe: "Webseiten, Landingpages und digitale Erlebnisse.",
sortOrder: 2,
isActive: true,
},
});
const commerceCategory = await prisma.category.upsert({
where: { slug: "commerce" },
update: {
nameAr: "التجارة الرقمية",
nameEn: "Commerce",
nameDe: "Commerce",
descriptionAr: "متاجر وتجارب شراء رقمية مع تركيز على الوضوح والتحويل.",
descriptionEn: "Commerce experiences with a focus on clarity and conversion.",
descriptionDe: "Commerce-Projekte mit Fokus auf Klarheit und Conversion.",
sortOrder: 3,
isActive: true,
},
create: {
slug: "commerce",
nameAr: "التجارة الرقمية",
nameEn: "Commerce",
nameDe: "Commerce",
descriptionAr: "متاجر وتجارب شراء رقمية مع تركيز على الوضوح والتحويل.",
descriptionEn: "Commerce experiences with a focus on clarity and conversion.",
descriptionDe: "Commerce-Projekte mit Fokus auf Klarheit und Conversion.",
sortOrder: 3,
isActive: true,
},
});
const gridCover = await upsertMediaAsset({
source: "UPLOAD",
kind: "IMAGE",
url: "/uploads/portfolio/demo-cover.svg",
fileName: "demo-cover.svg",
label: "Portfolio Grid Cover",
altText: "Portfolio Grid Cover",
mimeType: "image/svg+xml",
size: 1024,
});
const storyCover = await upsertMediaAsset({
source: "UPLOAD",
kind: "IMAGE",
url: "/uploads/portfolio/demo-cover.svg",
fileName: "demo-cover.svg",
label: "Portfolio Story Cover",
altText: "Portfolio Story Cover",
mimeType: "image/svg+xml",
size: 1024,
});
const caseStudyCover = await upsertMediaAsset({
source: "UPLOAD",
kind: "IMAGE",
url: "/uploads/portfolio/demo-cover.svg",
fileName: "demo-cover.svg",
label: "Portfolio Case Study Cover",
altText: "Portfolio Case Study Cover",
mimeType: "image/svg+xml",
size: 1024,
});
const projects = [
{
slug: "grid-product-launch",
categoryId: commerceCategory.id,
viewMode: "GRID",
titleAr: "إطلاق منتج رقمي",
titleEn: "Grid Product Launch",
titleDe: "Grid Product Launch",
summaryAr: "مثال عرض شبكي لمشروع سريع مع أقسام قصيرة وأصول داعمة.",
summaryEn: "Grid view example for a fast product launch page.",
summaryDe: "Grid-Ansicht als Beispiel fuer einen schnellen Produktlaunch.",
clientName: "Launch Studio",
projectYear: 2026,
serviceLabelAr: "تجربة إطلاق",
serviceLabelEn: "Launch Experience",
serviceLabelDe: "Launch Experience",
previewUrl: "https://example.com/preview/grid-product-launch",
coverImagePath: gridCover.url,
isFeatured: true,
isPublished: true,
publishedAt: new Date("2026-01-12T09:00:00.000Z"),
sortOrder: 1,
coverAssetId: gridCover.id,
sections: [
{
type: "RICH_TEXT",
titleAr: "الفكرة",
titleEn: "Concept",
titleDe: "Konzept",
bodyAr: "واجهة سريعة لعرض المنتج والتركيز على الرسالة الأساسية.",
bodyEn: "A fast modular presentation focused on the main launch message.",
bodyDe: "Eine schnelle modulare Darstellung mit Fokus auf die Hauptbotschaft.",
imagePath: null,
linkUrl: null,
sortOrder: 0,
mediaAssetId: null,
},
{
type: "GALLERY",
titleAr: "الصورة الرئيسية",
titleEn: "Hero Visual",
titleDe: "Hero Visual",
bodyAr: "",
bodyEn: "",
bodyDe: "",
imagePath: gridCover.url,
linkUrl: null,
sortOrder: 1,
mediaAssetId: gridCover.id,
},
],
assets: [
{
kind: "IMAGE",
filePath: gridCover.url,
altAr: "غلاف مشروع Grid",
altEn: "Grid project cover",
altDe: "Grid Projekt Cover",
sortOrder: 0,
mediaAssetId: gridCover.id,
},
],
},
{
slug: "campaign-site",
categoryId: webCategory.id,
viewMode: "STORY",
titleAr: "موقع حملة",
titleEn: "Campaign Site",
titleDe: "Campaign Site",
summaryAr: "مثال عرض قصصي لمشروع ويب مع تسلسل سردي أوضح.",
summaryEn: "Story view example for a launch campaign website.",
summaryDe: "Story-Ansicht als Beispiel fuer eine Kampagnenseite.",
clientName: "Launch Client",
projectYear: 2024,
serviceLabelAr: "موقع تسويقي",
serviceLabelEn: "Marketing Website",
serviceLabelDe: "Marketing Website",
previewUrl: "https://example.com/preview/campaign-site",
coverImagePath: storyCover.url,
isFeatured: false,
isPublished: true,
publishedAt: new Date("2024-09-05T09:00:00.000Z"),
sortOrder: 2,
coverAssetId: storyCover.id,
sections: [
{
type: "RICH_TEXT",
titleAr: "السياق",
titleEn: "Context",
titleDe: "Kontext",
bodyAr: "الحملة احتاجت صفحة مرنة وسريعة تتبدل بين أكثر من مرحلة.",
bodyEn: "The campaign needed a flexible page that could adapt across phases.",
bodyDe: "Die Kampagne brauchte eine flexible Seite fuer mehrere Phasen.",
imagePath: null,
linkUrl: null,
sortOrder: 0,
mediaAssetId: null,
},
{
type: "GALLERY",
titleAr: "العرض البصري",
titleEn: "Visual Flow",
titleDe: "Visueller Ablauf",
bodyAr: "",
bodyEn: "",
bodyDe: "",
imagePath: storyCover.url,
linkUrl: null,
sortOrder: 1,
mediaAssetId: storyCover.id,
},
{
type: "LINK",
titleAr: "المعاينة",
titleEn: "Preview",
titleDe: "Vorschau",
bodyAr: "رابط العرض المباشر.",
bodyEn: "Direct preview link.",
bodyDe: "Direkter Vorschau-Link.",
imagePath: null,
linkUrl: "https://example.com/preview/campaign-site",
sortOrder: 2,
mediaAssetId: null,
},
],
assets: [
{
kind: "IMAGE",
filePath: storyCover.url,
altAr: "غلاف مشروع Story",
altEn: "Story project cover",
altDe: "Story Projekt Cover",
sortOrder: 0,
mediaAssetId: storyCover.id,
},
],
},
{
slug: "brand-redesign",
categoryId: brandCategory.id,
viewMode: "CASE_STUDY",
titleAr: "إعادة تصميم الهوية",
titleEn: "Brand Redesign",
titleDe: "Brand Redesign",
summaryAr: "مثال عرض دراسة حالة يركز على التحدي والحل والنتيجة.",
summaryEn: "Case study example focused on challenge, solution, and outcome.",
summaryDe: "Case-Study-Ansicht mit Fokus auf Herausforderung, Loesung und Ergebnis.",
clientName: "Studio Client",
projectYear: 2025,
serviceLabelAr: "هوية بصرية",
serviceLabelEn: "Brand Identity",
serviceLabelDe: "Brand Identity",
previewUrl: "https://example.com/preview/brand-redesign",
coverImagePath: caseStudyCover.url,
isFeatured: true,
isPublished: true,
publishedAt: new Date("2025-01-10T09:00:00.000Z"),
sortOrder: 3,
coverAssetId: caseStudyCover.id,
sections: [
{
type: "RICH_TEXT",
titleAr: "التحدي",
titleEn: "Challenge",
titleDe: "Herausforderung",
bodyAr: "كان المطلوب تحديث الهوية بدون خسارة التعرف البصري الحالي.",
bodyEn: "The brief required a refreshed identity without losing recognition.",
bodyDe: "Die Marke sollte modernisiert werden, ohne die Wiedererkennbarkeit zu verlieren.",
imagePath: null,
linkUrl: null,
sortOrder: 0,
mediaAssetId: null,
},
{
type: "RICH_TEXT",
titleAr: "الحل",
titleEn: "Solution",
titleDe: "Loesung",
bodyAr: "تم بناء نظام مرئي أوضح مع قواعد استخدام قابلة للتوسع.",
bodyEn: "A clearer visual system with scalable usage rules was created.",
bodyDe: "Es wurde ein klareres visuelles System mit skalierbaren Regeln aufgebaut.",
imagePath: null,
linkUrl: null,
sortOrder: 1,
mediaAssetId: null,
},
{
type: "GALLERY",
titleAr: "التنفيذ البصري",
titleEn: "Visual Execution",
titleDe: "Visuelle Umsetzung",
bodyAr: "",
bodyEn: "",
bodyDe: "",
imagePath: caseStudyCover.url,
linkUrl: null,
sortOrder: 2,
mediaAssetId: caseStudyCover.id,
},
],
assets: [
{
kind: "IMAGE",
filePath: caseStudyCover.url,
altAr: "غلاف مشروع Case Study",
altEn: "Case study project cover",
altDe: "Case Study Projekt Cover",
sortOrder: 0,
mediaAssetId: caseStudyCover.id,
},
],
},
];
for (const projectConfig of projects) {
const project = await prisma.portfolioProject.upsert({
where: { slug: projectConfig.slug },
update: {
categoryId: projectConfig.categoryId,
viewMode: projectConfig.viewMode,
titleAr: projectConfig.titleAr,
titleEn: projectConfig.titleEn,
titleDe: projectConfig.titleDe,
summaryAr: projectConfig.summaryAr,
summaryEn: projectConfig.summaryEn,
summaryDe: projectConfig.summaryDe,
clientName: projectConfig.clientName,
projectYear: projectConfig.projectYear,
serviceLabelAr: projectConfig.serviceLabelAr,
serviceLabelEn: projectConfig.serviceLabelEn,
serviceLabelDe: projectConfig.serviceLabelDe,
previewUrl: projectConfig.previewUrl,
coverImagePath: projectConfig.coverImagePath,
isFeatured: projectConfig.isFeatured,
isPublished: projectConfig.isPublished,
publishedAt: projectConfig.publishedAt,
sortOrder: projectConfig.sortOrder,
},
create: {
slug: projectConfig.slug,
categoryId: projectConfig.categoryId,
viewMode: projectConfig.viewMode,
titleAr: projectConfig.titleAr,
titleEn: projectConfig.titleEn,
titleDe: projectConfig.titleDe,
summaryAr: projectConfig.summaryAr,
summaryEn: projectConfig.summaryEn,
summaryDe: projectConfig.summaryDe,
clientName: projectConfig.clientName,
projectYear: projectConfig.projectYear,
serviceLabelAr: projectConfig.serviceLabelAr,
serviceLabelEn: projectConfig.serviceLabelEn,
serviceLabelDe: projectConfig.serviceLabelDe,
previewUrl: projectConfig.previewUrl,
coverImagePath: projectConfig.coverImagePath,
isFeatured: projectConfig.isFeatured,
isPublished: projectConfig.isPublished,
publishedAt: projectConfig.publishedAt,
sortOrder: projectConfig.sortOrder,
},
});
const created = await syncProjectContent(project.id, projectConfig.sections.map((section) => ({
type: section.type,
titleAr: section.titleAr,
titleEn: section.titleEn,
titleDe: section.titleDe,
bodyAr: section.bodyAr,
bodyEn: section.bodyEn,
bodyDe: section.bodyDe,
imagePath: section.imagePath,
linkUrl: section.linkUrl,
sortOrder: section.sortOrder,
})), projectConfig.assets.map((asset) => ({
kind: asset.kind,
filePath: asset.filePath,
altAr: asset.altAr,
altEn: asset.altEn,
altDe: asset.altDe,
sortOrder: asset.sortOrder,
})));
await syncProjectMediaUsages(project.id, {
coverAssetId: projectConfig.coverAssetId,
sectionUsages: created.createdSections
.map((sectionRow, index) => ({
fieldKey: sectionRow.id,
assetId: projectConfig.sections[index]?.mediaAssetId,
}))
.filter((entry) => entry.assetId),
assetUsages: created.createdAssets
.map((assetRow, index) => ({
fieldKey: assetRow.id,
assetId: projectConfig.assets[index]?.mediaAssetId,
}))
.filter((entry) => entry.assetId),
});
}
}
main()
.catch((error) => {
console.error("Seed failed:", error);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
await pool.end();
});
-114
View File
@@ -1,114 +0,0 @@
#!/usr/bin/env node
/**
* Runs the test suite with Vitest's JSON reporter (plus the normal live output)
* and prints ONE compact, copy-pasteable summary at the end — pass/fail counts
* and every failing test with a one-line reason. Paste the block between the
* ===== markers to hand off the full picture without a wall of logs.
*
* Exits with the suite's own status, so `make test` / the pre-push hook still
* block on failure. This file is the same across all my projects; only RUNS
* differs (a project with several vitest configs lists one entry per config).
*/
import { spawnSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
// The npm script(s) that together make up "the whole suite". Run through npm (not
// `npx vitest` directly) so nested tooling in a globalSetup — e.g. `npx drizzle-kit
// migrate` — resolves with the right PATH. A project split across several vitest
// configs lists one entry per config.
const RUNS = [{ label: "all", args: ["test"] }];
const reporterArgs = (out) => ["--", "--reporter=default", "--reporter=json", `--outputFile.json=${out}`];
const projectName = (() => {
try {
return JSON.parse(readFileSync("package.json", "utf8")).name ?? "project";
} catch {
return "project";
}
})();
const workDir = mkdtempSync(join(tmpdir(), "test-summary-"));
const started = Date.now();
let status = 0;
const reports = [];
for (const run of RUNS) {
const out = join(workDir, `${run.label}.json`);
const res = spawnSync("npm", ["run", ...run.args, ...reporterArgs(out)], {
stdio: "inherit",
shell: process.platform === "win32",
});
if (res.status !== 0) status = res.status ?? 1;
try {
reports.push(JSON.parse(readFileSync(out, "utf8")));
} catch {
/* a crash before the report was written — status already non-zero */
}
}
let total = 0;
let passed = 0;
let failed = 0;
let skipped = 0;
let files = 0;
let filesFailed = 0;
const failures = [];
const cwd = process.cwd();
for (const r of reports) {
total += r.numTotalTests ?? 0;
passed += r.numPassedTests ?? 0;
failed += r.numFailedTests ?? 0;
skipped += (r.numPendingTests ?? 0) + (r.numTodoTests ?? 0);
for (const tr of r.testResults ?? []) {
files += 1;
const fileFailed = tr.status === "failed" || (tr.assertionResults ?? []).some((a) => a.status === "failed");
if (fileFailed) filesFailed += 1;
for (const a of tr.assertionResults ?? []) {
if (a.status !== "failed") continue;
const file = (tr.name ?? "").replace(`${cwd}/`, "");
const name = a.fullName || [...(a.ancestorTitles ?? []), a.title].filter(Boolean).join(" ");
const reason =
(a.failureMessages ?? [])
.join("\n")
.split("\n")
.map((l) => l.trim())
.find((l) => l && !l.startsWith("at ")) ?? "";
failures.push({ file, name, reason: reason.slice(0, 200) });
}
}
}
const elapsed = ((Date.now() - started) / 1000).toFixed(1);
const ok = status === 0 && failed === 0 && reports.length > 0;
const L = "=".repeat(38);
const lines = [];
lines.push(L);
lines.push(`TEST SUMMARY — ${projectName} (${new Date().toISOString().slice(0, 16).replace("T", " ")})`);
if (reports.length === 0) {
lines.push("❌ CRASH — the test run failed before producing a report (see output above).");
} else {
lines.push(
`${ok ? "✅ PASS" : "❌ FAIL"}${passed}/${total} tests passed` +
(failed ? `, ${failed} failed` : "") +
(skipped ? `, ${skipped} skipped` : "") +
` · ${files} files (${filesFailed} failed) · ${elapsed}s`,
);
if (failures.length) {
lines.push("");
lines.push(`FAILED (${failures.length}):`);
for (const f of failures) {
lines.push(`${f.file} ${f.name}`);
if (f.reason) lines.push(` ${f.reason}`);
}
}
}
lines.push(L);
console.log(`\n${lines.join("\n")}`);
rmSync(workDir, { recursive: true, force: true });
process.exit(status);
+1 -1
View File
@@ -27,7 +27,7 @@
- Overview dashboard - Overview dashboard
- Maintenance mode - Maintenance mode
- Media Library - Media Library
- Site Settings (Brand / Localization / SEO — see `docs/SEO.md`) - Site Settings
- Marquee Settings - Marquee Settings
- SMTP Settings - SMTP Settings
- Contact Protection - Contact Protection
-154
View File
@@ -1,154 +0,0 @@
# Test Plan — mohfarawati.de
Comprehensive automated test coverage for the multilingual Next.js portfolio + admin
workspace. Focus: **code correctness**. Scope excludes Playwright / browser E2E (per request).
## 1. Goals & principles
- Cover every application module: pure helpers, validation/schemas, data layer (Prisma),
server actions, API routes, middleware, forms, components, and architecture rules.
- **Do not change production behaviour.** Tests observe the code as-is. If a test reveals a
real bug or an architecture-rule violation, it is flagged for the owner — production code is
not changed without approval.
- Deterministic and self-contained: no external network, no reliance on a running app server.
- Real Postgres for the data layer (not mocks). See §3.
## 2. Test taxonomy & runner layout
Vitest with three **projects** (isolated environments), selected by file location:
| Project | Env | Location | Parallel | Purpose |
|---|---|---|---|---|
| `unit` | node | `tests/unit/**` | yes | Pure functions, schemas, formatting, mapping logic |
| `integration` | node | `tests/integration/**` | serial | Prisma data layer, server actions, API routes, middleware |
| `component` | jsdom | `tests/component/**` | yes | React components & forms (RTL) |
Legacy flat `tests/*.test.ts` files are folded into the new structure (kept passing).
### Tooling added (dev-only)
- `@testing-library/react`, `@testing-library/dom`, `@testing-library/jest-dom`,
`@testing-library/user-event`, `jsdom`, `@vitejs/plugin-react` — component tests.
- `@electric-sql/pglite`, `pglite-prisma-adapter` — an **embedded real Postgres**
(Postgres compiled to WASM) that runs the project's actual migrations and Prisma queries
in-process, via a Prisma driver adapter.
### Database strategy (real Postgres)
`tests/helpers/integration-setup.ts` + `tests/helpers/global-db-setup.ts`:
- If `TEST_DATABASE_URL` is set → the real `lib/prisma` singleton is used unchanged, pointed
at that Postgres (e.g. the Docker instance). The global setup resets the schema and applies
every `prisma/migrations/*/migration.sql` once before the run.
- Otherwise → `lib/prisma` is mocked (test-only) with a Prisma client backed by an in-process
PGlite database. Each worker gets its own isolated database with the migrations applied —
real Postgres semantics, no external server, no shared-state races. **Production code is
never modified.**
- `resetDb()` (TRUNCATE all tables, restart identities) runs in `beforeEach`.
- The integration project runs **serially** (`fileParallelism: false`); each file gets a fresh
database connection.
### Next.js runtime mocks (integration)
Server actions/middleware depend on the Next runtime. `tests/helpers/next-mocks.ts` provides:
- `next/navigation``redirect()` throws a catchable `NEXT_REDIRECT` carrying the URL.
- `next/dist/client/components/redirect-error``isRedirectError()` recognises the above.
- `next/cache``revalidatePath()` spy (no-op, asserted).
- `next/headers` → controllable `cookies()` / `headers()` stores.
- `@/lib/admin-auth` `isAdminAuthenticated` → toggled per test (auth guard tests).
- `nodemailer` → captured transport (no real SMTP).
## 3. Coverage matrix
### 3.1 Unit — pure lib
| Module | Cases |
|---|---|
| `admin-routing` | host resolution (forwarded/comma/port), `isAdminHost`, `hasDedicatedAdminHost`, legacy/dev/internal path predicates, `toInternalAdminPath`, `fromDevelopmentAdminPath`, `getAdminAppPath` dev vs prod, `buildAdminUrl`/`buildSiteUrl`, env overrides, normalization edge cases |
| `admin-feedback` | `withFlash` (success/error/both/none, encoding), `readFlash` |
| `admin-navigation` | tree shape, `active`/`expanded` flags for each section, portfolio child mapping, href de-dup filter |
| `form-data` | `isCheckedFormValue` truthy/falsey set |
| `locale` | `isSupportedLocale`, `resolveLocale`, `getDirection` (rtl for ar), `stripLocalePrefix`, `getLocalizedPath(WithDefault)` incl. prefix stripping/rebuilding |
| `utils` | `cn` merge/dedupe/conditional |
| `site-theme` | `buildSiteThemeTokens` (hex→hsl, derive dark/secondary, clamps), `buildSiteThemeStyleText` structure, invalid hex fallback |
| `marquee-settings` | defaults, `parseMarqueeSettingsValue` (invalid json, partial, trims), `syncMarqueeSettingsToGermanSource`, `splitMarqueeRowItems` (newline/comma/blank) |
| `site-settings` | `normalizeSiteDefaultLocale`, `normalizeSitePrimaryColor`, `buildDefaultSiteSettings`, `parseSiteSettingsValue` (merge, invalid json, legacy title, invalid locale/color) |
| `site-icons` | `buildSiteIconUrls` (version, relative vs absolute favicon url, name fallback), `buildSiteIconResponse` transparent fallback for non-managed paths |
| `media-storage` (pure) | `sanitizeBaseName`, `getExtensionForMimeType` (all mimes + unknown), `isManagedMediaFilePath`, `resolveMediaUploadPath` (root confinement + traversal guard) |
| `media-validation` | `mediaFieldInputSchema`: library needs assetId, external needs url, url format rule, upload mode, kind enum, trimming |
| `media-service` (pure) | `inferMediaKindFromMimeType`, `inferMediaKindFromFileName`, `getKindFromUploadFile` |
| `portfolio` (pure) | `resolvePortfolioProjectViewMode`, `getLocalizedValue` (direct/fallback/any) |
| `portfolio-form-progress` | slug/year/sortOrder validators, section readiness per type, asset readiness, wizard progress, first incomplete step |
| `portfolio-validation` | category/section/asset/project schemas: required fields, slug regex, coercions, view modes, section superRefine per type, media refinements, url rules |
| `metadata` | `applyTitleTemplateFn`, `buildLocaleAlternates`, `buildAppMetadataFromConfig`, `buildLocalizedMetadataFromConfig` (title template skip, description fallback, og/twitter, icons) |
| `mail` | `createSmtpTransport` (required host/user/pass errors, port/secure), `sendMail` (from with/without name), `sendContactMessage` (recipient + fallback, body fields), `sendTestEmail` (recipient fallback, transport reject) |
### 3.2 Integration — data layer (real DB)
| Module | Cases |
|---|---|
| `app-config` | maintenance get/set, site settings get (fallback name from `siteName` key) / update roundtrip, mail settings get/update, marquee get/update (german sync), `getSiteSettingsMediaBindings` (mediaUsage → bindings per field) |
| `media` | create asset, get by id (+usages), list, `getMediaOptions` kind filter, `replaceEntityMediaUsages` (transactional replace, unique constraint), `deleteEntityMediaUsages`, `getPortfolioMediaBindings` routing by usageType, `countMediaUsageReferences` |
| `portfolio` (queries) | admin categories (+project counts), active categories/by-slug, admin projects (status/category filters + ordering), published projects/by-slug, by-id with media bindings, localized mapping, `onDelete` Restrict/Cascade behaviour |
| `media-service.resolveMediaSelection` | library (found/missing), external (creates asset, filename from url), missing+required error, not-required empty |
| `admin-auth` (lockout) | `registerFailedAdminAttempt` increments & locks at threshold, `getAdminLockState`, `resetAdminFailedAttempts`, IP hashing via mocked headers; token `isPasswordValid`/verify with env |
### 3.3 Integration — API routes & middleware
| Target | Cases |
|---|---|
| `GET /api/health` | 200 + `database: up`; 503 + `database: down` when query throws |
| `GET /api/site/default-locale` | returns runtime `defaultLocale` + `maintenanceEnabled`, no-store header |
| `proxy` (middleware) | runtime default-locale passthrough, safe fallback on fetch failure, maintenance redirect, `SITE_RUNTIME_ORIGIN`, admin host rewrite → internal, dev `/root` handling, legacy 404 in prod, internal path 404 for non-admin in prod, basic-auth challenge/valid |
### 3.4 Integration — server actions
| Action file | Cases |
|---|---|
| `contact/actions` | valid → sends mail + redirect `/success`; invalid (short name/bad email/short message) → redirect with error; locale resolution |
| `maintenance/actions` | unauth → redirect to admin root; enable/disable toggles config + revalidates + success flash |
| `marquee/actions` | unauth guard; empty german rows throw per-row error → error flash; valid → saves (german-synced) + success flash |
| `smtp/actions` | `parseMailSettingsFormData` (port parse error, password retention when blank), save → success; `sendTestEmailAction` success + failure |
| `site-settings/actions` | brand save (primary color normalize, media selection + usage wiring, cleanup on error), localization save (siteName required, title template must contain `{pageTitle}`), `parseJsonObject` guard |
| `media/actions` | create (kind image/document, missing file error), delete (not found, in-use guard, managed-file removal) |
| `portfolio/actions` | `upsertCategoryAction` create/update + P2002 unique message; `deleteCategoryAction` blocks when projects exist; `saveProjectAction` create + update, sections/assets replace, `publishedAt` first-publish logic, media usage wiring, validation + error cleanup of created media; `deleteProjectAction` not-found + cascade + usage cleanup |
### 3.5 Component (jsdom + RTL)
Global mocks: `framer-motion`, `gsap`, `next/link`, `next/image`, `next-intl`, `next/navigation`.
| Component | Cases |
|---|---|
| `ui/badge` | variant classes, custom className merge, passthrough props |
| `ui/input` | renders, forwardRef, type/placeholder/disabled, className merge |
| `ui/textarea`, `ui/label`, `ui/card`*, `ui/app-card`, `ui/separator`, `ui/table`* | render, props, ref, composition |
| `admin/admin-flash` | null when empty, success `role=status`, error `role=alert`, both, className |
| `admin/marquee-settings-form` | renders 4 rows, default values, submit wiring to action |
| `site/portfolio-category-filter` | "all" link + per-category links, active state, localized labels, hrefs |
| `dashboard/dashboard-card`, `dashboard/stats-card` | presentational render, props |
| `layout/container`, `layout/hero-badge`, `home/section-heading`, `home/bento-card` | presentational render, children, className |
(*multi-part primitives tested for sub-component composition.)
### 3.6 Architecture-rule tests (`tests/integration/architecture`)
Enforced from `CLAUDE.md`:
1. **No Prisma in client components** — no file containing `"use client"` imports `lib/prisma`.
2. **Business logic out of UI** — components don't import server-only data modules directly
(allow-list of pure `lib/*` view/format helpers).
3. **Server actions are guarded** — every exported action in `app/**/actions.ts` calls an auth
guard (`ensureAdmin`/`requireAdminAuth`) except the public contact action.
4. **`"use server"` directive** — every `actions.ts` begins with `"use server"`.
5. **`lib/*` does not import from `app/`** — dependency direction.
6. **Admin mirror parity** — every `page.tsx` under `app/_admin/**` has matching re-export
stubs under `app/root/**` and `app/admin-internal/**` pointing back to `_admin`.
7. **No browser storage in components** — no `localStorage`/`sessionStorage` usage.
## 4. Deliverables & running
```bash
npm test # all projects
npx vitest run --project unit
npx vitest run --project integration
npx vitest run --project component
TEST_DATABASE_URL=postgres://… # optional: run integration against real Postgres
```
Findings (real bugs / rule violations) are reported to the owner; production code is only
changed after approval.

Some files were not shown because too many files have changed in this diff Show More