Compare commits

..
20 Commits
Author SHA1 Message Date
Moh 3a5c275c52 CLEANED - Remove unused comingSoon translation keys
CI / quality (push) Waiting to run
- Drop badge, kicker, status, primaryCta, secondaryCta, countdownLabel from all locales
- These were orphaned when the coming-soon page was trimmed to title + countdown
2026-08-08 03:52:08 +02:00
Moh b2b90bd1bf STYLED - Trim coming-soon to title + countdown
CI / quality (push) Waiting to run
- Remove the status badge, kicker, and description texts
- Keep only the title and the countdown (all locales)
2026-08-08 03:48:19 +02:00
Moh aadc93e870 STYLED - Simplify closed coming-soon page
CI / quality (push) Waiting to run
- Remove CTA buttons (site is closed, no navigation)
- Drop the card wrapper: content sits on the page background, gradient included
- Remove the launch label; keep the countdown
- Fit the viewport (no scroll)
2026-08-08 03:34:02 +02:00
Moh 0f436ec05a FIXED - Drop leftover Prisma ?schema=public from the DB URL
CI / quality (push) Waiting to run
- postgres.js forwarded schema as a startup param; Postgres rejected the connection
- Remove ?schema=public from docker-compose, CI, and the client default
- Strip any query string in lib/db/index.ts so this cannot recur
2026-08-08 03:04:26 +02:00
Moh 1264b577a1 FIXED - Restore the tested package-lock.json
CI / quality (push) Waiting to run
- Revert an unintended lockfile change from a machine with a newer npm
- Keeps the exact dependency tree the test suite ran against (npm ci verified)
2026-08-08 02:34:37 +02:00
Moh 61a58b31a7 CONFIGURED - Wire docker-compose to Traefik for first deploy
CI / quality (push) Waiting to run
- Join the app to the external proxy network and drop the published host port
- Add Traefik labels for mohfarawati.de, www, and root.mohfarawati.de
- Parametrize network/entrypoint/certresolver via TRAEFIK_* env with sane defaults
- Pass ADMIN_HOST to the app and document production env in .env.example
2026-08-08 02:33:09 +02:00
MOH cdf0380f79 IMPROVED - unify dev pattern: DB-only Docker + host app, safe start, add test:watch
CI / quality (push) Waiting to run
2026-08-07 15:05:34 +02:00
MOH 0a5f77d8de REFACTORED - migrate the data layer from Prisma to Drizzle (unify the stack)
- Add lib/db (Drizzle schema: 7 tables + 6 enums + relations, postgres.js client),
  drizzle.config.ts, lib/db/enums.ts (Prisma-compatible enum objects)
- Rewrite all 14 app consumers + 4 admin components to Drizzle
- Move the test DB layer to Drizzle + in-process PGlite; convert all 8 integration
  test files + factories (371 tests green)
- Remove Prisma: deps, prisma/ schema+migrations, lib/prisma.ts, Dockerfile prisma
  generate; wire db:generate/migrate/push/studio to drizzle-kit; update Makefile
- Preserve the old seed as scripts/legacy-prisma-seed.cjs (needs a Drizzle rewrite)
2026-08-07 14:18:41 +02:00
MOH e377877e7e ADDED - test gate: pre-push hook, copy-paste summary, make targets, mandatory-tests rule, CI test step 2026-08-06 20:03:37 +02:00
MOH 9c40d9030f FIXED - build type errors (vitest fileParallelism placement, NODE_ENV assignment) 2026-08-06 20:03:27 +02:00
MOH 777dcbab7b update 2026-08-06 02:27:49 +02:00
Moh e2e06be86e test: add comprehensive automated test coverage
- Vitest multi-project setup (unit / integration / component)
- Real-Postgres integration harness via in-process PGlite (TEST_DATABASE_URL
  override), migrations applied per worker; production code untouched
- Unit: routing, locale, validation/Zod schemas, metadata, mail, site-theme,
  marquee, media, portfolio helpers, plus architecture-rule tests
- Integration: Prisma data layer, API routes, and all server actions
- Component: UI primitives and form components (jsdom + Testing Library)
- 371 tests passing
2026-08-06 02:27:20 +02:00
MohFarawati 0f48381894 refactor(home): align contact CTA typography to the type scale
CI / quality (push) Has been cancelled
Use the eyebrow/.text-h2/body-lg tokens and caption/small labels so the
contact CTA matches the unified section styling. No structural change.
2026-07-14 22:38:08 +02:00
MohFarawati 59094eba85 refactor(home): unify section headings, differentiate capabilities and process
Reduce the "interchangeable cards" monotony flagged in Phase 0 and apply the
warm-premium type scale.

- SectionHeading: use the type scale (eyebrow via .eyebrow with brand tint,
  text-h2 title, body-lg description). Propagates to every home section.
- CapabilitiesSection: cleaner cards with a brand-tinted icon tile, type-scale
  title/body, soft elevation (drops the AppCard wrapper).
- ProcessSection: reworked into an editorial numbered timeline (large
  translucent brand numerals, top rule, no boxes) so it no longer looks
  identical to Capabilities.

Verified: eslint clean, no new type errors, tailwind generates the utilities.
2026-07-14 22:37:09 +02:00
MohFarawati dd72f013d4 feat(home): hero availability status and CTAs
The homepage hero had no call to action (Phase 0 finding). Add a live
availability pill and primary/secondary CTAs while keeping the signature title.

- HomeHero: optional availability status pill (pulsing dot) plus primary
  (view work) and secondary (contact) CTAs, RTL-aware arrow, using the type
  scale. Backward compatible (all new props optional).
- Wire copy + hrefs from the homepage; add heroVisual.primaryCta /
  secondaryCta to en/de/ar.

Verified: JSON valid, eslint clean, no new type errors.
2026-07-14 22:23:54 +02:00
MohFarawati 86243b79c2 feat(portfolio): real project-detail hero, no wasted space
The detail page rendered an empty <PageHero /> (blank gradient band) with the
real title buried in a card lower down (Phase 0 finding).

- Populate the detail PageHero with the project category (badge), title and
  summary, so the top of the page is a proper header.
- Slim the in-content ProjectHeader to back-link + meta chips + preview button,
  removing the now-duplicated title and summary. Applies across all three
  detail templates (grid/story/case-study).

Verified: eslint clean, no new type errors.
2026-07-14 22:20:47 +02:00
MohFarawati 4892f27006 feat(home): image-led featured projects section
Bring imagery to the homepage projects block (Phase 3), matching the portfolio
gallery.

- Thread coverImagePath through ProjectCardCopy and the home mapping.
- Rewrite ProjectsSection as image-first cards (reusing PortfolioCover with the
  branded placeholder fallback): whole-card link, featured project spans two
  columns with a larger cover, type-scale title, meta + stack badges, hover
  lift. Replaces the previous text-only cards.

Verified: eslint clean; no new type errors (pre-existing Prisma-gen anys only).
2026-07-14 22:19:05 +02:00
MohFarawati f7355b5356 feat(portfolio): image-led project gallery with branded placeholders
Redesign the portfolio list/category grid into a premium, image-first gallery
(Phase 3), applying the warm-premium tokens.

- Add components/site/portfolio-cover.tsx: renders the stored cover image
  (next/image, object-cover, hover zoom) or a restrained branded placeholder
  (monogram + category) when none is set. Placeholders are temporary — the
  owner will swap in real artwork.
- Rewrite portfolio-project-grid: responsive 1/2/3-col gallery, featured
  projects span two columns with a larger cover, cards use the type scale
  (text-h2/h3/caption), eyebrow category labels, line-clamped summaries, soft
  elevation and hover lift. Directly fixes the "portfolio shows no images"
  gap on the list and category pages.

Verified: eslint clean, no new type errors, tailwind generates the utilities.
2026-07-14 22:13:26 +02:00
MohFarawati dcffa87be7 feat(design-system): add type scale, spacing rhythm and font tokens
Apply the Phase 2 warm-premium base additively (no existing page changes):
- tailwind.config.ts: add fontSize roles (display, h1, h2, h3, title, body-lg,
  body, small, caption, overline) with line-height/tracking/weight; add
  fontFamily (sans/latin = Museo, arabic = Dubai); add section-sm/section-lg
  spacing rhythm.
- globals.css: add Arabic-safe .eyebrow utility (uppercase + tracking on Latin,
  neutralised for html[lang="ar"]).

Colors and fonts unchanged. Verified: tsc clean, eslint clean, tailwind build
generates the new utilities with correct values.
2026-07-14 22:08:34 +02:00
MohFarawati d1a1a67b84 docs(design-system): document warm-premium direction and token system
Add docs/DESIGN_SYSTEM.md capturing Phase 2 visual identity:
- Chosen direction (warm premium, work-forward) and its principles.
- Documents existing color/surface/brand/status/border/hero tokens with
  light + dark values and an accent-budget usage rule.
- Fonts (Museo Latin, Dubai Arabic) and a defined type scale + Arabic overrides.
- Radius, elevation, container and spacing-rhythm guidance, motion, component
  conventions, and do/don't. Colors and fonts are owner-fixed and unchanged.
2026-07-14 22:03:15 +02:00
112 changed files with 9692 additions and 1429 deletions
+26 -4
View File
@@ -1,9 +1,31 @@
DATABASE_URL="postgresql://USER:PASSWORD@HOST:5432/moh_sass?schema=public"
NEXT_PUBLIC_APP_URL="https://mohfarawati.de"
NEXT_PUBLIC_SITE_URL="https://mohfarawati.de"
NEXT_PUBLIC_ADMIN_URL="https://root.mohfarawati.de"
# Local dev: app runs on host (npm run dev), only Postgres runs in Docker.
# Production: docker-compose.yml runs the full stack (deploy via `make deploy`).
# --- Database ---
# 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"
# --- Admin auth ---
ADMIN_PASSWORD="change-me"
ADMIN_AUTH_SECRET="replace-with-a-long-random-secret"
ADMIN_BASIC_AUTH_USER="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.
+21
View File
@@ -0,0 +1,21 @@
#!/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."
+6 -1
View File
@@ -8,7 +8,7 @@ jobs:
quality:
runs-on: ubuntu-latest
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/moh_sass?schema=public
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/moh_sass
NEXT_TELEMETRY_DISABLED: "1"
steps:
- name: Checkout
@@ -29,5 +29,10 @@ jobs:
- name: 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
run: npm run build
+1
View File
@@ -110,6 +110,7 @@ SITE_RUNTIME_ORIGIN Internal origin for middleware to fetch runtime state
## 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.
- Make the smallest safe change that solves the task.
- Do not modify unrelated files.
+3 -3
View File
@@ -7,7 +7,7 @@ FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npx prisma generate && npm run build
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
@@ -17,8 +17,8 @@ COPY --from=builder /app/package*.json ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
COPY --from=builder /app/drizzle.config.ts ./drizzle.config.ts
COPY --from=builder /app/lib/db ./lib/db
COPY --from=builder /app/next.config.mjs ./next.config.mjs
EXPOSE 3000
+90 -57
View File
@@ -1,76 +1,109 @@
.PHONY: start stop restart deploy logs build ps port health clean-orphans app-shell db-shell db-init db-migrate db-seed prisma-generate prisma-migrate help
# mohfarawati.de — task runner.
# Local dev = DB in Docker, app on host (`npm run dev`).
# Production = docker-compose.yml runs the full stack on the server.
MIGRATION_NAME ?= init
.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:
docker compose up -d --build
-@docker compose stop app 2>/dev/null || true
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:
docker compose down
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:
git pull
docker compose up -d --build
@git log -1 --oneline
logs:
deploy-logs:
docker compose logs -f --tail=200
build:
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 "npx prisma generate && npx prisma migrate deploy && npx prisma db seed"
db-migrate:
docker compose exec app npx prisma migrate deploy
db-seed:
docker compose exec app npx prisma db seed
prisma-generate:
docker compose exec app npx prisma generate
prisma-migrate:
docker compose exec app npx prisma migrate dev --name $(MIGRATION_NAME)
deploy-down:
docker compose down
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 Generate client, apply migrations, run seed"
@echo " make db-migrate Apply prisma migrations"
@echo " make db-seed Seed database data"
@echo " make prisma-generate Run prisma generate"
@echo " make prisma-migrate Create/apply dev migration"
@echo " make health Check app health endpoint via public domain"
+99 -5
View File
@@ -3,9 +3,19 @@ import { getLocale, getTranslations } from "next-intl/server";
import { Container } from "@/components/layout/container";
import { PageHero } from "@/components/layout/page-hero";
import { MotionFade } from "@/components/motion-fade";
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 { resolveLocale } from "@/lib/locale";
import { getLocalizedPath, resolveLocale } from "@/lib/locale";
import { buildLocalizedMetadata } from "@/lib/metadata";
type AboutPageProps = {
@@ -36,6 +46,38 @@ export default async function AboutPage({ params }: AboutPageProps) {
const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale);
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 (
<>
<PageHero
@@ -45,10 +87,62 @@ export default async function AboutPage({ params }: AboutPageProps) {
description={t("description")}
/>
<Container className="pb-16 lg:pb-20">
<AppCard level={3} padding="lg" className="mx-auto max-w-3xl text-center">
<p className="text-lg font-medium text-foreground">{t("placeholder")}</p>
</AppCard>
<Container className="flex flex-col gap-16 pb-16 sm:gap-20 lg:gap-24 lg:pb-20">
<MotionFade>
<section className="mx-auto max-w-3xl space-y-6">
<p className="eyebrow text-caption font-medium text-brand-primary">
{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>
</>
);
+4
View File
@@ -180,6 +180,7 @@ export default async function HomePage({ params }: HomePageProps) {
String(project.projectYear),
],
featured: index === 0,
coverImagePath: project.coverImagePath,
}));
const fallbackProjectCards: ProjectCardCopy[] = projectsCopy.fallbackItems.map((item) => ({
@@ -203,6 +204,9 @@ export default async function HomePage({ params }: HomePageProps) {
kicker={t("heroVisual.kicker")}
title={t("heroVisual.title")}
description={t("heroVisual.description")}
availability={contactCtaCopy.availabilityValue}
primaryCta={{ label: t("heroVisual.primaryCta"), href: portfolioHref }}
secondaryCta={{ label: t("heroVisual.secondaryCta"), href: contactHref }}
/>
<Container
@@ -58,10 +58,18 @@ export default async function PortfolioItemPage({
}
const t = await getTranslations({ locale: localeKey, namespace: "portfolioDetail" });
const title = getLocalizedValue(item.title, localeKey);
const category = getLocalizedValue(item.category.name, localeKey);
const summary = getLocalizedValue(item.summary, localeKey);
return (
<>
<PageHero />
<PageHero
locale={localeKey}
badge={category}
lines={[{ text: title, tone: "soft", align: "center" }]}
description={summary}
/>
<Container size="wide" className="pb-12 lg:pb-16">
<PortfolioProjectDetail
+18 -87
View File
@@ -1,17 +1,13 @@
import type { Metadata } from "next";
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 { FloatingPreferences } from "@/components/layout/floating-preferences";
import { HeroContentMotion, HeroMotionItem, HeroShell, HeroTitle } from "@/components/layout/site-hero";
import { LaunchCountdown } from "@/components/site/launch-countdown";
import { Button } from "@/components/ui/button";
import { getSiteSettings } from "@/lib/app-config";
import { buildLocalizedMetadata } from "@/lib/metadata";
import { getLocalizedPath, resolveLocale } from "@/lib/locale";
import { cn } from "@/lib/utils";
import { resolveLocale } from "@/lib/locale";
// Target launch date for the countdown. Edit this single line to change it.
const LAUNCH_DATE_ISO = "2026-08-28T12:00:00Z";
@@ -48,7 +44,6 @@ export default async function ComingSoonPage({ params }: ComingSoonPageProps) {
const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale);
const t = await getTranslations({ locale: localeKey, namespace: "comingSoon" });
const isArabic = localeKey === "ar";
const DirectionIcon = isArabic ? ArrowLeft : ArrowRight;
const lines = [
{
@@ -68,92 +63,28 @@ 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 (
<div className="relative min-h-screen overflow-hidden">
<div className="relative h-screen overflow-hidden">
<FloatingPreferences locale={localeKey} defaultLocale={siteSettings.defaultLocale} />
<HeroShell className="min-h-screen" showBridges>
<HeroShell className="h-screen" showBridges>
<HeroContentMotion className="relative z-10 w-full">
<div className="mx-auto w-full max-w-[60rem]">
<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 */}
<span
aria-hidden
className="pointer-events-none absolute inset-x-8 top-0 h-px bg-gradient-to-r from-transparent via-foreground/25 to-transparent"
<div className="mx-auto flex w-full max-w-[60rem] flex-col items-center text-center">
<HeroTitle locale={localeKey} lines={lines} className="mx-auto tracking-normal" />
<HeroMotionItem className="mt-12">
<LaunchCountdown
targetIso={LAUNCH_DATE_ISO}
arabic={isArabic}
launchedLabel={t("launched")}
labels={{
days: t("unitDays"),
hours: t("unitHours"),
minutes: t("unitMinutes"),
seconds: t("unitSeconds"),
}}
/>
{/* 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>
</HeroMotionItem>
</div>
</HeroContentMotion>
</HeroShell>
+6 -7
View File
@@ -1,6 +1,6 @@
"use server";
import { MediaKind } from "@prisma/client";
import { MediaKind } from "@/lib/db/enums";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { isRedirectError } from "next/dist/client/components/redirect-error";
@@ -11,7 +11,10 @@ import { withFlash } from "@/lib/admin-feedback";
import { countMediaUsageReferences, getMediaAssetById } from "@/lib/media";
import { createStandaloneMediaAsset, deleteMediaAssetAndFile } from "@/lib/media-service";
import { isManagedMediaFilePath } from "@/lib/media-storage";
import { prisma } from "@/lib/prisma";
import { eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { mediaAsset } from "@/lib/db/schema";
async function ensureAdmin() {
if (!(await isAdminAuthenticated())) {
@@ -71,11 +74,7 @@ export async function deleteMediaAssetAction(formData: FormData) {
redirect(withFlash(getAdminAppPath("/media"), { error: "Datei wird noch verwendet." }));
}
await prisma.mediaAsset.delete({
where: {
id: asset.id,
},
});
await db.delete(mediaAsset).where(eq(mediaAsset.id, asset.id));
if (isManagedMediaFilePath(asset.url)) {
await deleteMediaAssetAndFile({
+96 -130
View File
@@ -1,6 +1,6 @@
"use server";
import { MediaUsageType, Prisma } from "@prisma/client";
import { and, eq, inArray } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { isRedirectError } from "next/dist/client/components/redirect-error";
@@ -15,7 +15,16 @@ import { resolveMediaSelection } from "@/lib/media-service";
import { getLocalizedPath } from "@/lib/locale";
import { removeManagedMediaFile } from "@/lib/media-storage";
import { mediaFieldInputSchema } from "@/lib/media-validation";
import { prisma } from "@/lib/prisma";
import { db } from "@/lib/db";
import {
category,
mediaAsset,
mediaUsage,
portfolioAsset,
portfolioProject,
portfolioSection,
} from "@/lib/db/schema";
import { MediaUsageType } from "@/lib/db/enums";
import { isCheckedFormValue } from "@/lib/form-data";
import { getSiteSettings } from "@/lib/app-config";
import {
@@ -81,6 +90,20 @@ function parseZodError(error: ZodError) {
return error.issues[0]?.message ?? "Validierung fehlgeschlagen.";
}
// Postgres unique-violation (code 23505, was Prisma's "P2002"). The error shape
// 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 {
if (typeof error !== "object" || error === null) {
return false;
}
const e = error as { code?: string; cause?: { code?: string }; message?: string };
if (e.code === "23505" || e.cause?.code === "23505") {
return true;
}
return typeof e.message === "string" && /23505|duplicate key|unique constraint/i.test(e.message);
}
async function revalidatePortfolioPages() {
revalidatePath(toInternalAdminPath("/"));
revalidatePath(toInternalAdminPath("/media"));
@@ -121,16 +144,9 @@ export async function upsertCategoryAction(formData: FormData) {
});
if (parsed.id) {
await prisma.category.update({
where: {
id: parsed.id,
},
data: parsed,
});
await db.update(category).set(parsed).where(eq(category.id, parsed.id));
} else {
await prisma.category.create({
data: parsed,
});
await db.insert(category).values(parsed);
}
await revalidatePortfolioPages();
@@ -143,7 +159,7 @@ export async function upsertCategoryAction(formData: FormData) {
const message =
error instanceof ZodError
? parseZodError(error)
: error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002"
: isUniqueViolation(error)
? "Kategorie Slug muss eindeutig sein."
: "Kategorie konnte nicht gespeichert werden.";
@@ -158,21 +174,13 @@ export async function deleteCategoryAction(formData: FormData) {
const id = String(formData.get("id") ?? "");
try {
const projectCount = await prisma.portfolioProject.count({
where: {
categoryId: id,
},
});
const projectCount = await db.$count(portfolioProject, eq(portfolioProject.categoryId, id));
if (projectCount > 0) {
redirect(withFlash(redirectPath, { error: "Kategorie mit Projekten kann nicht geloescht werden." }));
}
await prisma.category.delete({
where: {
id,
},
});
await db.delete(category).where(eq(category.id, id));
await revalidatePortfolioPages();
redirect(withFlash(redirectPath, { success: "Kategorie geloescht." }));
@@ -241,15 +249,16 @@ export async function saveProjectAction(formData: FormData) {
});
const existingProject = parsed.id
? await prisma.portfolioProject.findUnique({
where: {
id: parsed.id,
},
select: {
isPublished: true,
publishedAt: true,
},
})
? (
await db
.select({
isPublished: portfolioProject.isPublished,
publishedAt: portfolioProject.publishedAt,
})
.from(portfolioProject)
.where(eq(portfolioProject.id, parsed.id))
.limit(1)
)[0] ?? null
: null;
const shouldPublishNow = parsed.isPublished && !existingProject?.publishedAt;
@@ -359,81 +368,56 @@ export async function saveProjectAction(formData: FormData) {
});
}
const projectResult = await prisma.$transaction(async (tx) => {
const currentProject = parsed.id
? await tx.portfolioProject.update({
where: {
id: parsed.id,
},
data: {
categoryId: parsed.categoryId,
slug: parsed.slug,
viewMode: parsed.viewMode,
titleAr: parsed.titleAr,
titleEn: parsed.titleEn,
titleDe: parsed.titleDe,
summaryAr: parsed.summaryAr,
summaryEn: parsed.summaryEn,
summaryDe: parsed.summaryDe,
clientName: parsed.clientName,
projectYear: parsed.projectYear,
serviceLabelAr: parsed.serviceLabelAr,
serviceLabelEn: parsed.serviceLabelEn,
serviceLabelDe: parsed.serviceLabelDe,
previewUrl: parsed.previewUrl || null,
coverImagePath: coverSelection.url || null,
isFeatured: parsed.isFeatured,
isPublished: parsed.isPublished,
const projectResult = await db.transaction(async (tx) => {
const projectValues = {
categoryId: parsed.categoryId,
slug: parsed.slug,
viewMode: parsed.viewMode,
titleAr: parsed.titleAr,
titleEn: parsed.titleEn,
titleDe: parsed.titleDe,
summaryAr: parsed.summaryAr,
summaryEn: parsed.summaryEn,
summaryDe: parsed.summaryDe,
clientName: parsed.clientName,
projectYear: parsed.projectYear,
serviceLabelAr: parsed.serviceLabelAr,
serviceLabelEn: parsed.serviceLabelEn,
serviceLabelDe: parsed.serviceLabelDe,
previewUrl: parsed.previewUrl || null,
coverImagePath: coverSelection.url || null,
isFeatured: parsed.isFeatured,
isPublished: parsed.isPublished,
sortOrder: parsed.sortOrder,
};
const [currentProject] = parsed.id
? await tx
.update(portfolioProject)
.set({
...projectValues,
publishedAt: parsed.isPublished
? shouldPublishNow
? new Date()
: existingProject?.publishedAt ?? new Date()
: null,
sortOrder: parsed.sortOrder,
},
})
: await tx.portfolioProject.create({
data: {
categoryId: parsed.categoryId,
slug: parsed.slug,
viewMode: parsed.viewMode,
titleAr: parsed.titleAr,
titleEn: parsed.titleEn,
titleDe: parsed.titleDe,
summaryAr: parsed.summaryAr,
summaryEn: parsed.summaryEn,
summaryDe: parsed.summaryDe,
clientName: parsed.clientName,
projectYear: parsed.projectYear,
serviceLabelAr: parsed.serviceLabelAr,
serviceLabelEn: parsed.serviceLabelEn,
serviceLabelDe: parsed.serviceLabelDe,
previewUrl: parsed.previewUrl || null,
coverImagePath: coverSelection.url || null,
isFeatured: parsed.isFeatured,
isPublished: parsed.isPublished,
publishedAt: parsed.isPublished ? new Date() : null,
sortOrder: parsed.sortOrder,
},
});
})
.where(eq(portfolioProject.id, parsed.id))
.returning()
: await tx
.insert(portfolioProject)
.values({ ...projectValues, publishedAt: parsed.isPublished ? new Date() : null })
.returning();
await tx.portfolioSection.deleteMany({
where: {
projectId: currentProject.id,
},
});
await tx.portfolioAsset.deleteMany({
where: {
projectId: currentProject.id,
},
});
await tx.delete(portfolioSection).where(eq(portfolioSection.projectId, currentProject.id));
await tx.delete(portfolioAsset).where(eq(portfolioAsset.projectId, currentProject.id));
const createdSections = [];
for (const section of sectionRows) {
const createdSection = await tx.portfolioSection.create({
data: {
const [createdSection] = await tx
.insert(portfolioSection)
.values({
projectId: currentProject.id,
type: section.type,
titleAr: section.titleAr,
@@ -445,8 +429,8 @@ export async function saveProjectAction(formData: FormData) {
imagePath: section.imagePath || null,
linkUrl: section.linkUrl || null,
sortOrder: section.sortOrder,
},
});
})
.returning();
createdSections.push(createdSection);
}
@@ -454,8 +438,9 @@ export async function saveProjectAction(formData: FormData) {
const createdAssets = [];
for (const asset of assetRows) {
const createdAsset = await tx.portfolioAsset.create({
data: {
const [createdAsset] = await tx
.insert(portfolioAsset)
.values({
projectId: currentProject.id,
kind: asset.kind,
filePath: asset.filePath,
@@ -463,8 +448,8 @@ export async function saveProjectAction(formData: FormData) {
altEn: asset.altEn,
altDe: asset.altDe,
sortOrder: asset.sortOrder,
},
});
})
.returning();
createdAssets.push(createdAsset);
}
@@ -536,7 +521,7 @@ export async function saveProjectAction(formData: FormData) {
const message =
error instanceof ZodError
? parseZodError(error)
: error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002"
: isUniqueViolation(error)
? "Projekt Slug muss eindeutig sein."
: error instanceof Error
? error.message
@@ -544,20 +529,8 @@ export async function saveProjectAction(formData: FormData) {
await removeManagedPaths(uploadedPaths);
if (createdMediaAssetIds.length > 0) {
await prisma.mediaUsage.deleteMany({
where: {
assetId: {
in: createdMediaAssetIds,
},
},
});
await prisma.mediaAsset.deleteMany({
where: {
id: {
in: createdMediaAssetIds,
},
},
});
await db.delete(mediaUsage).where(inArray(mediaUsage.assetId, createdMediaAssetIds));
await db.delete(mediaAsset).where(inArray(mediaAsset.id, createdMediaAssetIds));
}
redirect(withFlash(redirectPath, { error: message }));
}
@@ -569,24 +542,17 @@ export async function deleteProjectAction(formData: FormData) {
const id = String(formData.get("id") ?? "");
try {
const project = await prisma.portfolioProject.findUnique({
where: {
id,
},
select: {
slug: true,
},
});
const [project] = await db
.select({ slug: portfolioProject.slug })
.from(portfolioProject)
.where(eq(portfolioProject.id, id))
.limit(1);
if (!project) {
redirect(withFlash(getAdminAppPath("/portfolio"), { error: "Projekt nicht gefunden." }));
}
await prisma.portfolioProject.delete({
where: {
id,
},
});
await db.delete(portfolioProject).where(eq(portfolioProject.id, id));
await deleteEntityMediaUsages("portfolio-project", id);
await revalidatePortfolioPages();
+6 -9
View File
@@ -1,6 +1,6 @@
"use server";
import { MediaUsageType } from "@prisma/client";
import { MediaUsageType } from "@/lib/db/enums";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { isRedirectError } from "next/dist/client/components/redirect-error";
@@ -30,7 +30,10 @@ import { routing } from "@/i18n/routing";
import { getLocalizedPath } from "@/lib/locale";
import { removeManagedMediaFile } from "@/lib/media-storage";
import { mediaFieldInputSchema } from "@/lib/media-validation";
import { prisma } from "@/lib/prisma";
import { inArray } from "drizzle-orm";
import { db } from "@/lib/db";
import { mediaAsset } from "@/lib/db/schema";
async function ensureAdmin() {
if (!(await isAdminAuthenticated())) {
@@ -60,13 +63,7 @@ function parseJsonObject(rawValue: FormDataEntryValue | null, key: string) {
async function cleanupCreatedMedia(assetIds: string[], uploadedPaths: string[]) {
if (assetIds.length > 0) {
await prisma.mediaAsset.deleteMany({
where: {
id: {
in: Array.from(new Set(assetIds)),
},
},
});
await db.delete(mediaAsset).where(inArray(mediaAsset.id, Array.from(new Set(assetIds))));
}
for (const filePath of Array.from(new Set(uploadedPaths.filter(Boolean)))) {
+1 -1
View File
@@ -1,4 +1,4 @@
import { MediaKind } from "@prisma/client";
import { MediaKind } from "@/lib/db/enums";
import { redirect } from "next/navigation";
import { MotionFade } from "@/components/motion-fade";
+3 -2
View File
@@ -1,6 +1,7 @@
import { sql } from "drizzle-orm";
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { db } from "@/lib/db";
export const dynamic = "force-dynamic";
@@ -8,7 +9,7 @@ export async function GET() {
const timestamp = new Date().toISOString();
try {
await prisma.$queryRaw`SELECT 1`;
await db.execute(sql`SELECT 1`);
return NextResponse.json(
{
+12
View File
@@ -168,6 +168,18 @@ html[lang="ar"] {
font-family: var(--font-dubai), sans-serif;
}
/* Eyebrow / overline label. Latin gets uppercase + tracking; Arabic keeps
normal case and spacing so joined letterforms are not broken. */
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.14em;
}
html[lang="ar"] .eyebrow {
text-transform: none;
letter-spacing: normal;
}
@layer base {
* {
@apply border-border;
+1 -1
View File
@@ -2,7 +2,7 @@
/* eslint-disable @next/next/no-img-element */
import type { MediaKind } from "@prisma/client";
import type { MediaKind } from "@/lib/db/enums";
import { Check, ImageIcon, Search, Trash2 } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
+1 -1
View File
@@ -2,7 +2,7 @@
/* eslint-disable @next/next/no-img-element */
import type { MediaKind } from "@prisma/client";
import type { MediaKind } from "@/lib/db/enums";
import { FileType2, Grid2x2, ImageIcon, LayoutList, LoaderCircle, Trash2, Upload } from "lucide-react";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
+1 -1
View File
@@ -1,6 +1,6 @@
"use client";
import type { PortfolioProjectViewMode, PortfolioSectionType } from "@prisma/client";
import type { PortfolioProjectViewMode, PortfolioSectionType } from "@/lib/db/enums";
import {
ArrowDown,
ArrowUp,
+1 -1
View File
@@ -2,7 +2,7 @@
/* eslint-disable @next/next/no-img-element */
import { MediaKind } from "@prisma/client";
import { MediaKind } from "@/lib/db/enums";
import {
Check,
FileText,
+12 -17
View File
@@ -1,6 +1,5 @@
import { Bot, Database, Globe, ServerCog } from "lucide-react";
import { AppCard } from "@/components/ui/app-card";
import { SectionHeading } from "./section-heading";
import type { CapabilitiesSectionCopy } from "./types";
@@ -19,29 +18,25 @@ export function CapabilitiesSection({ copy }: CapabilitiesSectionProps) {
description={copy.description}
/>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4 xl:gap-6">
<div className="grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-4">
{copy.items.map((item, index) => {
const Icon = capabilityIcons[index] ?? Globe;
return (
<AppCard
<div
key={item.title}
layer="single"
interactive
className="group border-border/80 bg-surface-2 transition-all duration-300 hover:-translate-y-1 hover:border-border-strong hover:shadow-panel"
className="group flex h-full flex-col gap-5 rounded-surface border border-border bg-surface-1 p-6 shadow-card transition duration-300 ease-out hover:-translate-y-1 hover:shadow-panel"
>
<div className="flex h-full flex-col gap-5 p-6">
<div className="flex h-11 w-11 items-center justify-center rounded-nested border border-border/70 bg-background text-foreground transition-transform duration-300 group-hover:-translate-y-0.5">
<Icon className="h-5 w-5" />
</div>
<div className="space-y-3">
<h3 className="text-xl font-semibold tracking-[-0.04em] text-foreground">
{item.title}
</h3>
<p className="text-sm leading-7 text-muted-foreground">{item.description}</p>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-nested bg-brand-primary/10 text-brand-primary transition-transform duration-300 group-hover:-translate-y-0.5">
<Icon className="h-5 w-5" />
</div>
</AppCard>
<div className="space-y-2">
<h3 className="text-title font-semibold text-foreground">
{item.title}
</h3>
<p className="text-small leading-relaxed text-muted-foreground">{item.description}</p>
</div>
</div>
);
})}
</div>
+8 -8
View File
@@ -19,26 +19,26 @@ export function ContactCtaSection({
<AppCard className="overflow-hidden">
<div className="grid gap-6 rounded-nested border border-border/70 bg-background p-6 sm:p-8 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end lg:p-10">
<div className="space-y-4">
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground">
<p className="eyebrow text-caption font-medium text-brand-primary">
{copy.eyebrow}
</p>
<h2 className="max-w-3xl text-balance text-3xl font-semibold tracking-[-0.05em] text-foreground sm:text-4xl lg:text-5xl">
<h2 className="max-w-3xl text-balance text-h2 text-foreground">
{copy.title}
</h2>
<p className="max-w-2xl text-sm leading-7 text-muted-foreground sm:text-base">
<p className="max-w-2xl text-body-lg text-muted-foreground">
{copy.description}
</p>
<div className="flex flex-wrap gap-6 text-sm">
<div className="flex flex-wrap gap-8 pt-2">
<div className="space-y-1">
<p className="font-semibold text-foreground">{copy.emailLabel}</p>
<a href={`mailto:${copy.emailValue}`} className="text-muted-foreground hover:text-foreground">
<p className="text-caption font-medium text-muted-foreground">{copy.emailLabel}</p>
<a href={`mailto:${copy.emailValue}`} className="text-small text-foreground transition-colors hover:text-brand-primary">
{copy.emailValue}
</a>
</div>
<div className="space-y-1">
<p className="font-semibold text-foreground">{copy.availabilityLabel}</p>
<p className="text-muted-foreground">{copy.availabilityValue}</p>
<p className="text-caption font-medium text-muted-foreground">{copy.availabilityLabel}</p>
<p className="text-small text-foreground">{copy.availabilityValue}</p>
</div>
</div>
</div>
+14 -22
View File
@@ -1,4 +1,3 @@
import { AppCard } from "@/components/ui/app-card";
import { SectionHeading } from "./section-heading";
import type { ProcessSectionCopy } from "./types";
@@ -8,35 +7,28 @@ type ProcessSectionProps = {
export function ProcessSection({ copy }: ProcessSectionProps) {
return (
<section className="space-y-8">
<section className="space-y-10">
<SectionHeading
eyebrow={copy.eyebrow}
title={copy.title}
description={copy.description}
/>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4 xl:gap-6">
<ol className="grid grid-cols-1 gap-x-8 gap-y-10 sm:grid-cols-2 lg:grid-cols-4">
{copy.steps.map((step, index) => (
<AppCard
key={step.title}
layer="single"
interactive
className="group border-border/80 bg-surface-2 transition-all duration-300 hover:-translate-y-1 hover:border-border-strong hover:shadow-panel"
>
<div className="flex h-full flex-col gap-5 p-6">
<p className="text-sm font-semibold tracking-[0.18em] text-muted-foreground">
{String(index + 1).padStart(2, "0")}
</p>
<div className="space-y-3">
<h3 className="text-2xl font-semibold tracking-[-0.05em] text-foreground">
{step.title}
</h3>
<p className="text-sm leading-7 text-muted-foreground">{step.description}</p>
</div>
</div>
</AppCard>
<li key={step.title} className="border-t border-border pt-5">
<span className="block text-[2.75rem] font-semibold leading-none tracking-tight text-brand-primary/25">
{String(index + 1).padStart(2, "0")}
</span>
<h3 className="mt-5 text-title font-semibold text-foreground">
{step.title}
</h3>
<p className="mt-2 text-small leading-relaxed text-muted-foreground">
{step.description}
</p>
</li>
))}
</div>
</ol>
</section>
);
}
+70 -50
View File
@@ -1,8 +1,9 @@
import { ArrowUpRight } from "lucide-react";
import Link from "next/link";
import { PortfolioCover } from "@/components/site/portfolio-cover";
import { Badge } from "@/components/ui/badge";
import { AppCard } from "@/components/ui/app-card";
import { cn } from "@/lib/utils";
import { SectionHeading } from "./section-heading";
import type { ProjectCardCopy, ProjectsSectionCopy } from "./types";
@@ -20,66 +21,85 @@ export function ProjectsSection({ copy, items }: ProjectsSectionProps) {
description={copy.description}
/>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3 xl:gap-6">
{items.map((item, index) => (
<AppCard
key={`${item.title}-${index}`}
layer="single"
interactive
className={[
"group h-full border-border/80 bg-surface-2 transition-all duration-300 hover:-translate-y-1 hover:border-border-strong hover:bg-background/80 hover:shadow-lg",
index === 0 ? "lg:col-span-2" : "",
].join(" ")}
>
<div className="flex h-full flex-col gap-6 p-6">
<div className="flex flex-wrap items-center gap-2">
{item.meta.map((metaItem) => (
<Badge
key={metaItem}
variant="outline"
className="border-border/80 bg-background text-muted-foreground transition-colors duration-300 group-hover:border-border-strong group-hover:text-foreground/80"
>
{metaItem}
</Badge>
))}
<div className="grid grid-cols-1 gap-5 lg:grid-cols-3">
{items.map((item, index) => {
const featured = Boolean(item.featured);
return (
<Link
key={`${item.title}-${index}`}
href={item.href}
className={cn(
"group flex h-full flex-col overflow-hidden rounded-surface border border-border bg-surface-1 shadow-card transition duration-300 ease-out hover:-translate-y-1 hover:shadow-panel focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
featured && "lg:col-span-2",
)}
>
<div
className={cn(
"relative overflow-hidden",
featured ? "aspect-[16/10]" : "aspect-[4/3]",
)}
>
<PortfolioCover
src={item.coverImagePath ?? null}
title={item.title}
category={item.meta[0]}
sizes={
featured
? "(min-width: 1024px) 66vw, 100vw"
: "(min-width: 1024px) 33vw, 100vw"
}
/>
</div>
<div className="space-y-3">
<h3 className="text-2xl font-semibold tracking-[-0.05em] text-foreground transition-colors duration-300 group-hover:text-foreground/95">
{item.title}
</h3>
<p className="text-sm leading-7 text-muted-foreground">{item.summary}</p>
</div>
<div className="space-y-3">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
{copy.stackLabel}
</p>
<div className="flex flex-wrap gap-2">
{item.stack.map((stackItem) => (
<div className="flex flex-1 flex-col p-6">
<div className="flex flex-wrap items-center gap-2">
{item.meta.map((metaItem) => (
<Badge
key={stackItem}
variant="secondary"
className="bg-secondary/70 text-foreground transition-colors duration-300 group-hover:bg-secondary"
key={metaItem}
variant="outline"
className="border-border text-caption text-muted-foreground"
>
{stackItem}
{metaItem}
</Badge>
))}
</div>
</div>
<div className="mt-auto">
<Link
href={item.href}
className="inline-flex items-center gap-2 text-sm font-medium text-foreground transition-all duration-200 group-hover:translate-x-0.5"
<h3
className={cn(
"mt-4 font-semibold tracking-tight text-foreground",
featured ? "text-h3 sm:text-h2" : "text-h3",
)}
>
{item.title}
</h3>
<p className="mt-2 line-clamp-2 text-small leading-relaxed text-muted-foreground">
{item.summary}
</p>
{item.stack.length > 0 ? (
<div className="mt-4 flex flex-wrap gap-2">
{item.stack.map((stackItem) => (
<Badge
key={stackItem}
variant="secondary"
className="bg-secondary/70 text-caption text-foreground"
>
{stackItem}
</Badge>
))}
</div>
) : null}
<span className="mt-auto inline-flex items-center gap-2 pt-6 text-caption font-medium text-foreground/70 transition-colors group-hover:text-brand-primary">
{item.cta}
<ArrowUpRight className="h-4 w-4 transition-transform duration-200 group-hover:-translate-y-0.5 group-hover:translate-x-0.5" />
</Link>
<ArrowUpRight className="h-4 w-4 transition-transform duration-300 group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
</span>
</div>
</div>
</AppCard>
))}
</Link>
);
})}
</div>
</section>
);
+3 -3
View File
@@ -23,13 +23,13 @@ export function SectionHeading({
className,
)}
>
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground">
<p className="eyebrow text-caption font-medium text-brand-primary">
{eyebrow}
</p>
<h2 className="max-w-4xl text-balance text-3xl font-semibold tracking-[-0.05em] text-foreground sm:text-4xl lg:text-5xl">
<h2 className="max-w-4xl text-balance text-h2 text-foreground">
{title}
</h2>
<p className="max-w-3xl text-sm leading-7 text-muted-foreground sm:text-[1.02rem]">
<p className={cn("text-body-lg text-muted-foreground", align === "center" ? "mx-auto" : "max-w-2xl")}>
{description}
</p>
</div>
+1
View File
@@ -71,6 +71,7 @@ export type ProjectCardCopy = {
cta: string;
meta: string[];
featured?: boolean;
coverImagePath?: string | null;
};
export type ProjectsSectionCopy = {
+57 -7
View File
@@ -1,5 +1,8 @@
"use client";
import { ArrowLeft, ArrowRight, Mail } from "lucide-react";
import Link from "next/link";
import { HeroBadge } from "@/components/layout/hero-badge";
import {
HeroContentMotion,
@@ -7,12 +10,21 @@ import {
HeroShell,
HeroTitle,
} from "@/components/layout/site-hero";
import { Button } from "@/components/ui/button";
type HeroCta = {
label: string;
href: string;
};
type HomeHeroProps = {
locale: string;
kicker: string;
title: string;
description: string;
availability?: string;
primaryCta?: HeroCta;
secondaryCta?: HeroCta;
};
export function HomeHero({
@@ -20,9 +32,13 @@ export function HomeHero({
kicker,
title,
description,
availability,
primaryCta,
secondaryCta,
}: HomeHeroProps) {
const titleLines = title.split("\n").filter(Boolean);
const isArabic = locale === "ar";
const DirectionIcon = isArabic ? ArrowLeft : ArrowRight;
const lines = isArabic
? [
{
@@ -67,26 +83,60 @@ export function HomeHero({
return (
<HeroShell showBridges>
<HeroContentMotion variant="home">
{availability ? (
<HeroMotionItem variant="home">
<span className="inline-flex items-center gap-2.5 rounded-pill border border-foreground/10 bg-background/55 px-3.5 py-1.5 text-caption font-medium text-foreground/70 shadow-[var(--shadow-sm)] backdrop-blur-[18px]">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-status-success opacity-70" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-status-success" />
</span>
{availability}
</span>
</HeroMotionItem>
) : null}
<HeroMotionItem variant="home">
<HeroBadge
trailing={
<span
aria-hidden="true"
className="text-[0.95rem] leading-none"
>
👋
</span>
<span aria-hidden="true" className="text-[0.95rem] leading-none">
👋
</span>
}
>
{kicker}
</HeroBadge>
</HeroMotionItem>
<HeroTitle locale={locale} lines={lines} />
<HeroMotionItem variant="home">
<p className="mx-auto mt-7 max-w-[33rem] whitespace-pre-line text-sm leading-7 text-foreground/68 sm:text-base sm:leading-8">
<p className="mx-auto mt-7 max-w-[33rem] whitespace-pre-line text-body text-foreground/68">
{description}
</p>
</HeroMotionItem>
{primaryCta || secondaryCta ? (
<HeroMotionItem variant="home">
<div className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row">
{primaryCta ? (
<Button asChild size="lg" className="w-full sm:w-auto">
<Link href={primaryCta.href}>
{primaryCta.label}
<DirectionIcon className="h-4 w-4" />
</Link>
</Button>
) : null}
{secondaryCta ? (
<Button asChild variant="outline" size="lg" className="w-full sm:w-auto">
<Link href={secondaryCta.href}>
<Mail className="h-4 w-4" />
{secondaryCta.label}
</Link>
</Button>
) : null}
</div>
</HeroMotionItem>
) : null}
</HeroContentMotion>
</HeroShell>
);
+67
View File
@@ -0,0 +1,67 @@
import Image from "next/image";
import { ImageIcon } from "lucide-react";
import { cn } from "@/lib/utils";
type PortfolioCoverProps = {
src: string | null;
title: string;
category?: string;
sizes?: string;
priority?: boolean;
className?: string;
};
const DEFAULT_SIZES =
"(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw";
/**
* Project cover. Renders the stored image when present, otherwise a restrained
* branded placeholder (owner will replace placeholders with real artwork).
* Fills a positioned, aspect-sized parent (`relative` + `aspect-*`).
*/
export function PortfolioCover({
src,
title,
category,
sizes = DEFAULT_SIZES,
priority = false,
className,
}: PortfolioCoverProps) {
if (src) {
return (
<Image
src={src}
alt={title}
fill
sizes={sizes}
priority={priority}
className={cn(
"object-cover transition-transform duration-500 ease-out group-hover:scale-[1.04]",
className,
)}
/>
);
}
const monogram = title.trim().charAt(0).toUpperCase() || "•";
return (
<div
aria-hidden
className={cn(
"relative flex h-full w-full flex-col items-center justify-center gap-3 bg-surface-2",
className,
)}
>
<span className="pointer-events-none absolute -top-1/3 left-1/2 h-2/3 w-2/3 -translate-x-1/2 rounded-full bg-[radial-gradient(closest-side,hsl(var(--brand-primary)/0.12),transparent)]" />
<span className="text-[clamp(3rem,7vw,4.5rem)] font-semibold leading-none text-foreground/12">
{monogram}
</span>
<span className="inline-flex items-center gap-1.5 text-caption text-muted-foreground/70">
<ImageIcon className="h-3.5 w-3.5" />
{category}
</span>
</div>
);
}
+1 -8
View File
@@ -235,14 +235,7 @@ function ProjectHeader({
<Link href={getLocalizedPath(locale, "/portfolio", defaultLocale)}>{t("back")}</Link>
</Button>
<h1 className="mt-5 text-3xl font-semibold text-foreground sm:text-4xl lg:text-5xl">
{getLocalizedValue(item.title, locale)}
</h1>
<p className="mt-4 max-w-3xl text-base leading-8 text-muted-foreground sm:text-lg">
{getLocalizedValue(item.summary, locale)}
</p>
<div className="mt-8">
<div className="mt-6">
<ProjectMeta item={item} locale={locale} />
</div>
+71 -34
View File
@@ -2,10 +2,10 @@ import { ArrowUpRight, CalendarDays } from "lucide-react";
import Link from "next/link";
import { MotionFade } from "@/components/motion-fade";
import { AppCard } from "@/components/ui/app-card";
import { CardContent } from "@/components/ui/card";
import { PortfolioCover } from "@/components/site/portfolio-cover";
import { getLocalizedPath, type AppLocale } from "@/lib/locale";
import { getLocalizedValue, type PortfolioProjectView } from "@/lib/portfolio";
import { cn } from "@/lib/utils";
type PortfolioProjectGridProps = {
locale: AppLocale;
@@ -22,48 +22,85 @@ export function PortfolioProjectGrid({
emptyLabel,
openLabel,
}: PortfolioProjectGridProps) {
if (projects.length === 0) {
return (
<section>
<div className="rounded-surface border border-border bg-surface-1 px-6 py-16 text-center text-small text-muted-foreground">
{emptyLabel}
</div>
</section>
);
}
return (
<section className="grid gap-4 md:grid-cols-2">
{projects.map((item, index) => (
<MotionFade key={item.slug} delay={index * 0.05}>
<AppCard interactive>
<CardContent className="p-5">
<Link
href={getLocalizedPath(locale, `/portfolio/${item.slug}`, defaultLocale)}
className="group block"
<section className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{projects.map((item, index) => {
const featured = item.isFeatured;
const title = getLocalizedValue(item.title, locale);
const category = getLocalizedValue(item.category.name, locale);
return (
<MotionFade
key={item.slug}
delay={Math.min(index * 0.05, 0.3)}
className={cn(featured && "sm:col-span-2 lg:col-span-2")}
>
<Link
href={getLocalizedPath(locale, `/portfolio/${item.slug}`, defaultLocale)}
className="group flex h-full flex-col overflow-hidden rounded-surface border border-border bg-surface-1 shadow-card transition duration-300 ease-out hover:-translate-y-1 hover:shadow-panel focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
>
<div
className={cn(
"relative overflow-hidden",
featured ? "aspect-[16/10]" : "aspect-[4/3]",
)}
>
<PortfolioCover
src={item.coverImagePath}
title={title}
category={category}
priority={index === 0}
sizes={
featured
? "(min-width: 1024px) 66vw, 100vw"
: "(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
}
/>
</div>
<div className="flex flex-1 flex-col p-5 sm:p-6">
<div className="flex items-center justify-between gap-3">
<p className="text-sm text-muted-foreground/80">
{getLocalizedValue(item.category.name, locale)}
</p>
<p className="inline-flex items-center gap-1 text-xs text-muted-foreground/80">
<span className="eyebrow text-caption text-muted-foreground">
{category}
</span>
<span className="inline-flex items-center gap-1.5 text-caption text-muted-foreground">
<CalendarDays className="h-3.5 w-3.5" />
{item.projectYear}
</p>
</span>
</div>
<h2 className="mt-3 text-xl font-semibold text-foreground">
{getLocalizedValue(item.title, locale)}
<h2
className={cn(
"mt-3 font-semibold tracking-tight text-foreground",
featured ? "text-h3 sm:text-h2" : "text-h3",
)}
>
{title}
</h2>
<p className="mt-2 text-sm text-muted-foreground">
<p className="mt-2 line-clamp-2 text-small leading-relaxed text-muted-foreground">
{getLocalizedValue(item.summary, locale)}
</p>
<p className="mt-4 inline-flex items-center gap-2 text-sm font-medium text-foreground/80 group-hover:text-foreground">
{openLabel}
<ArrowUpRight className="h-4 w-4" />
</p>
</Link>
</CardContent>
</AppCard>
</MotionFade>
))}
{projects.length === 0 ? (
<AppCard className="md:col-span-2">
<CardContent className="p-6 text-sm text-muted-foreground">
{emptyLabel}
</CardContent>
</AppCard>
) : null}
<span className="mt-5 inline-flex items-center gap-2 text-caption font-medium text-foreground/70 transition-colors group-hover:text-brand-primary">
{openLabel}
<ArrowUpRight className="h-4 w-4 transition-transform duration-300 group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
</span>
</div>
</Link>
</MotionFade>
);
})}
</section>
);
}
+23 -3
View File
@@ -12,8 +12,9 @@ services:
NEXT_TELEMETRY_DISABLED: "1"
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-https://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}
DATABASE_URL: postgresql://postgres:postgres@db:5432/moh_sass?schema=public
DATABASE_URL: postgresql://postgres:postgres@db:5432/moh_sass
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_BASIC_AUTH_USER: ${ADMIN_BASIC_AUTH_USER:?ADMIN_BASIC_AUTH_USER must be set in .env}
@@ -27,11 +28,25 @@ services:
timeout: 5s
retries: 10
start_period: 20s
ports:
- "${PORT:-3014}:3000"
labels:
- traefik.enable=true
- 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:
- media_uploads:/app/public/uploads/media
networks:
- proxy
- appnet
db:
@@ -57,3 +72,8 @@ volumes:
networks:
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}
+231
View File
@@ -0,0 +1,231 @@
# Design System — mohfarawati.de
The visual language for the public site and admin. This documents the token
system that already lives in `app/globals.css` and `tailwind.config.ts`, and
defines the layers that are still missing (type scale, spacing rhythm) so the
site has one consistent, premium base before any page is redesigned.
Source of truth for values: `app/globals.css` (`:root` + `.dark`). Tailwind
mappings: `tailwind.config.ts`. This file is the *usage guide* for those tokens.
---
## Direction: Warm Premium (work-forward)
Chosen visual direction. Every design decision serves these principles:
1. **The work is the hero.** Layouts lead with project imagery and a strong
title hierarchy; chrome stays quiet.
2. **One decisive accent.** The warm red-orange brand color appears sparingly —
primary CTAs, one highlighted word, active/selected states, focus. Everything
else is neutral ink on light/dark surfaces. Accent is a punctuation mark, not
a background.
3. **Generous whitespace + restraint.** Space and type hierarchy carry the
premium feel — not borders, gradients, or decoration. "Too busy" is the
default failure mode; cut before adding.
4. **Depth, softly.** Elevation via one soft shadow layer and the large 24px
radius already in the tokens. Avoid stacking shadows or heavy borders.
5. **Light + dark parity.** Every token has a dark value; never hardcode a hex.
6. **Arabic is first-class.** RTL layout, no uppercase or letter-spacing on
Arabic text, slightly looser line-height. Latin uses Museo, Arabic uses Dubai.
Colors and fonts are fixed by the owner and are **not** changed by this system —
it only organizes and documents them and adds the missing scale layers.
---
## 1. Color tokens
All colors are HSL channel triplets consumed as `hsl(var(--token))`. Light is
`:root`, dark is `.dark`.
### Base
| Token | Light | Dark | Use |
|---|---|---|---|
| `--background` | `0 0% 100%` | `222 22% 8%` | Page canvas |
| `--foreground` | `222 24% 10%` | `210 20% 96%` | Primary ink |
| `--muted` | `220 14% 96%` | `222 16% 14%` | Quiet fills |
| `--muted-foreground` | `220 10% 42%` | `217 10% 64%` | Secondary text |
### Surfaces (elevation ramp)
| Token | Light | Dark | Use |
|---|---|---|---|
| `--surface-1` | `0 0% 100%` | `222 22% 9%` | Base card |
| `--surface-2` | `220 16% 97%` | `222 18% 12%` | Raised card / section tint |
| `--surface-3` | `220 14% 94%` | `222 16% 15%` | Highest / hover |
| `--surface-inverse` | `222 24% 12%` | `210 20% 96%` | Inverted blocks |
Step surfaces 1 → 2 → 3 for hierarchy. Do not invent intermediate greys.
### Brand + semantic (use sparingly)
| Token | Light | Dark | Use |
|---|---|---|---|
| `--brand-primary` / `--primary` / `--ring` | `9 73% 50%` | `9 78% 56%` | The one accent: CTAs, highlight word, focus |
| `--brand-secondary` | `12 64% 58%` | `12 74% 64%` | Accent gradient partner, live dots |
| `--primary-foreground` | `210 40% 98%` | `210 40% 98%` | Text on accent |
| `--destructive` | `0 84% 60%` | `0 63% 31%` | Errors only |
### Status
| Token | Light | Dark |
|---|---|---|
| `--status-success` / `--status-success-soft` | `145 63% 42%` / `145 58% 92%` | `145 68% 66%` / `145 36% 18%` |
| `--status-warning` / `--status-warning-soft` | `36 92% 44%` / `41 96% 90%` | `42 96% 66%` / `35 40% 18%` |
Reserved for real state (success/warning feedback), never decoration.
### Lines + hero
| Token | Light | Dark | Use |
|---|---|---|---|
| `--border` | `220 13% 90%` | `222 14% 18%` | Default hairline |
| `--border-strong` | `220 11% 82%` | `222 12% 26%` | Emphasis divider |
| `--hero-base` / `--hero-ink` | `260 22% 95%` / `226 43% 11%` | `222 24% 10%` / `210 20% 96%` | Hero backdrop base + ink |
Tailwind exposes these as `bg-background`, `text-foreground`, `bg-surface-2`,
`text-brand-primary`, `border-border`, `bg-status-success-soft`, etc.
**Accent budget:** at most one accent-filled element per view (usually the
primary CTA). Additional emphasis uses ink weight/size, not more color.
---
## 2. Typography
### Fonts (fixed)
| Script | Family | Variable | Utility | Weights |
|---|---|---|---|---|
| Latin (default) | Museo Sans Rounded | `--font-museo` | `.font-latin` | 300, 500, 700, 900 |
| Arabic | Dubai | `--font-dubai` | `.font-arabic` | 300, 400, 700 |
Applied on `body` (Latin) and `html[lang="ar"]` (Arabic) in `globals.css`.
### Type scale (to add as tokens in 2.3)
A single documented scale replaces ad-hoc sizes. Latin values; Arabic overrides
noted below.
| Role | Size (clamp) | Weight | Line-height | Tracking |
|---|---|---|---|---|
| `display` | `clamp(2.75rem, 8vw, 5.5rem)` | 900 | 0.95 | -0.03em |
| `h1` | `clamp(2.25rem, 5vw, 3.5rem)` | 700 | 1.05 | -0.02em |
| `h2` | `clamp(1.75rem, 3.5vw, 2.5rem)` | 700 | 1.1 | -0.015em |
| `h3` | `1.375rem` (22px) | 700 | 1.2 | -0.01em |
| `title` | `1.125rem` (18px) | 500 | 1.3 | 0 |
| `body-lg` | `1.125rem` (18px) | 400 | 1.7 | 0 |
| `body` | `1rem` (16px) | 400 | 1.7 | 0 |
| `small` | `0.875rem` (14px) | 400 / 500 | 1.6 | 0 |
| `caption` | `0.8125rem` (13px) | 500 | 1.5 | 0 |
| `overline` | `0.75rem` (12px) | 500 | 1.4 | 0.14em, uppercase |
**Arabic overrides:** never `uppercase`, never letter-spacing (breaks joins);
add ~0.1 to line-height on display/h1/h2; cap weight at 700 (Dubai has no 900).
The `overline` role for Arabic uses normal tracking and normal case.
Only two Latin weights per view in body copy (regular 400 + one bold). Reserve
900 for `display` only.
---
## 3. Radius
| Token | Value | Use |
|---|---|---|
| `--radius-surface` | `24px` | Cards, panels, large containers (`rounded-surface` / `lg` / `xl`) |
| `--radius-nested` | `16px` | Nested elements, inputs, buttons (`rounded-nested` / `sm` / `md`) |
| `--radius-pill` | `9999px` | Pills, badges, toggles (`rounded-pill`) |
The generous 24px surface radius is part of the premium feel — keep it
consistent; do not mix arbitrary radii.
---
## 4. Elevation (shadows)
| Token | Use |
|---|---|
| `--shadow-xs` / `shadow-xs` | Subtle lift (hairline cards) |
| `--shadow-sm` / `shadow-sm` | Small controls, pills |
| `--shadow-card` / `shadow-card` | Default raised card |
| `--shadow-panel` / `shadow-panel` | Floating panel / glass surface |
| `--shadow-md`, `--shadow-lg` | Reserved for overlays; use rarely |
Discipline: most surfaces are flat with a hairline border. Raise with **one**
shadow layer at a time. Never stack `shadow-lg` on nested elements.
---
## 5. Spacing + layout
### Containers
| Token | Value | Use |
|---|---|---|
| `--container-narrow` | `48rem` | Reading width, forms |
| `--container-default` | `72rem` | Standard page content |
| `--container-wide` | `80rem` | Galleries, wide grids |
| `--container-admin` | `88rem` | Admin workspace |
Header: `--header-height: 4.5rem`.
### Vertical rhythm (to formalize in 2.3)
Existing: `--section-space: 3.5rem`, `--content-space: 1.5rem`
(`spacing.section`, `spacing.content`).
Premium layouts need more air. Recommended section rhythm to add:
| Token | Value | Use |
|---|---|---|
| `section-sm` | `3rem` | Tight sections |
| `section` | `5rem` (mobile) → `7rem` (lg) | Default between major sections |
| `section-lg` | `8rem`+ | Hero-adjacent breathing room |
Internal gaps use rem steps: `1rem`, `1.5rem`, `2rem`.
---
## 6. Motion
framer-motion, restrained. Entrance pattern (see `components/layout/site-hero.tsx`):
- item: `opacity 0→1`, `y 16→0`, `duration 0.54`, ease `[0.22, 1, 0.36, 1]`
- container stagger: `0.13`, delay `0.08`
Rules: entrance + hover only; respect `prefers-reduced-motion`; no looping or
attention-grabbing motion on content.
---
## 7. Component conventions
- **Cards** route through `components/ui/app-card.tsx` (`AppCard` with
`level`/`layer`/`padding`). Use `level` to express hierarchy — do not restyle
cards ad hoc.
- **Buttons**: one `primary` (accent) per view; others `outline`/`ghost`.
- **Badges**: `success`/`warning` variants map to status tokens; `outline` for
neutral tags.
- **Inputs**: `--radius-nested`, `--border`, focus uses `--focus-ring`.
- **Inline feedback** (admin): `components/admin/admin-flash.tsx`.
---
## 8. Do / Don't
**Do**
- Lead with imagery and whitespace; let type hierarchy do the work.
- Use the accent once per view, deliberately.
- Keep one radius language (24 / 16 / pill) and one shadow layer.
- Test every screen in light and dark, and Arabic RTL.
**Don't**
- Fill backgrounds with brand color or gradients.
- Add a second accent hue.
- Apply uppercase/letter-spacing to Arabic text.
- Stack shadows or mix arbitrary radii/greys.
- Put decoration where whitespace would do.
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./lib/db/schema.ts",
out: "./lib/db/migrations",
dialect: "postgresql",
dbCredentials: {
url:
process.env.DATABASE_URL ??
"postgresql://postgres:postgres@localhost:5432/moh_sass",
},
});
+25 -20
View File
@@ -2,7 +2,10 @@ import { createHash, createHmac, timingSafeEqual } from "crypto";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { prisma } from "./prisma";
import { and, eq, like, lt } from "drizzle-orm";
import { db } from "./db";
import { appConfig } from "./db/schema";
import { getAdminAppPath } from "./admin-routing";
export const ADMIN_SESSION_COOKIE = "moh_admin_session";
@@ -137,11 +140,9 @@ function getLockoutKey(ip: string): string {
async function cleanupExpiredLockouts(): Promise<void> {
try {
const cutoff = new Date(Date.now() - LOCKOUT_SECONDS * 2 * 1000);
await prisma.$executeRaw`
DELETE FROM "AppConfig"
WHERE key LIKE ${`${ADMIN_LOCKOUT_KEY_PREFIX}:%`}
AND "updatedAt" < ${cutoff}
`;
await db
.delete(appConfig)
.where(and(like(appConfig.key, `${ADMIN_LOCKOUT_KEY_PREFIX}:%`), lt(appConfig.updatedAt, cutoff)));
} catch {
// Non-critical — ignore cleanup errors.
}
@@ -216,10 +217,11 @@ export async function getAdminLockState(): Promise<{ locked: boolean; remainingS
try {
const ip = await getClientIp();
const key = getLockoutKey(ip);
const config = await prisma.appConfig.findUnique({
where: { key },
select: { value: true },
});
const [config] = await db
.select({ value: appConfig.value })
.from(appConfig)
.where(eq(appConfig.key, key))
.limit(1);
const state = parseFailState(config?.value);
const now = Date.now();
@@ -244,10 +246,11 @@ export async function registerFailedAdminAttempt(): Promise<{ locked: boolean; r
await cleanupExpiredLockouts();
const config = await prisma.appConfig.findUnique({
where: { key },
select: { value: true },
});
const [config] = await db
.select({ value: appConfig.value })
.from(appConfig)
.where(eq(appConfig.key, key))
.limit(1);
const current = parseFailState(config?.value);
// If a previous lockout has expired, reset the counter.
@@ -256,11 +259,13 @@ export async function registerFailedAdminAttempt(): Promise<{ locked: boolean; r
const locked = attempts >= MAX_FAILED_ATTEMPTS;
const lockUntil = locked ? now + LOCKOUT_SECONDS * 1000 : 0;
await prisma.appConfig.upsert({
where: { key },
update: { value: JSON.stringify({ attempts, lockUntil }) },
create: { key, value: JSON.stringify({ attempts, lockUntil }) },
});
await db
.insert(appConfig)
.values({ key, value: JSON.stringify({ attempts, lockUntil }) })
.onConflictDoUpdate({
target: appConfig.key,
set: { value: JSON.stringify({ attempts, lockUntil }) },
});
return {
locked,
@@ -276,7 +281,7 @@ export async function resetAdminFailedAttempts(): Promise<void> {
try {
const ip = await getClientIp();
const key = getLockoutKey(ip);
await prisma.appConfig.deleteMany({ where: { key } });
await db.delete(appConfig).where(eq(appConfig.key, key));
} catch {
// Non-critical — ignore.
}
+69 -120
View File
@@ -1,4 +1,7 @@
import { prisma } from "./prisma";
import { and, eq, inArray } from "drizzle-orm";
import { db } from "./db";
import { appConfig, mediaAsset, mediaUsage } from "./db/schema";
export const MAINTENANCE_MODE_KEY = "maintenance_mode";
export {
SITE_NAME_KEY,
@@ -65,45 +68,42 @@ import {
type MarqueeSettings,
} from "./marquee-settings";
// Small helpers over the app_config key/value table (Drizzle).
async function readConfigValue(key: string): Promise<string | undefined> {
const [row] = await db
.select({ value: appConfig.value })
.from(appConfig)
.where(eq(appConfig.key, key))
.limit(1);
return row?.value;
}
async function upsertConfig(key: string, value: string): Promise<void> {
await db
.insert(appConfig)
.values({ key, value })
.onConflictDoUpdate({ target: appConfig.key, set: { value } });
}
export async function getMaintenanceMode(): Promise<boolean> {
try {
const config = await prisma.appConfig.findUnique({
where: { key: MAINTENANCE_MODE_KEY },
select: { value: true },
});
return config?.value === "true";
return (await readConfigValue(MAINTENANCE_MODE_KEY)) === "true";
} catch {
return false;
}
}
export async function setMaintenanceMode(enabled: boolean): Promise<void> {
await prisma.appConfig.upsert({
where: { key: MAINTENANCE_MODE_KEY },
update: {
value: enabled ? "true" : "false",
},
create: {
key: MAINTENANCE_MODE_KEY,
value: enabled ? "true" : "false",
},
});
await upsertConfig(MAINTENANCE_MODE_KEY, enabled ? "true" : "false");
}
export async function getSiteSettings(): Promise<SiteSettings> {
try {
const configs = await prisma.appConfig.findMany({
where: {
key: {
in: [SITE_SETTINGS_KEY, SITE_NAME_KEY],
},
},
select: {
key: true,
value: true,
},
});
const configs = await db
.select({ key: appConfig.key, value: appConfig.value })
.from(appConfig)
.where(inArray(appConfig.key, [SITE_SETTINGS_KEY, SITE_NAME_KEY]));
const configMap = new Map(configs.map((config) => [config.key, config.value]));
const fallbackName = configMap.get(SITE_NAME_KEY) ?? DEFAULT_SITE_NAME;
@@ -115,26 +115,12 @@ export async function getSiteSettings(): Promise<SiteSettings> {
}
export async function updateSiteSettings(settings: SiteSettings): Promise<void> {
await prisma.appConfig.upsert({
where: { key: SITE_SETTINGS_KEY },
update: {
value: JSON.stringify(settings),
},
create: {
key: SITE_SETTINGS_KEY,
value: JSON.stringify(settings),
},
});
await upsertConfig(SITE_SETTINGS_KEY, JSON.stringify(settings));
}
export async function getMailSettings(): Promise<MailSettings> {
try {
const config = await prisma.appConfig.findUnique({
where: { key: MAIL_SETTINGS_KEY },
select: { value: true },
});
return parseMailSettingsValue(config?.value);
return parseMailSettingsValue(await readConfigValue(MAIL_SETTINGS_KEY));
} catch {
return buildDefaultMailSettings();
}
@@ -147,26 +133,12 @@ export async function getMailSettingsFormValues(): Promise<MailSettingsFormValue
}
export async function updateMailSettings(settings: MailSettings): Promise<void> {
await prisma.appConfig.upsert({
where: { key: MAIL_SETTINGS_KEY },
update: {
value: JSON.stringify(settings),
},
create: {
key: MAIL_SETTINGS_KEY,
value: JSON.stringify(settings),
},
});
await upsertConfig(MAIL_SETTINGS_KEY, JSON.stringify(settings));
}
export async function getMarqueeSettings(): Promise<MarqueeSettings> {
try {
const config = await prisma.appConfig.findUnique({
where: { key: MARQUEE_SETTINGS_KEY },
select: { value: true },
});
return parseMarqueeSettingsValue(config?.value);
return parseMarqueeSettingsValue(await readConfigValue(MARQUEE_SETTINGS_KEY));
} catch {
return buildDefaultMarqueeSettings();
}
@@ -175,75 +147,52 @@ export async function getMarqueeSettings(): Promise<MarqueeSettings> {
export async function updateMarqueeSettings(settings: MarqueeSettings): Promise<void> {
const normalizedSettings = syncMarqueeSettingsToGermanSource(settings);
await prisma.appConfig.upsert({
where: { key: MARQUEE_SETTINGS_KEY },
update: {
value: JSON.stringify(normalizedSettings),
},
create: {
key: MARQUEE_SETTINGS_KEY,
value: JSON.stringify(normalizedSettings),
},
});
await upsertConfig(MARQUEE_SETTINGS_KEY, JSON.stringify(normalizedSettings));
}
export async function getSiteSettingsMediaBindings(): Promise<SiteSettingsMediaBindings> {
try {
const usages = await prisma.mediaUsage.findMany({
where: {
entityType: SITE_SETTINGS_ENTITY_TYPE,
entityId: SITE_SETTINGS_ENTITY_ID,
},
select: {
fieldKey: true,
updatedAt: true,
asset: {
select: {
id: true,
url: true,
},
},
},
});
const usages = await db
.select({
fieldKey: mediaUsage.fieldKey,
updatedAt: mediaUsage.updatedAt,
assetId: mediaAsset.id,
assetUrl: mediaAsset.url,
})
.from(mediaUsage)
.innerJoin(mediaAsset, eq(mediaAsset.id, mediaUsage.assetId))
.where(
and(
eq(mediaUsage.entityType, SITE_SETTINGS_ENTITY_TYPE),
eq(mediaUsage.entityId, SITE_SETTINGS_ENTITY_ID),
),
);
return usages.reduce<SiteSettingsMediaBindings>(
(result, usage) => {
if (usage.fieldKey === SITE_SETTINGS_LOGO_LIGHT_FIELD_KEY) {
result.siteLogoLight = {
assetId: usage.asset.id,
url: usage.asset.url,
version: usage.updatedAt.toISOString(),
};
}
return usages.reduce<SiteSettingsMediaBindings>((result, usage) => {
const binding = {
assetId: usage.assetId,
url: usage.assetUrl,
version: usage.updatedAt.toISOString(),
};
if (usage.fieldKey === SITE_SETTINGS_LOGO_DARK_FIELD_KEY) {
result.siteLogoDark = {
assetId: usage.asset.id,
url: usage.asset.url,
version: usage.updatedAt.toISOString(),
};
}
if (usage.fieldKey === SITE_SETTINGS_LOGO_LIGHT_FIELD_KEY) {
result.siteLogoLight = binding;
}
if (usage.fieldKey === SITE_SETTINGS_FAVICON_FIELD_KEY) {
result.favicon = {
assetId: usage.asset.id,
url: usage.asset.url,
version: usage.updatedAt.toISOString(),
};
}
if (usage.fieldKey === SITE_SETTINGS_LOGO_DARK_FIELD_KEY) {
result.siteLogoDark = binding;
}
if (usage.fieldKey === SITE_SETTINGS_DEFAULT_OG_IMAGE_FIELD_KEY) {
result.defaultOgImage = {
assetId: usage.asset.id,
url: usage.asset.url,
version: usage.updatedAt.toISOString(),
};
}
if (usage.fieldKey === SITE_SETTINGS_FAVICON_FIELD_KEY) {
result.favicon = binding;
}
return result;
},
getDefaultSiteSettingsMediaBindings(),
);
if (usage.fieldKey === SITE_SETTINGS_DEFAULT_OG_IMAGE_FIELD_KEY) {
result.defaultOgImage = binding;
}
return result;
}, getDefaultSiteSettingsMediaBindings());
} catch {
return getDefaultSiteSettingsMediaBindings();
}
+35
View File
@@ -0,0 +1,35 @@
import {
mediaKind,
mediaSource,
mediaUsageType,
portfolioAssetKind,
portfolioProjectViewMode,
portfolioSectionType,
} from "./schema";
/**
* Prisma-compatible enum objects + types, derived from the Drizzle pgEnums, so
* existing consumers can keep writing `MediaKind.IMAGE` (value) and `: MediaKind`
* (type) — only the import path changes from `@prisma/client` to `@/lib/db/enums`.
*/
function asEnum<T extends string>(values: readonly T[]): { [K in T]: K } {
return Object.fromEntries(values.map((v) => [v, v])) as { [K in T]: K };
}
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];
+29
View File
@@ -0,0 +1,29 @@
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";
/**
* The Drizzle database client (postgres.js driver), matching the house standard
* used by the other projects. Replaces the old Prisma client (`lib/prisma.ts`).
* A single connection is reused across hot reloads in dev.
*/
// Strip any query string (e.g. a leftover Prisma `?schema=public`) — postgres.js
// 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 {
dbClient: ReturnType<typeof postgres> | undefined;
};
const client = globalForDb.dbClient ?? postgres(connectionString);
if (process.env.NODE_ENV !== "production") {
globalForDb.dbClient = client;
}
export const db = drizzle(client, { schema });
export { schema };
+125
View File
@@ -0,0 +1,125 @@
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");
+956
View File
@@ -0,0 +1,956 @@
{
"id": "a001b9c1-c931-4e9f-b21c-50cdfbffb6a6",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.app_config": {
"name": "app_config",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"key": {
"name": "key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"app_config_key_unique": {
"name": "app_config_key_unique",
"nullsNotDistinct": false,
"columns": [
"key"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.category": {
"name": "category",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name_ar": {
"name": "name_ar",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name_en": {
"name": "name_en",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name_de": {
"name": "name_de",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description_ar": {
"name": "description_ar",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description_en": {
"name": "description_en",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description_de": {
"name": "description_de",
"type": "text",
"primaryKey": false,
"notNull": true
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"is_active": {
"name": "is_active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"category_slug_unique": {
"name": "category_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.media_asset": {
"name": "media_asset",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"source": {
"name": "source",
"type": "media_source",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"kind": {
"name": "kind",
"type": "media_kind",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true
},
"file_name": {
"name": "file_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": true
},
"alt_text": {
"name": "alt_text",
"type": "text",
"primaryKey": false,
"notNull": false
},
"mime_type": {
"name": "mime_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"size": {
"name": "size",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"media_asset_kind_created_idx": {
"name": "media_asset_kind_created_idx",
"columns": [
{
"expression": "kind",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.media_usage": {
"name": "media_usage",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"asset_id": {
"name": "asset_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"usage_type": {
"name": "usage_type",
"type": "media_usage_type",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"entity_type": {
"name": "entity_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"entity_id": {
"name": "entity_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"field_key": {
"name": "field_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"media_usage_unique_slot": {
"name": "media_usage_unique_slot",
"columns": [
{
"expression": "usage_type",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "entity_type",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "entity_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "field_key",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"media_usage_asset_idx": {
"name": "media_usage_asset_idx",
"columns": [
{
"expression": "asset_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"media_usage_entity_idx": {
"name": "media_usage_entity_idx",
"columns": [
{
"expression": "entity_type",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "entity_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"media_usage_asset_id_media_asset_id_fk": {
"name": "media_usage_asset_id_media_asset_id_fk",
"tableFrom": "media_usage",
"tableTo": "media_asset",
"columnsFrom": [
"asset_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.portfolio_asset": {
"name": "portfolio_asset",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"kind": {
"name": "kind",
"type": "portfolio_asset_kind",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"file_path": {
"name": "file_path",
"type": "text",
"primaryKey": false,
"notNull": true
},
"alt_ar": {
"name": "alt_ar",
"type": "text",
"primaryKey": false,
"notNull": true
},
"alt_en": {
"name": "alt_en",
"type": "text",
"primaryKey": false,
"notNull": true
},
"alt_de": {
"name": "alt_de",
"type": "text",
"primaryKey": false,
"notNull": true
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"portfolio_asset_project_sort_idx": {
"name": "portfolio_asset_project_sort_idx",
"columns": [
{
"expression": "project_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "sort_order",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"portfolio_asset_project_id_portfolio_project_id_fk": {
"name": "portfolio_asset_project_id_portfolio_project_id_fk",
"tableFrom": "portfolio_asset",
"tableTo": "portfolio_project",
"columnsFrom": [
"project_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.portfolio_project": {
"name": "portfolio_project",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"category_id": {
"name": "category_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true
},
"view_mode": {
"name": "view_mode",
"type": "portfolio_project_view_mode",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'GRID'"
},
"title_ar": {
"name": "title_ar",
"type": "text",
"primaryKey": false,
"notNull": true
},
"title_en": {
"name": "title_en",
"type": "text",
"primaryKey": false,
"notNull": true
},
"title_de": {
"name": "title_de",
"type": "text",
"primaryKey": false,
"notNull": true
},
"summary_ar": {
"name": "summary_ar",
"type": "text",
"primaryKey": false,
"notNull": true
},
"summary_en": {
"name": "summary_en",
"type": "text",
"primaryKey": false,
"notNull": true
},
"summary_de": {
"name": "summary_de",
"type": "text",
"primaryKey": false,
"notNull": true
},
"client_name": {
"name": "client_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"project_year": {
"name": "project_year",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"service_label_ar": {
"name": "service_label_ar",
"type": "text",
"primaryKey": false,
"notNull": true
},
"service_label_en": {
"name": "service_label_en",
"type": "text",
"primaryKey": false,
"notNull": true
},
"service_label_de": {
"name": "service_label_de",
"type": "text",
"primaryKey": false,
"notNull": true
},
"preview_url": {
"name": "preview_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"cover_image_path": {
"name": "cover_image_path",
"type": "text",
"primaryKey": false,
"notNull": false
},
"is_featured": {
"name": "is_featured",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"is_published": {
"name": "is_published",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"published_at": {
"name": "published_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"portfolio_project_category_published_sort_idx": {
"name": "portfolio_project_category_published_sort_idx",
"columns": [
{
"expression": "category_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "is_published",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "sort_order",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"portfolio_project_published_sort_idx": {
"name": "portfolio_project_published_sort_idx",
"columns": [
{
"expression": "is_published",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "sort_order",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"portfolio_project_category_id_category_id_fk": {
"name": "portfolio_project_category_id_category_id_fk",
"tableFrom": "portfolio_project",
"tableTo": "category",
"columnsFrom": [
"category_id"
],
"columnsTo": [
"id"
],
"onDelete": "restrict",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"portfolio_project_slug_unique": {
"name": "portfolio_project_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.portfolio_section": {
"name": "portfolio_section",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "portfolio_section_type",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"title_ar": {
"name": "title_ar",
"type": "text",
"primaryKey": false,
"notNull": true
},
"title_en": {
"name": "title_en",
"type": "text",
"primaryKey": false,
"notNull": true
},
"title_de": {
"name": "title_de",
"type": "text",
"primaryKey": false,
"notNull": true
},
"body_ar": {
"name": "body_ar",
"type": "text",
"primaryKey": false,
"notNull": true
},
"body_en": {
"name": "body_en",
"type": "text",
"primaryKey": false,
"notNull": true
},
"body_de": {
"name": "body_de",
"type": "text",
"primaryKey": false,
"notNull": true
},
"image_path": {
"name": "image_path",
"type": "text",
"primaryKey": false,
"notNull": false
},
"link_url": {
"name": "link_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"portfolio_section_project_sort_idx": {
"name": "portfolio_section_project_sort_idx",
"columns": [
{
"expression": "project_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "sort_order",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"portfolio_section_project_id_portfolio_project_id_fk": {
"name": "portfolio_section_project_id_portfolio_project_id_fk",
"tableFrom": "portfolio_section",
"tableTo": "portfolio_project",
"columnsFrom": [
"project_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.media_kind": {
"name": "media_kind",
"schema": "public",
"values": [
"IMAGE",
"DOCUMENT"
]
},
"public.media_source": {
"name": "media_source",
"schema": "public",
"values": [
"UPLOAD",
"EXTERNAL"
]
},
"public.media_usage_type": {
"name": "media_usage_type",
"schema": "public",
"values": [
"PORTFOLIO_COVER",
"PORTFOLIO_SECTION",
"PORTFOLIO_ASSET",
"GENERIC"
]
},
"public.portfolio_asset_kind": {
"name": "portfolio_asset_kind",
"schema": "public",
"values": [
"IMAGE",
"DOCUMENT"
]
},
"public.portfolio_project_view_mode": {
"name": "portfolio_project_view_mode",
"schema": "public",
"values": [
"GRID",
"STORY",
"CASE_STUDY"
]
},
"public.portfolio_section_type": {
"name": "portfolio_section_type",
"schema": "public",
"values": [
"RICH_TEXT",
"GALLERY",
"STATS",
"DELIVERABLES",
"LINK"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1786049545718,
"tag": "0000_fixed_venom",
"breakpoints": true
}
]
}
+241
View File
@@ -0,0 +1,241 @@
import { relations } from "drizzle-orm";
import {
boolean,
index,
integer,
pgEnum,
pgTable,
text,
timestamp,
uniqueIndex,
} from "drizzle-orm/pg-core";
/**
* Drizzle schema — the single source of truth for the database, replacing the
* old Prisma schema (see docs). The database is Postgres; migrations are
* generated with `drizzle-kit generate`. IDs are app-generated opaque strings
* (was Prisma `cuid()`), timestamps default in the DB and bump on update.
*/
// `crypto.randomUUID()` is a global in Node 20+ and browsers (no node: import),
// so the schema stays safe to pull into a client bundle via lib/db/enums.
const id = () =>
text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID());
const createdAt = timestamp("created_at", { withTimezone: true }).notNull().defaultNow();
const updatedAt = timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date());
// --- Enums ------------------------------------------------------------------
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(),
key: text("key").notNull().unique(),
value: text("value").notNull(),
createdAt,
updatedAt,
});
export const category = pgTable("category", {
id: id(),
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(),
categoryId: text("category_id")
.notNull()
.references(() => category.id, { onDelete: "restrict" }),
slug: text("slug").notNull().unique(),
viewMode: portfolioProjectViewMode("view_mode").notNull().default("GRID"),
titleAr: text("title_ar").notNull(),
titleEn: text("title_en").notNull(),
titleDe: text("title_de").notNull(),
summaryAr: text("summary_ar").notNull(),
summaryEn: text("summary_en").notNull(),
summaryDe: text("summary_de").notNull(),
clientName: text("client_name").notNull(),
projectYear: integer("project_year").notNull(),
serviceLabelAr: text("service_label_ar").notNull(),
serviceLabelEn: text("service_label_en").notNull(),
serviceLabelDe: text("service_label_de").notNull(),
previewUrl: text("preview_url"),
coverImagePath: text("cover_image_path"),
isFeatured: boolean("is_featured").notNull().default(false),
isPublished: boolean("is_published").notNull().default(false),
publishedAt: timestamp("published_at", { withTimezone: true }),
sortOrder: integer("sort_order").notNull().default(0),
createdAt,
updatedAt,
},
(t) => [
index("portfolio_project_category_published_sort_idx").on(t.categoryId, t.isPublished, t.sortOrder),
index("portfolio_project_published_sort_idx").on(t.isPublished, t.sortOrder),
],
);
export const portfolioSection = pgTable(
"portfolio_section",
{
id: id(),
projectId: text("project_id")
.notNull()
.references(() => portfolioProject.id, { onDelete: "cascade" }),
type: portfolioSectionType("type").notNull(),
titleAr: text("title_ar").notNull(),
titleEn: text("title_en").notNull(),
titleDe: text("title_de").notNull(),
bodyAr: text("body_ar").notNull(),
bodyEn: text("body_en").notNull(),
bodyDe: text("body_de").notNull(),
imagePath: text("image_path"),
linkUrl: text("link_url"),
sortOrder: integer("sort_order").notNull().default(0),
createdAt,
updatedAt,
},
(t) => [index("portfolio_section_project_sort_idx").on(t.projectId, t.sortOrder)],
);
export const portfolioAsset = pgTable(
"portfolio_asset",
{
id: id(),
projectId: text("project_id")
.notNull()
.references(() => portfolioProject.id, { onDelete: "cascade" }),
kind: portfolioAssetKind("kind").notNull(),
filePath: text("file_path").notNull(),
altAr: text("alt_ar").notNull(),
altEn: text("alt_en").notNull(),
altDe: text("alt_de").notNull(),
sortOrder: integer("sort_order").notNull().default(0),
createdAt,
updatedAt,
},
(t) => [index("portfolio_asset_project_sort_idx").on(t.projectId, t.sortOrder)],
);
export const mediaAsset = pgTable(
"media_asset",
{
id: id(),
source: mediaSource("source").notNull(),
kind: mediaKind("kind").notNull(),
url: text("url").notNull(),
fileName: text("file_name").notNull(),
label: text("label").notNull(),
altText: text("alt_text"),
mimeType: text("mime_type"),
size: integer("size"),
createdAt,
updatedAt,
},
(t) => [index("media_asset_kind_created_idx").on(t.kind, t.createdAt)],
);
export const mediaUsage = pgTable(
"media_usage",
{
id: id(),
assetId: text("asset_id")
.notNull()
.references(() => mediaAsset.id, { onDelete: "cascade" }),
usageType: mediaUsageType("usage_type").notNull(),
entityType: text("entity_type").notNull(),
entityId: text("entity_id").notNull(),
fieldKey: text("field_key").notNull(),
createdAt,
updatedAt,
},
(t) => [
uniqueIndex("media_usage_unique_slot").on(t.usageType, t.entityType, t.entityId, t.fieldKey),
index("media_usage_asset_idx").on(t.assetId),
index("media_usage_entity_idx").on(t.entityType, t.entityId),
],
);
// --- Relations (for the relational query API: db.query.*.findMany({ with })) --
export const categoryRelations = relations(category, ({ many }) => ({
projects: many(portfolioProject),
}));
export const portfolioProjectRelations = relations(portfolioProject, ({ one, many }) => ({
category: one(category, {
fields: [portfolioProject.categoryId],
references: [category.id],
}),
sections: many(portfolioSection),
assets: many(portfolioAsset),
}));
export const portfolioSectionRelations = relations(portfolioSection, ({ one }) => ({
project: one(portfolioProject, {
fields: [portfolioSection.projectId],
references: [portfolioProject.id],
}),
}));
export const portfolioAssetRelations = relations(portfolioAsset, ({ one }) => ({
project: one(portfolioProject, {
fields: [portfolioAsset.projectId],
references: [portfolioProject.id],
}),
}));
export const mediaAssetRelations = relations(mediaAsset, ({ many }) => ({
usages: many(mediaUsage),
}));
export const mediaUsageRelations = relations(mediaUsage, ({ one }) => ({
asset: one(mediaAsset, {
fields: [mediaUsage.assetId],
references: [mediaAsset.id],
}),
}));
+1 -1
View File
@@ -1,6 +1,6 @@
import path from "path";
import { MediaKind, MediaSource } from "@prisma/client";
import { MediaKind, MediaSource } from "@/lib/db/enums";
import { createMediaAsset, getMediaAssetById } from "@/lib/media";
import { getExtensionForMimeType, removeManagedMediaFile, saveMediaUpload } from "@/lib/media-storage";
+1 -1
View File
@@ -1,4 +1,4 @@
import { MediaKind } from "@prisma/client";
import { MediaKind } from "@/lib/db/enums";
import { z } from "zod";
const mediaModeSchema = z.enum(["library", "external", "upload"]);
+54 -79
View File
@@ -1,20 +1,17 @@
import type {
MediaAsset,
MediaKind,
MediaSource,
MediaUsage,
MediaUsageType,
} from "@prisma/client";
import { and, desc, eq } from "drizzle-orm";
import { prisma } from "@/lib/prisma";
import { db } from "@/lib/db";
import { mediaAsset, mediaUsage } from "@/lib/db/schema";
import type { MediaKind, MediaSource, MediaUsageType } from "@/lib/db/enums";
type MediaAssetRow = typeof mediaAsset.$inferSelect;
type MediaUsageRow = typeof mediaUsage.$inferSelect;
export type MediaAssetView = Pick<
MediaAsset,
MediaAssetRow,
"id" | "source" | "kind" | "url" | "fileName" | "label" | "altText" | "mimeType" | "size" | "createdAt"
> & {
usages: Array<
Pick<MediaUsage, "id" | "usageType" | "entityType" | "entityId" | "fieldKey">
>;
usages: Array<Pick<MediaUsageRow, "id" | "usageType" | "entityType" | "entityId" | "fieldKey">>;
};
export type MediaOption = Pick<MediaAssetView, "id" | "kind" | "url" | "label" | "source">;
@@ -25,11 +22,7 @@ export type PortfolioMediaBindings = {
assetIds: Record<string, string>;
};
function mapMediaAsset(
asset: MediaAsset & {
usages: MediaUsage[];
},
): MediaAssetView {
function mapMediaAsset(asset: MediaAssetRow & { usages: MediaUsageRow[] }): MediaAssetView {
return {
id: asset.id,
source: asset.source,
@@ -52,40 +45,32 @@ function mapMediaAsset(
}
export async function getAdminMediaAssets() {
const assets = await prisma.mediaAsset.findMany({
include: {
usages: {
orderBy: [{ createdAt: "desc" }],
},
},
orderBy: [{ createdAt: "desc" }],
const assets = await db.query.mediaAsset.findMany({
with: { usages: { orderBy: [desc(mediaUsage.createdAt)] } },
orderBy: [desc(mediaAsset.createdAt)],
});
return assets.map(mapMediaAsset);
}
export async function getMediaOptions(filters?: { kind?: MediaKind }) {
const assets = await prisma.mediaAsset.findMany({
where: filters?.kind ? { kind: filters.kind } : undefined,
orderBy: [{ createdAt: "desc" }],
select: {
id: true,
kind: true,
url: true,
label: true,
source: true,
},
});
return assets;
export async function getMediaOptions(filters?: { kind?: MediaKind }): Promise<MediaOption[]> {
return db
.select({
id: mediaAsset.id,
kind: mediaAsset.kind,
url: mediaAsset.url,
label: mediaAsset.label,
source: mediaAsset.source,
})
.from(mediaAsset)
.where(filters?.kind ? eq(mediaAsset.kind, filters.kind) : undefined)
.orderBy(desc(mediaAsset.createdAt));
}
export async function getMediaAssetById(id: string) {
const asset = await prisma.mediaAsset.findUnique({
where: { id },
include: {
usages: true,
},
const asset = await db.query.mediaAsset.findFirst({
where: eq(mediaAsset.id, id),
with: { usages: true },
});
return asset ? mapMediaAsset(asset) : null;
@@ -101,8 +86,9 @@ export async function createMediaAsset(input: {
mimeType?: string | null;
size?: number | null;
}) {
return prisma.mediaAsset.create({
data: {
const [created] = await db
.insert(mediaAsset)
.values({
source: input.source,
kind: input.kind,
url: input.url,
@@ -111,8 +97,10 @@ export async function createMediaAsset(input: {
altText: input.altText ?? null,
mimeType: input.mimeType ?? null,
size: input.size ?? null,
},
});
})
.returning();
return created;
}
export async function replaceEntityMediaUsages(input: {
@@ -124,51 +112,42 @@ export async function replaceEntityMediaUsages(input: {
fieldKey: string;
}>;
}) {
await prisma.$transaction(async (tx) => {
await tx.mediaUsage.deleteMany({
where: {
entityType: input.entityType,
entityId: input.entityId,
},
});
await db.transaction(async (tx) => {
await tx
.delete(mediaUsage)
.where(and(eq(mediaUsage.entityType, input.entityType), eq(mediaUsage.entityId, input.entityId)));
if (input.usages.length === 0) {
return;
}
await tx.mediaUsage.createMany({
data: input.usages.map((usage) => ({
await tx.insert(mediaUsage).values(
input.usages.map((usage) => ({
assetId: usage.assetId,
usageType: usage.usageType,
entityType: input.entityType,
entityId: input.entityId,
fieldKey: usage.fieldKey,
})),
});
);
});
}
export async function deleteEntityMediaUsages(entityType: string, entityId: string) {
await prisma.mediaUsage.deleteMany({
where: {
entityType,
entityId,
},
});
await db
.delete(mediaUsage)
.where(and(eq(mediaUsage.entityType, entityType), eq(mediaUsage.entityId, entityId)));
}
export async function getPortfolioMediaBindings(projectId: string): Promise<PortfolioMediaBindings> {
const usages = await prisma.mediaUsage.findMany({
where: {
entityType: "portfolio-project",
entityId: projectId,
},
select: {
assetId: true,
usageType: true,
fieldKey: true,
},
});
const usages = await db
.select({
assetId: mediaUsage.assetId,
usageType: mediaUsage.usageType,
fieldKey: mediaUsage.fieldKey,
})
.from(mediaUsage)
.where(and(eq(mediaUsage.entityType, "portfolio-project"), eq(mediaUsage.entityId, projectId)));
return usages.reduce<PortfolioMediaBindings>(
(result, usage) => {
@@ -195,9 +174,5 @@ export async function getPortfolioMediaBindings(projectId: string): Promise<Port
}
export async function countMediaUsageReferences(assetId: string) {
return prisma.mediaUsage.count({
where: {
assetId,
},
});
return db.$count(mediaUsage, eq(mediaUsage.assetId, assetId));
}
+1 -1
View File
@@ -1,4 +1,4 @@
import type { PortfolioProjectViewMode, PortfolioSectionType } from "@prisma/client";
import type { PortfolioProjectViewMode, PortfolioSectionType } from "@/lib/db/enums";
export type PortfolioWizardStep = "basics" | "content" | "sections" | "assets";
+1 -1
View File
@@ -1,4 +1,4 @@
import { PortfolioSectionType } from "@prisma/client";
import { PortfolioSectionType } from "@/lib/db/enums";
import { z } from "zod";
import { mediaFieldInputSchema } from "./media-validation";
+72 -147
View File
@@ -1,74 +1,22 @@
import type {
Category,
PortfolioAsset,
PortfolioProject,
PortfolioProjectViewMode,
PortfolioSection,
} from "@prisma/client";
import { cache } from "react";
import { and, asc, desc, eq } from "drizzle-orm";
import { db } from "@/lib/db";
import {
category as categoryTable,
portfolioAsset,
portfolioProject,
portfolioSection,
} from "@/lib/db/schema";
import type { PortfolioProjectViewMode } from "@/lib/db/enums";
import { getPortfolioMediaBindings } from "@/lib/media";
import type { AppLocale } from "@/lib/locale";
import { prisma } from "@/lib/prisma";
type CategoryRecord = Pick<
Category,
| "id"
| "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"
>;
type CategoryRecord = typeof categoryTable.$inferSelect;
type SectionRecord = typeof portfolioSection.$inferSelect;
type AssetRecord = typeof portfolioAsset.$inferSelect;
type ProjectRecord = typeof portfolioProject.$inferSelect;
export type LocalizedContent = {
ar: string;
@@ -240,40 +188,29 @@ export function getLocalizedValue(
}
export async function getAdminPortfolioCategories() {
const categories = await prisma.category.findMany({
include: {
_count: {
select: {
projects: true,
},
},
},
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
const categories = await db.query.category.findMany({
orderBy: [asc(categoryTable.sortOrder), asc(categoryTable.createdAt)],
with: { projects: { columns: { id: true } } },
});
return categories.map((category) => ({
...mapCategory(category),
projectCount: category._count.projects,
projectCount: category.projects.length,
}));
}
export async function getActivePortfolioCategories() {
const categories = await prisma.category.findMany({
where: {
isActive: true,
},
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
const categories = await db.query.category.findMany({
where: eq(categoryTable.isActive, true),
orderBy: [asc(categoryTable.sortOrder), asc(categoryTable.createdAt)],
});
return categories.map(mapCategory);
}
export async function getActivePortfolioCategoryBySlug(slug: string) {
const category = await prisma.category.findFirst({
where: {
slug,
isActive: true,
},
const category = await db.query.category.findFirst({
where: and(eq(categoryTable.slug, slug), eq(categoryTable.isActive, true)),
});
return category ? mapCategory(category) : null;
@@ -283,90 +220,78 @@ export async function getAdminPortfolioProjects(filters?: {
categoryId?: string;
status?: "all" | "draft" | "published";
}) {
const projects = await prisma.portfolioProject.findMany({
where: {
...(filters?.categoryId ? { categoryId: filters.categoryId } : {}),
...(filters?.status === "draft"
? { isPublished: false }
: filters?.status === "published"
? { isPublished: true }
: {}),
},
include: {
const conditions = [
...(filters?.categoryId ? [eq(portfolioProject.categoryId, filters.categoryId)] : []),
...(filters?.status === "draft"
? [eq(portfolioProject.isPublished, false)]
: filters?.status === "published"
? [eq(portfolioProject.isPublished, true)]
: []),
];
const projects = await db.query.portfolioProject.findMany({
where: conditions.length ? and(...conditions) : undefined,
with: {
category: true,
sections: {
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
},
assets: {
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
},
sections: { orderBy: [asc(portfolioSection.sortOrder), asc(portfolioSection.createdAt)] },
assets: { orderBy: [asc(portfolioAsset.sortOrder), asc(portfolioAsset.createdAt)] },
},
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
orderBy: [asc(portfolioProject.sortOrder), desc(portfolioProject.createdAt)],
});
return projects.map((project) => mapProject(project));
}
export async function getPublishedPortfolioProjects(filters?: { categorySlug?: string }) {
const projects = await prisma.portfolioProject.findMany({
where: {
isPublished: true,
category: {
isActive: true,
...(filters?.categorySlug ? { slug: filters.categorySlug } : {}),
},
},
include: {
const projects = await db.query.portfolioProject.findMany({
where: eq(portfolioProject.isPublished, true),
with: {
category: true,
sections: {
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
},
assets: {
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
},
sections: { orderBy: [asc(portfolioSection.sortOrder), asc(portfolioSection.createdAt)] },
assets: { orderBy: [asc(portfolioAsset.sortOrder), asc(portfolioAsset.createdAt)] },
},
orderBy: [{ sortOrder: "asc" }, { publishedAt: "desc" }, { createdAt: "desc" }],
orderBy: [
asc(portfolioProject.sortOrder),
desc(portfolioProject.publishedAt),
desc(portfolioProject.createdAt),
],
});
return projects.map((project) => mapProject(project));
// Prisma filtered on the related category (active + optional slug); the
// relational query filters the main table only, so narrow here.
return projects
.filter(
(project) =>
project.category.isActive &&
(!filters?.categorySlug || project.category.slug === filters.categorySlug),
)
.map((project) => mapProject(project));
}
export const getPublishedPortfolioProjectBySlug = cache(async function (slug: string) {
const project = await prisma.portfolioProject.findFirst({
where: {
slug,
isPublished: true,
category: {
isActive: true,
},
},
include: {
const project = await db.query.portfolioProject.findFirst({
where: and(eq(portfolioProject.slug, slug), eq(portfolioProject.isPublished, true)),
with: {
category: true,
sections: {
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
},
assets: {
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
},
sections: { orderBy: [asc(portfolioSection.sortOrder), asc(portfolioSection.createdAt)] },
assets: { orderBy: [asc(portfolioAsset.sortOrder), asc(portfolioAsset.createdAt)] },
},
});
return project ? mapProject(project) : null;
if (!project || !project.category.isActive) {
return null;
}
return mapProject(project);
});
export async function getAdminPortfolioProjectById(id: string) {
const project = await prisma.portfolioProject.findUnique({
where: {
id,
},
include: {
const project = await db.query.portfolioProject.findFirst({
where: eq(portfolioProject.id, id),
with: {
category: true,
sections: {
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
},
assets: {
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
},
sections: { orderBy: [asc(portfolioSection.sortOrder), asc(portfolioSection.createdAt)] },
assets: { orderBy: [asc(portfolioAsset.sortOrder), asc(portfolioAsset.createdAt)] },
},
});
-32
View File
@@ -1,32 +0,0 @@
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@prisma/client";
import { Pool } from "pg";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
prismaPool: Pool | undefined;
};
const connectionString =
process.env.DATABASE_URL ??
"postgresql://postgres:postgres@localhost:5432/moh_sass?schema=public";
const pool =
globalForPrisma.prismaPool ??
new Pool({
connectionString,
});
const adapter = new PrismaPg(pool);
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
adapter,
log: ["warn", "error"],
});
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prismaPool = pool;
globalForPrisma.prisma = prisma;
}
+86 -8
View File
@@ -21,16 +21,10 @@
}
},
"comingSoon": {
"badge": "تحديث مهني",
"kicker": "الموقع قيد إعادة البناء",
"titleLineOne": "الموقع",
"titleLineTwo": "قيد التطوير",
"titleLineThree": "وسيعود قريباً",
"description": "أعيد بناء الموقع ليعرض الخدمات، الأعمال المختارة، وطريقة التعاون بشكل مباشر ومنظم.",
"primaryCta": "ابدأ مشروعاً",
"secondaryCta": "العودة للرئيسية",
"status": "قيد التطوير · يعود قريباً",
"countdownLabel": "الإطلاق خلال",
"unitDays": "أيام",
"unitHours": "ساعات",
"unitMinutes": "دقائق",
@@ -44,6 +38,8 @@
},
"heroVisual": {
"kicker": "مرحبا، أنا محمد",
"primaryCta": "شاهد الأعمال",
"secondaryCta": "تواصل معي",
"title": "مطوّر واجهات ويب\nومصمّم جرافيك",
"description": "أبني حلول رقمية لمشاكل حقيقية\nتبدو قوية وتعمل بكفاءة."
},
@@ -268,9 +264,91 @@
},
"aboutPage": {
"title": "من أنا",
"description": "نظرة مركزة على دراستي، دوري الحالي، ونوع شغل الواجهات الذي أقدمه.",
"description": "أنا مطور Full-Stack ومصمم جرافيك مقيم في برلين. بتنقل من الفكرة للكود - هوية بصرية وتصميم واجهات والهندسة يلي بتطلعهم عالنور.",
"heroEyebrow": "من أنا",
"placeholder": "محتوى صفحة من أنا سينضاف هون قريباً."
"story": {
"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": {
"title": "تواصل",
+86 -8
View File
@@ -21,16 +21,10 @@
}
},
"comingSoon": {
"badge": "Professionelles Update",
"kicker": "Die Website wird neu aufgebaut",
"titleLineOne": "Website",
"titleLineTwo": "in Bearbeitung",
"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.",
"primaryCta": "Projekt starten",
"secondaryCta": "Zur Startseite",
"status": "In Entwicklung · bald zurück",
"countdownLabel": "Start in",
"unitDays": "Tage",
"unitHours": "Stunden",
"unitMinutes": "Minuten",
@@ -44,6 +38,8 @@
},
"heroVisual": {
"kicker": "Hi, ich bin Moh",
"primaryCta": "Arbeiten ansehen",
"secondaryCta": "Kontakt aufnehmen",
"title": "frontend\ndeveloper\n& designer",
"description": "Ich entwickle digitale Loesungen fuer echte Probleme,\ndie gut aussehen und sauber funktionieren."
},
@@ -268,9 +264,91 @@
},
"aboutPage": {
"title": "Über mich",
"description": "Ein fokussierter Überblick über meine Ausbildung, meine aktuelle Rolle und die Art von Frontend Arbeit, die ich liefere.",
"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.",
"heroEyebrow": "Über mich",
"placeholder": "Der Inhalt der About Seite kommt bald hier hin."
"story": {
"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": {
"title": "Kontakt",
+86 -8
View File
@@ -21,16 +21,10 @@
}
},
"comingSoon": {
"badge": "Professional update",
"kicker": "The site is being rebuilt",
"titleLineOne": "Website",
"titleLineTwo": "under development",
"titleLineThree": "returning soon",
"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",
"unitHours": "Hours",
"unitMinutes": "Minutes",
@@ -44,6 +38,8 @@
},
"heroVisual": {
"kicker": "Hi, I am Moh",
"primaryCta": "View work",
"secondaryCta": "Get in touch",
"title": "frontend\ndeveloper\n& designer",
"description": "I craft digital solutions for human problems\nthat look good and work well."
},
@@ -268,9 +264,91 @@
},
"aboutPage": {
"title": "About",
"description": "A focused overview of my education, current role, and the kind of frontend work I deliver.",
"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.",
"heroEyebrow": "About",
"placeholder": "About page content will be added here soon."
"story": {
"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": {
"title": "Contact",
+2628 -164
View File
File diff suppressed because it is too large Load Diff
+15 -11
View File
@@ -3,22 +3,18 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"dev": "next dev -p 3014",
"build": "next build --webpack",
"start": "next start",
"lint": "eslint .",
"test": "vitest run",
"prisma:generate": "prisma generate",
"db:migrate": "prisma migrate deploy",
"db:migrate:dev": "prisma migrate dev",
"db:seed": "prisma db seed"
},
"prisma": {
"seed": "node prisma/seed.js"
"test:watch": "vitest",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@prisma/adapter-pg": "^7.4.2",
"@prisma/client": "^7.4.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
@@ -28,6 +24,7 @@
"@types/nodemailer": "^7.0.11",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"drizzle-orm": "^0.45.2",
"framer-motion": "^12.35.0",
"gsap": "^3.15.0",
"lucide-react": "^0.577.0",
@@ -36,6 +33,7 @@
"next-themes": "^0.4.6",
"nodemailer": "^8.0.1",
"pg": "^8.20.0",
"postgres": "^3.4.9",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-hook-form": "^7.71.2",
@@ -43,14 +41,20 @@
"zod": "^4.3.6"
},
"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/pg": "^8.18.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"drizzle-kit": "^0.31.10",
"eslint": "^9.39.4",
"eslint-config-next": "^16.1.6",
"jsdom": "^30.0.1",
"postcss": "^8",
"prisma": "^7.4.2",
"tailwindcss": "^3.4.1",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5",
-13
View File
@@ -1,13 +0,0 @@
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
seed: "node prisma/seed.js",
},
datasource: {
url: process.env["DATABASE_URL"] ?? "",
},
});
@@ -1,13 +0,0 @@
-- CreateTable
CREATE TABLE "AppConfig" (
"id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"value" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AppConfig_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "AppConfig_key_key" ON "AppConfig"("key");
@@ -1,112 +0,0 @@
CREATE TYPE "PortfolioSectionType" AS ENUM (
'RICH_TEXT',
'GALLERY',
'STATS',
'DELIVERABLES',
'LINK'
);
CREATE TYPE "PortfolioAssetKind" AS ENUM (
'IMAGE',
'DOCUMENT'
);
CREATE TABLE "Category" (
"id" TEXT 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 NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "PortfolioProject" (
"id" TEXT NOT NULL,
"categoryId" TEXT NOT NULL,
"slug" TEXT 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 NOT NULL DEFAULT false,
"isPublished" BOOLEAN NOT NULL DEFAULT false,
"publishedAt" TIMESTAMP(3),
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PortfolioProject_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "PortfolioSection" (
"id" TEXT 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 NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PortfolioSection_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "PortfolioAsset" (
"id" TEXT 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 NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PortfolioAsset_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "Category_slug_key" ON "Category"("slug");
CREATE UNIQUE INDEX "PortfolioProject_slug_key" ON "PortfolioProject"("slug");
CREATE INDEX "PortfolioProject_categoryId_isPublished_sortOrder_idx" ON "PortfolioProject"("categoryId", "isPublished", "sortOrder");
CREATE INDEX "PortfolioProject_isPublished_sortOrder_idx" ON "PortfolioProject"("isPublished", "sortOrder");
CREATE INDEX "PortfolioSection_projectId_sortOrder_idx" ON "PortfolioSection"("projectId", "sortOrder");
CREATE INDEX "PortfolioAsset_projectId_sortOrder_idx" ON "PortfolioAsset"("projectId", "sortOrder");
ALTER TABLE "PortfolioProject"
ADD CONSTRAINT "PortfolioProject_categoryId_fkey"
FOREIGN KEY ("categoryId") REFERENCES "Category"("id")
ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "PortfolioSection"
ADD CONSTRAINT "PortfolioSection_projectId_fkey"
FOREIGN KEY ("projectId") REFERENCES "PortfolioProject"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "PortfolioAsset"
ADD CONSTRAINT "PortfolioAsset_projectId_fkey"
FOREIGN KEY ("projectId") REFERENCES "PortfolioProject"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,44 +0,0 @@
CREATE TYPE "MediaSource" AS ENUM ('UPLOAD', 'EXTERNAL');
CREATE TYPE "MediaKind" AS ENUM ('IMAGE', 'DOCUMENT');
CREATE TYPE "MediaUsageType" AS ENUM ('PORTFOLIO_COVER', 'PORTFOLIO_SECTION', 'PORTFOLIO_ASSET', 'GENERIC');
CREATE TABLE "MediaAsset" (
"id" TEXT 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) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MediaAsset_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "MediaUsage" (
"id" TEXT 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) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MediaUsage_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "MediaAsset_kind_createdAt_idx" ON "MediaAsset"("kind", "createdAt");
CREATE INDEX "MediaUsage_assetId_idx" ON "MediaUsage"("assetId");
CREATE INDEX "MediaUsage_entityType_entityId_idx" ON "MediaUsage"("entityType", "entityId");
CREATE UNIQUE INDEX "MediaUsage_usageType_entityType_entityId_fieldKey_key" ON "MediaUsage"("usageType", "entityType", "entityId", "fieldKey");
ALTER TABLE "MediaUsage" ADD CONSTRAINT "MediaUsage_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "MediaAsset"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,4 +0,0 @@
CREATE TYPE "PortfolioProjectViewMode" AS ENUM ('GRID', 'STORY', 'CASE_STUDY');
ALTER TABLE "PortfolioProject"
ADD COLUMN "viewMode" "PortfolioProjectViewMode" NOT NULL DEFAULT 'GRID';
-3
View File
@@ -1,3 +0,0 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
-168
View File
@@ -1,168 +0,0 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
}
model AppConfig {
id String @id @default(cuid())
key String @unique
value String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
enum PortfolioSectionType {
RICH_TEXT
GALLERY
STATS
DELIVERABLES
LINK
}
enum PortfolioAssetKind {
IMAGE
DOCUMENT
}
enum PortfolioProjectViewMode {
GRID
STORY
CASE_STUDY
}
enum MediaSource {
UPLOAD
EXTERNAL
}
enum MediaKind {
IMAGE
DOCUMENT
}
enum MediaUsageType {
PORTFOLIO_COVER
PORTFOLIO_SECTION
PORTFOLIO_ASSET
GENERIC
}
model Category {
id String @id @default(cuid())
slug String @unique
nameAr String
nameEn String
nameDe String
descriptionAr String
descriptionEn String
descriptionDe String
sortOrder Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
projects PortfolioProject[]
}
model PortfolioProject {
id String @id @default(cuid())
categoryId String
slug String @unique
viewMode PortfolioProjectViewMode @default(GRID)
titleAr String
titleEn String
titleDe String
summaryAr String
summaryEn String
summaryDe String
clientName String
projectYear Int
serviceLabelAr String
serviceLabelEn String
serviceLabelDe String
previewUrl String?
coverImagePath String?
isFeatured Boolean @default(false)
isPublished Boolean @default(false)
publishedAt DateTime?
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
category Category @relation(fields: [categoryId], references: [id], onDelete: Restrict)
sections PortfolioSection[]
assets PortfolioAsset[]
@@index([categoryId, isPublished, sortOrder])
@@index([isPublished, sortOrder])
}
model PortfolioSection {
id String @id @default(cuid())
projectId String
type PortfolioSectionType
titleAr String
titleEn String
titleDe String
bodyAr String
bodyEn String
bodyDe String
imagePath String?
linkUrl String?
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
project PortfolioProject @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@index([projectId, sortOrder])
}
model PortfolioAsset {
id String @id @default(cuid())
projectId String
kind PortfolioAssetKind
filePath String
altAr String
altEn String
altDe String
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
project PortfolioProject @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@index([projectId, sortOrder])
}
model MediaAsset {
id String @id @default(cuid())
source MediaSource
kind MediaKind
url String
fileName String
label String
altText String?
mimeType String?
size Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
usages MediaUsage[]
@@index([kind, createdAt])
}
model MediaUsage {
id String @id @default(cuid())
assetId String
usageType MediaUsageType
entityType String
entityId String
fieldKey String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
asset MediaAsset @relation(fields: [assetId], references: [id], onDelete: Cascade)
@@unique([usageType, entityType, entityId, fieldKey])
@@index([assetId])
@@index([entityType, entityId])
}
+114
View File
@@ -0,0 +1,114 @@
#!/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);
+19
View File
@@ -74,6 +74,23 @@ const config: Config = {
ring: "hsl(var(--sidebar-ring))",
},
},
fontFamily: {
sans: ["var(--font-museo)", "Arial", "Helvetica", "sans-serif"],
latin: ["var(--font-museo)", "Arial", "Helvetica", "sans-serif"],
arabic: ["var(--font-dubai)", "Tahoma", "Arial", "sans-serif"],
},
fontSize: {
display: ["clamp(2.75rem, 8vw, 5.5rem)", { lineHeight: "0.95", letterSpacing: "-0.03em", fontWeight: "900" }],
h1: ["clamp(2.25rem, 5vw, 3.5rem)", { lineHeight: "1.05", letterSpacing: "-0.02em", fontWeight: "700" }],
h2: ["clamp(1.75rem, 3.5vw, 2.5rem)", { lineHeight: "1.1", letterSpacing: "-0.015em", fontWeight: "700" }],
h3: ["1.375rem", { lineHeight: "1.2", letterSpacing: "-0.01em", fontWeight: "700" }],
title: ["1.125rem", { lineHeight: "1.3", fontWeight: "500" }],
"body-lg": ["1.125rem", { lineHeight: "1.7", fontWeight: "400" }],
body: ["1rem", { lineHeight: "1.7", fontWeight: "400" }],
small: ["0.875rem", { lineHeight: "1.6" }],
caption: ["0.8125rem", { lineHeight: "1.5", fontWeight: "500" }],
overline: ["0.75rem", { lineHeight: "1.4", letterSpacing: "0.14em", fontWeight: "500" }],
},
borderRadius: {
sm: "var(--radius-nested)",
md: "var(--radius-nested)",
@@ -106,6 +123,8 @@ const config: Config = {
},
spacing: {
section: "var(--section-space)",
"section-sm": "3rem",
"section-lg": "8rem",
content: "var(--content-space)",
},
backdropBlur: {
+154
View File
@@ -0,0 +1,154 @@
# 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.
+11
View File
@@ -0,0 +1,11 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Badge } from "@/components/ui/badge";
describe("component harness smoke test", () => {
it("renders a component into jsdom", () => {
render(<Badge>Hello</Badge>);
expect(screen.getByText("Hello")).toBeInTheDocument();
});
});
+36
View File
@@ -0,0 +1,36 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { AdminFlash } from "@/components/admin/admin-flash";
describe("AdminFlash", () => {
it("renders nothing when there are no messages", () => {
const { container } = render(<AdminFlash />);
expect(container.firstChild).toBeNull();
});
it("renders a success message with a status role", () => {
render(<AdminFlash success="Saved." />);
const status = screen.getByRole("status");
expect(status).toHaveTextContent("Saved.");
expect(screen.queryByRole("alert")).toBeNull();
});
it("renders an error message with an alert role", () => {
render(<AdminFlash error="Failed." />);
const alert = screen.getByRole("alert");
expect(alert).toHaveTextContent("Failed.");
expect(screen.queryByRole("status")).toBeNull();
});
it("renders both a success and error message together", () => {
render(<AdminFlash success="Yes" error="No" />);
expect(screen.getByRole("status")).toHaveTextContent("Yes");
expect(screen.getByRole("alert")).toHaveTextContent("No");
});
it("applies a custom className to the wrapper", () => {
const { container } = render(<AdminFlash success="Yes" className="mb-4" />);
expect(container.firstChild).toHaveClass("mb-4");
});
});
+37
View File
@@ -0,0 +1,37 @@
import { Activity } from "lucide-react";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { DashboardCard } from "@/components/dashboard/dashboard-card";
import { StatsCard } from "@/components/dashboard/stats-card";
describe("StatsCard", () => {
it("renders the title and value", () => {
render(<StatsCard title="Visitors" value="1,234" icon={Activity} />);
expect(screen.getByText("Visitors")).toBeInTheDocument();
expect(screen.getByText("1,234")).toBeInTheDocument();
});
it("renders an optional description and footer", () => {
render(
<StatsCard title="T" value="V" description="Up 5%" footer={<span>footer</span>} />,
);
expect(screen.getByText("Up 5%")).toBeInTheDocument();
expect(screen.getByText("footer")).toBeInTheDocument();
});
it("omits the icon container when no icon is provided", () => {
const { container } = render(<StatsCard title="T" value="V" />);
expect(container.querySelector("svg")).toBeNull();
});
});
describe("DashboardCard", () => {
it("passes its props through to a StatsCard", () => {
render(<DashboardCard title="Sales" value="42" description="today" icon={Activity} />);
expect(screen.getByText("Sales")).toBeInTheDocument();
expect(screen.getByText("42")).toBeInTheDocument();
expect(screen.getByText("today")).toBeInTheDocument();
});
});
@@ -0,0 +1,28 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { MarqueeSettingsForm } from "@/components/admin/marquee-settings-form";
import { buildDefaultMarqueeSettings } from "@/lib/marquee-settings";
describe("MarqueeSettingsForm", () => {
it("renders a textarea per row with german default values", () => {
const settings = buildDefaultMarqueeSettings();
settings.locales.de.row1 = "First row value";
render(<MarqueeSettingsForm action={vi.fn()} initialSettings={settings} />);
for (const name of ["row1-de", "row2-de", "row3-de", "row4-de"]) {
const field = document.querySelector(`textarea[name="${name}"]`);
expect(field).not.toBeNull();
}
expect((document.querySelector('textarea[name="row1-de"]') as HTMLTextAreaElement).value).toBe(
"First row value",
);
});
it("wires the server action onto the form and renders a submit button", () => {
const action = vi.fn();
render(<MarqueeSettingsForm action={action} initialSettings={buildDefaultMarqueeSettings()} />);
expect(document.querySelector("form#marquee-settings-form")).not.toBeNull();
expect(screen.getByRole("button", { name: /save marquee/i })).toBeInTheDocument();
});
});
@@ -0,0 +1,72 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { PortfolioCategoryFilter } from "@/components/site/portfolio-category-filter";
import type { PortfolioCategoryView } from "@/lib/portfolio";
function category(overrides: Partial<PortfolioCategoryView> = {}): PortfolioCategoryView {
return {
id: overrides.id ?? "c1",
slug: overrides.slug ?? "branding",
name: overrides.name ?? { ar: "الهوية", en: "Branding", de: "Branding" },
description: overrides.description ?? { ar: "", en: "", de: "" },
sortOrder: overrides.sortOrder ?? 0,
isActive: overrides.isActive ?? true,
};
}
describe("PortfolioCategoryFilter", () => {
const categories = [
category({ id: "c1", slug: "branding", name: { ar: "الهوية", en: "Branding", de: "Marke" } }),
category({ id: "c2", slug: "web", name: { ar: "ويب", en: "Web", de: "Web" } }),
];
it("renders the all-projects link and one link per category", () => {
render(
<PortfolioCategoryFilter
locale="en"
defaultLocale="de"
categories={categories}
allLabel="All"
/>,
);
const links = screen.getAllByRole("link");
expect(links).toHaveLength(3);
expect(screen.getByRole("link", { name: "All" })).toHaveAttribute("href", "/en/portfolio");
expect(screen.getByRole("link", { name: "Branding" })).toHaveAttribute(
"href",
"/en/portfolio/category/branding",
);
});
it("uses localized labels for the active locale", () => {
render(
<PortfolioCategoryFilter
locale="de"
defaultLocale="de"
categories={categories}
allLabel="Alle"
/>,
);
// German locale on the default locale -> unprefixed paths and German labels
expect(screen.getByRole("link", { name: "Marke" })).toHaveAttribute(
"href",
"/portfolio/category/branding",
);
expect(screen.getByRole("link", { name: "Alle" })).toHaveAttribute("href", "/portfolio");
});
it("highlights the active category", () => {
render(
<PortfolioCategoryFilter
locale="en"
defaultLocale="de"
categories={categories}
allLabel="All"
activeCategorySlug="web"
/>,
);
const active = screen.getByRole("link", { name: "Web" });
expect(active).toHaveClass("bg-primary");
});
});
+149
View File
@@ -0,0 +1,149 @@
import { createRef } from "react";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { AppCard } from "@/components/ui/app-card";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { Textarea } from "@/components/ui/textarea";
import { Container } from "@/components/layout/container";
import { HeroBadge } from "@/components/layout/hero-badge";
import { SectionHeading } from "@/components/home/section-heading";
describe("Badge", () => {
it("renders children and default variant classes", () => {
render(<Badge>New</Badge>);
const badge = screen.getByText("New");
expect(badge).toHaveClass("bg-primary");
});
it("applies a chosen variant and merges custom classNames", () => {
render(<Badge variant="success" className="custom-class">OK</Badge>);
const badge = screen.getByText("OK");
expect(badge).toHaveClass("bg-status-success-soft");
expect(badge).toHaveClass("custom-class");
});
it("passes through arbitrary props", () => {
render(<Badge data-testid="b" title="tip">X</Badge>);
expect(screen.getByTestId("b")).toHaveAttribute("title", "tip");
});
});
describe("Input", () => {
it("forwards the ref and renders attributes", () => {
const ref = createRef<HTMLInputElement>();
render(<Input ref={ref} type="email" placeholder="you@example.com" disabled />);
const input = screen.getByPlaceholderText("you@example.com");
expect(ref.current).toBe(input);
expect(input).toHaveAttribute("type", "email");
expect(input).toBeDisabled();
});
it("merges custom classes", () => {
render(<Input className="w-20" aria-label="field" />);
expect(screen.getByLabelText("field")).toHaveClass("w-20");
});
});
describe("Textarea", () => {
it("forwards the ref and renders a default value", () => {
const ref = createRef<HTMLTextAreaElement>();
render(<Textarea ref={ref} defaultValue="hello" aria-label="msg" />);
const textarea = screen.getByLabelText("msg");
expect(ref.current).toBe(textarea);
expect(textarea).toHaveValue("hello");
});
});
describe("Label", () => {
it("associates with a control via htmlFor", () => {
render(<Label htmlFor="name">Name</Label>);
expect(screen.getByText("Name")).toHaveAttribute("for", "name");
});
});
describe("Separator", () => {
it("defaults to a horizontal separator", () => {
const { container } = render(<Separator />);
expect(container.firstChild).toHaveClass("h-px", "w-full");
});
it("supports a vertical orientation", () => {
const { container } = render(<Separator orientation="vertical" />);
expect(container.firstChild).toHaveClass("h-full", "w-px");
});
});
describe("Card family", () => {
it("composes header, title, description and content", () => {
render(
<Card>
<CardHeader>
<CardTitle>Title</CardTitle>
<CardDescription>Desc</CardDescription>
</CardHeader>
<CardContent>Body</CardContent>
</Card>,
);
expect(screen.getByRole("heading", { name: "Title" })).toBeInTheDocument();
expect(screen.getByText("Desc")).toBeInTheDocument();
expect(screen.getByText("Body")).toBeInTheDocument();
});
});
describe("AppCard", () => {
it("renders a single-layer card with its children", () => {
render(<AppCard layer="single">Single</AppCard>);
expect(screen.getByText("Single")).toBeInTheDocument();
});
it("renders a double-layer card (default) with its children", () => {
render(<AppCard>Double</AppCard>);
expect(screen.getByText("Double")).toBeInTheDocument();
});
});
describe("Container", () => {
it("applies the size variant classes", () => {
const { container } = render(<Container size="narrow">Body</Container>);
expect(container.firstChild).toHaveClass("max-w-narrow");
});
it("renders as a child element when asChild is set", () => {
render(
<Container asChild>
<section data-testid="as-child">X</section>
</Container>,
);
expect(screen.getByTestId("as-child").tagName).toBe("SECTION");
});
});
describe("HeroBadge", () => {
it("renders children and optional trailing content", () => {
render(<HeroBadge trailing={<span></span>}>Available</HeroBadge>);
expect(screen.getByText("Available")).toBeInTheDocument();
expect(screen.getByText("→")).toBeInTheDocument();
});
});
describe("SectionHeading", () => {
it("renders eyebrow, title and description", () => {
render(<SectionHeading eyebrow="Work" title="Projects" description="What I build" />);
expect(screen.getByText("Work")).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Projects" })).toBeInTheDocument();
expect(screen.getByText("What I build")).toBeInTheDocument();
});
it("centers content when align is center", () => {
const { container } = render(
<SectionHeading eyebrow="E" title="T" description="D" align="center" />,
);
expect(container.firstChild).toHaveClass("text-center");
});
});
+133
View File
@@ -0,0 +1,133 @@
import { cleanup } from "@testing-library/react";
import { afterEach, vi } from "vitest";
import "@testing-library/jest-dom/vitest";
import React from "react";
afterEach(() => {
cleanup();
});
// ---------------------------------------------------------------------------
// Global mocks for heavy / browser-only libraries so components render in jsdom.
// (vi.mock in a setup file applies to every test file in the component project.)
// ---------------------------------------------------------------------------
vi.mock("framer-motion", () => {
const passthrough = (tag: string) =>
React.forwardRef(function MotionMock(
{ children, ...props }: Record<string, unknown> & { children?: React.ReactNode },
ref: React.Ref<unknown>,
) {
const domProps: Record<string, unknown> = {};
for (const [key, value] of Object.entries(props)) {
// Drop motion-only props that would warn as unknown DOM attributes.
if (
/^(initial|animate|exit|transition|variants|whileHover|whileTap|whileInView|whileFocus|whileDrag|drag|layout|layoutId|viewport|custom|onAnimationComplete|style)$/.test(
key,
)
) {
if (key === "style") domProps.style = value;
continue;
}
domProps[key] = value;
}
return React.createElement(tag, { ...domProps, ref }, children as React.ReactNode);
});
const motion = new Proxy(
{},
{
get: (_target, tag: string) => passthrough(tag),
},
);
return {
motion,
AnimatePresence: ({ children }: { children?: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
useReducedMotion: () => true,
useInView: () => true,
useScroll: () => ({ scrollYProgress: { on: () => () => {}, get: () => 0 } }),
useTransform: () => 0,
useMotionValue: (value: unknown) => ({ get: () => value, set: () => {}, on: () => () => {} }),
useAnimate: () => [React.useRef(null), () => Promise.resolve()],
};
});
vi.mock("gsap", () => {
const tween = { kill: () => {}, play: () => {}, pause: () => {}, progress: () => {} };
const gsap = {
to: () => tween,
from: () => tween,
fromTo: () => tween,
set: () => tween,
timeline: () => ({
to: () => ({}),
from: () => ({}),
fromTo: () => ({}),
add: () => ({}),
kill: () => {},
}),
registerPlugin: () => {},
context: (fn: () => void) => {
if (typeof fn === "function") fn();
return { revert: () => {}, kill: () => {} };
},
matchMedia: () => ({ add: () => {}, revert: () => {} }),
utils: { toArray: (v: unknown) => (Array.isArray(v) ? v : [v]) },
};
return { gsap, default: gsap };
});
vi.mock("@gsap/react", () => ({
useGSAP: () => ({ context: {}, contextSafe: (fn: unknown) => fn }),
}));
vi.mock("next/link", () => ({
default: React.forwardRef(function LinkMock(
{ href, children, ...props }: Record<string, unknown> & { href?: unknown; children?: React.ReactNode },
ref: React.Ref<HTMLAnchorElement>,
) {
return React.createElement("a", { href: String(href ?? ""), ref, ...props }, children as React.ReactNode);
}),
}));
vi.mock("next/image", () => ({
default: ({ src, alt, ...props }: Record<string, unknown> & { src?: unknown; alt?: string }) =>
React.createElement("img", { src: String(src ?? ""), alt: alt ?? "", ...props }),
}));
vi.mock("next/navigation", () => ({
usePathname: () => "/",
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
refresh: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
prefetch: vi.fn(),
}),
useSearchParams: () => new URLSearchParams(),
useParams: () => ({}),
redirect: vi.fn(),
notFound: vi.fn(),
}));
vi.mock("next-intl", () => ({
useTranslations: () => {
const t = (key: string) => key;
t.rich = (key: string) => key;
t.markup = (key: string) => key;
t.raw = (key: string) => key;
return t;
},
useLocale: () => "de",
useFormatter: () => ({
dateTime: (v: Date) => v.toISOString(),
number: (v: number) => String(v),
relativeTime: (v: unknown) => String(v),
}),
useMessages: () => ({}),
NextIntlClientProvider: ({ children }: { children?: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
}));
+138
View File
@@ -0,0 +1,138 @@
import { db } from "@/lib/db";
import {
category,
mediaAsset,
mediaUsage,
portfolioAsset,
portfolioProject,
portfolioSection,
} from "@/lib/db/schema";
import { MediaKind, MediaSource, MediaUsageType, PortfolioSectionType } from "@/lib/db/enums";
let counter = 0;
function uniq(prefix: string) {
counter += 1;
return `${prefix}-${counter}`;
}
export async function createCategory(overrides: Record<string, unknown> = {}) {
const [row] = await db
.insert(category)
.values({
slug: (overrides.slug as string) ?? uniq("cat"),
nameAr: "الاسم",
nameEn: "Name",
nameDe: "Name",
descriptionAr: "وصف",
descriptionEn: "Description",
descriptionDe: "Beschreibung",
sortOrder: 0,
isActive: true,
...overrides,
} as typeof category.$inferInsert)
.returning();
return row;
}
export async function createProject(overrides: Record<string, unknown> = {}) {
const categoryId = (overrides.categoryId as string) ?? (await createCategory()).id;
const isPublished = (overrides.isPublished as boolean) ?? true;
const slug = (overrides.slug as string) ?? uniq("proj");
const [row] = await db
.insert(portfolioProject)
.values({
categoryId,
slug,
viewMode: "GRID",
titleAr: "عنوان",
titleEn: "Title",
titleDe: "Titel",
summaryAr: "ملخص",
summaryEn: "Summary",
summaryDe: "Zusammenfassung",
clientName: "Client",
projectYear: 2025,
serviceLabelAr: "خدمة",
serviceLabelEn: "Service",
serviceLabelDe: "Service",
isFeatured: false,
sortOrder: 0,
...overrides,
isPublished,
publishedAt: isPublished ? new Date() : null,
} as typeof portfolioProject.$inferInsert)
.returning();
return row;
}
export async function createSection(projectId: string, overrides: Record<string, unknown> = {}) {
const [row] = await db
.insert(portfolioSection)
.values({
projectId,
type: PortfolioSectionType.RICH_TEXT,
titleAr: "ع",
titleEn: "t",
titleDe: "t",
bodyAr: "ب",
bodyEn: "b",
bodyDe: "b",
sortOrder: 0,
...overrides,
} as typeof portfolioSection.$inferInsert)
.returning();
return row;
}
export async function createAsset(projectId: string, overrides: Record<string, unknown> = {}) {
const [row] = await db
.insert(portfolioAsset)
.values({
projectId,
kind: "IMAGE",
filePath: "/uploads/media/assets/x.svg",
altAr: "ع",
altEn: "a",
altDe: "a",
sortOrder: 0,
...overrides,
} as typeof portfolioAsset.$inferInsert)
.returning();
return row;
}
export async function createMediaAsset(overrides: Record<string, unknown> = {}) {
const [row] = await db
.insert(mediaAsset)
.values({
source: MediaSource.EXTERNAL,
kind: MediaKind.IMAGE,
url: (overrides.url as string) ?? `https://cdn.example.com/${uniq("img")}.png`,
fileName: "img.png",
label: "Image",
...overrides,
} as typeof mediaAsset.$inferInsert)
.returning();
return row;
}
export async function createMediaUsage(assetId: string, overrides: Record<string, unknown> = {}) {
const [row] = await db
.insert(mediaUsage)
.values({
assetId,
usageType: MediaUsageType.GENERIC,
entityType: "test-entity",
entityId: "e1",
fieldKey: uniq("field"),
...overrides,
} as typeof mediaUsage.$inferInsert)
.returning();
return row;
}
+23
View File
@@ -0,0 +1,23 @@
import { mkdirSync, unlinkSync, writeFileSync } from "fs";
import path from "path";
import { MEDIA_UPLOAD_ROOT } from "@/lib/media-storage";
/**
* Some sandboxed / read-only-mount environments allow writes but forbid `unlink`.
* Filesystem round-trip tests that create AND delete managed media files self-skip
* when deletion isn't permitted, so the suite stays green there while still running
* fully on a normal filesystem (developer machine / CI).
*/
export const canManageUploads: boolean = (() => {
try {
const dir = path.join(MEDIA_UPLOAD_ROOT, "tests");
mkdirSync(dir, { recursive: true });
const probe = path.join(dir, `.cap-probe-${process.pid}-${Date.now()}`);
writeFileSync(probe, "probe");
unlinkSync(probe);
return true;
} catch {
return false;
}
})();
+35
View File
@@ -0,0 +1,35 @@
import { readFileSync, readdirSync } from "fs";
import path from "path";
/**
* Global integration setup.
*
* Only needed when running against a real Postgres via TEST_DATABASE_URL: reset the
* schema and apply every Drizzle migration once before the workers start. When
* TEST_DATABASE_URL is not set, each worker spins up its own in-process PGlite
* database (see tests/helpers/integration-setup.ts) and this is a no-op.
*/
const MIGRATIONS_DIR = path.resolve(process.cwd(), "lib", "db", "migrations");
export default async function setup() {
const connectionString = process.env.TEST_DATABASE_URL?.trim();
if (!connectionString) {
return;
}
const pg = (await import("pg")).default;
const client = new pg.Client({ connectionString });
await client.connect();
try {
await client.query("DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public;");
const files = readdirSync(MIGRATIONS_DIR)
.filter((entry) => entry.endsWith(".sql"))
.sort();
for (const file of files) {
await client.query(readFileSync(path.join(MIGRATIONS_DIR, file), "utf8"));
}
} finally {
await client.end();
}
}
+74
View File
@@ -0,0 +1,74 @@
import { readFileSync, readdirSync } from "fs";
import path from "path";
import { sql } from "drizzle-orm";
import { afterAll, beforeEach, vi } from "vitest";
import * as schema from "@/lib/db/schema";
/**
* Integration database wiring (Drizzle).
*
* - If TEST_DATABASE_URL is set, the real `@/lib/db` singleton is used unchanged,
* pointed at that Postgres. Migrations are applied once by the global setup;
* files run serially and truncate between tests.
*
* - Otherwise, `@/lib/db` is mocked with a Drizzle client backed by an in-process
* PGlite database (Postgres compiled to WASM) — real Postgres semantics, fully
* isolated per worker, no external server. Production code is never modified.
*/
const realDbUrl = process.env.TEST_DATABASE_URL?.trim();
if (realDbUrl) {
process.env.DATABASE_URL = realDbUrl;
}
vi.mock("@/lib/db", async () => {
if (process.env.TEST_DATABASE_URL?.trim()) {
return await vi.importActual<typeof import("@/lib/db")>("@/lib/db");
}
const { PGlite } = await import("@electric-sql/pglite");
const { drizzle } = await import("drizzle-orm/pglite");
const client = new PGlite();
const migrationsDir = path.resolve(process.cwd(), "lib", "db", "migrations");
const files = readdirSync(migrationsDir)
.filter((entry) => entry.endsWith(".sql"))
.sort();
for (const file of files) {
await client.exec(readFileSync(path.join(migrationsDir, file), "utf8"));
}
const db = drizzle(client, { schema });
return { db, schema };
});
const { db } = await import("@/lib/db");
export { db };
// Truncated in dependency order (children first) between every test for isolation.
const TABLES = [
"media_usage",
"media_asset",
"portfolio_asset",
"portfolio_section",
"portfolio_project",
"category",
"app_config",
];
export async function resetDb() {
const list = TABLES.map((table) => `"${table}"`).join(", ");
await db.execute(sql.raw(`TRUNCATE TABLE ${list} RESTART IDENTITY CASCADE;`));
}
beforeEach(async () => {
await resetDb();
});
afterAll(async () => {
// PGlite is in-process and torn down with the worker; the real postgres.js
// client is a shared singleton and is left open on purpose.
});
+67
View File
@@ -0,0 +1,67 @@
import { vi } from "vitest";
/**
* Shared mocks for the Next.js runtime pieces that server actions depend on.
* Action tests wire these in with `vi.mock(...)` at the top of each file, e.g.:
*
* vi.mock("next/navigation", async () => ({
* redirect: (await import("@/tests/helpers/next-mocks")).redirect,
* }));
*/
export class RedirectError extends Error {
readonly digest = "NEXT_REDIRECT";
readonly __isRedirect = true;
constructor(public url: string) {
super(`NEXT_REDIRECT:${url}`);
}
}
export function redirect(url: string): never {
throw new RedirectError(url);
}
export function isRedirectError(error: unknown): error is RedirectError {
return (
error instanceof RedirectError ||
(typeof error === "object" && error !== null && (error as { __isRedirect?: boolean }).__isRedirect === true)
);
}
export const revalidatePath = vi.fn();
export const adminAuth = { authenticated: true };
export const clearAdminSessionCookie = vi.fn(async () => {});
export async function isAdminAuthenticated(): Promise<boolean> {
return adminAuth.authenticated;
}
/** Run an action and return the URL it redirected to (or throw if it didn't). */
export async function captureRedirect(run: () => Promise<unknown>): Promise<string> {
try {
await run();
} catch (error) {
if (isRedirectError(error)) {
return error.url;
}
throw error;
}
throw new Error("Expected the action to redirect, but it returned normally.");
}
export function resetNextMocks() {
revalidatePath.mockClear();
clearAdminSessionCookie.mockClear();
adminAuth.authenticated = true;
}
/** Build a FormData from a flat record (strings and Files). */
export function formDataFrom(fields: Record<string, string | File | undefined>): FormData {
const formData = new FormData();
for (const [key, value] of Object.entries(fields)) {
if (value !== undefined) {
formData.set(key, value);
}
}
return formData;
}
+42
View File
@@ -0,0 +1,42 @@
import { eq } from "drizzle-orm";
import { describe, expect, it } from "vitest";
import { db } from "@/lib/db";
import { appConfig, category } from "@/lib/db/schema";
describe("integration harness smoke test", () => {
it("connects to the migrated test database and performs CRUD", async () => {
const [created] = await db
.insert(category)
.values({
slug: "smoke",
nameAr: "a",
nameEn: "b",
nameDe: "c",
descriptionAr: "a",
descriptionEn: "b",
descriptionDe: "c",
})
.returning();
expect(created.id).toBeTruthy();
expect(created.isActive).toBe(true);
const found = await db.query.category.findFirst({ where: eq(category.slug, "smoke") });
expect(found?.nameEn).toBe("b");
});
it("resets the database between tests", async () => {
const count = await db.$count(category);
expect(count).toBe(0);
});
it("supports enums and appconfig upsert", async () => {
await db
.insert(appConfig)
.values({ key: "k", value: "v1" })
.onConflictDoUpdate({ target: appConfig.key, set: { value: "v2" } });
const row = await db.query.appConfig.findFirst({ where: eq(appConfig.key, "k") });
expect(row?.value).toBe("v1");
});
});
+92
View File
@@ -0,0 +1,92 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("next/navigation", async () => ({
redirect: (await import("@/tests/helpers/next-mocks")).redirect,
}));
vi.mock("next/dist/client/components/redirect-error", async () => ({
isRedirectError: (await import("@/tests/helpers/next-mocks")).isRedirectError,
}));
const { sendContactMessage } = vi.hoisted(() => ({ sendContactMessage: vi.fn(async () => {}) }));
vi.mock("@/lib/mail", () => ({ sendContactMessage }));
import { submitContactFormAction } from "@/app/[locale]/(site)/contact/actions";
import { captureRedirect, formDataFrom } from "@/tests/helpers/next-mocks";
beforeEach(() => {
sendContactMessage.mockClear();
});
describe("submitContactFormAction", () => {
it("sends a valid message and redirects to the localized success page", async () => {
const url = await captureRedirect(() =>
submitContactFormAction(
formDataFrom({
locale: "en",
name: "Jane Doe",
email: "jane@example.com",
message: "Hello, I would like to work together on a project.",
}),
),
);
expect(url).toBe("/en/success");
expect(sendContactMessage).toHaveBeenCalledTimes(1);
expect(sendContactMessage).toHaveBeenCalledWith(
expect.objectContaining({ locale: "en", name: "Jane Doe", email: "jane@example.com" }),
);
});
it("uses the default locale (de) and its bare success path", async () => {
const url = await captureRedirect(() =>
submitContactFormAction(
formDataFrom({
locale: "de",
name: "Max Mustermann",
email: "max@example.com",
message: "Ich interessiere mich fuer eine Zusammenarbeit.",
}),
),
);
expect(url).toBe("/success");
});
it("redirects back with an error for an invalid email and does not send", async () => {
const url = await captureRedirect(() =>
submitContactFormAction(
formDataFrom({
locale: "en",
name: "Jane",
email: "not-an-email",
message: "This is a long enough message body.",
}),
),
);
expect(url).toContain("/en/contact?error=");
expect(sendContactMessage).not.toHaveBeenCalled();
});
it("rejects a too-short message", async () => {
const url = await captureRedirect(() =>
submitContactFormAction(
formDataFrom({ locale: "de", name: "Jane Doe", email: "jane@example.com", message: "short" }),
),
);
expect(url).toContain("/contact?error=");
expect(sendContactMessage).not.toHaveBeenCalled();
});
it("redirects with an error when delivery fails", async () => {
sendContactMessage.mockRejectedValueOnce(new Error("smtp down"));
const url = await captureRedirect(() =>
submitContactFormAction(
formDataFrom({
locale: "en",
name: "Jane Doe",
email: "jane@example.com",
message: "A perfectly valid message body here.",
}),
),
);
expect(url).toContain("/en/contact?error=");
});
});
@@ -0,0 +1,41 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("next/cache", async () => ({ revalidatePath: (await import("@/tests/helpers/next-mocks")).revalidatePath }));
vi.mock("next/navigation", async () => ({ redirect: (await import("@/tests/helpers/next-mocks")).redirect }));
vi.mock("next/dist/client/components/redirect-error", async () => ({
isRedirectError: (await import("@/tests/helpers/next-mocks")).isRedirectError,
}));
vi.mock("@/lib/admin-auth", async () => {
const m = await import("@/tests/helpers/next-mocks");
return { isAdminAuthenticated: m.isAdminAuthenticated, clearAdminSessionCookie: m.clearAdminSessionCookie };
});
import { updateMaintenanceModeAction } from "@/app/_admin/maintenance/actions";
import { getMaintenanceMode } from "@/lib/app-config";
import { adminAuth, captureRedirect, formDataFrom, resetNextMocks } from "@/tests/helpers/next-mocks";
beforeEach(() => {
resetNextMocks();
});
describe("updateMaintenanceModeAction", () => {
it("enables maintenance mode and redirects with a success flash", async () => {
const url = await captureRedirect(() => updateMaintenanceModeAction(formDataFrom({ enabled: "true" })));
expect(url).toContain("success=");
expect(await getMaintenanceMode()).toBe(true);
});
it("disables maintenance mode", async () => {
await captureRedirect(() => updateMaintenanceModeAction(formDataFrom({ enabled: "true" })));
await captureRedirect(() => updateMaintenanceModeAction(formDataFrom({ enabled: "false" })));
expect(await getMaintenanceMode()).toBe(false);
});
it("redirects unauthenticated callers to the admin root", async () => {
adminAuth.authenticated = false;
const url = await captureRedirect(() => updateMaintenanceModeAction(formDataFrom({ enabled: "true" })));
expect(url).toBe("/");
// state unchanged
expect(await getMaintenanceMode()).toBe(false);
});
});
+51
View File
@@ -0,0 +1,51 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("next/cache", async () => ({ revalidatePath: (await import("@/tests/helpers/next-mocks")).revalidatePath }));
vi.mock("next/navigation", async () => ({ redirect: (await import("@/tests/helpers/next-mocks")).redirect }));
vi.mock("next/dist/client/components/redirect-error", async () => ({
isRedirectError: (await import("@/tests/helpers/next-mocks")).isRedirectError,
}));
vi.mock("@/lib/admin-auth", async () => {
const m = await import("@/tests/helpers/next-mocks");
return { isAdminAuthenticated: m.isAdminAuthenticated, clearAdminSessionCookie: m.clearAdminSessionCookie };
});
import { saveMarqueeSettingsAction } from "@/app/_admin/marquee/actions";
import { getMarqueeSettings } from "@/lib/app-config";
import { adminAuth, captureRedirect, formDataFrom, resetNextMocks } from "@/tests/helpers/next-mocks";
beforeEach(() => {
resetNextMocks();
});
const validRows = {
"row1-de": "A\nB",
"row2-de": "C\nD",
"row3-de": "E\nF",
"row4-de": "G\nH",
};
describe("saveMarqueeSettingsAction", () => {
it("saves german rows and mirrors them across locales", async () => {
const url = await captureRedirect(() => saveMarqueeSettingsAction(formDataFrom(validRows)));
expect(url).toContain("success=");
const settings = await getMarqueeSettings();
expect(settings.locales.de.row1).toBe("A\nB");
expect(settings.locales.en.row1).toBe("A\nB");
expect(settings.locales.ar.row4).toBe("G\nH");
});
it("redirects with an error when a required row is empty", async () => {
const url = await captureRedirect(() =>
saveMarqueeSettingsAction(formDataFrom({ ...validRows, "row2-de": " " })),
);
expect(url).toContain("error=");
});
it("redirects unauthenticated callers to the admin root", async () => {
adminAuth.authenticated = false;
const url = await captureRedirect(() => saveMarqueeSettingsAction(formDataFrom(validRows)));
expect(url).toBe("/");
});
});
+79
View File
@@ -0,0 +1,79 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("next/cache", async () => ({ revalidatePath: (await import("@/tests/helpers/next-mocks")).revalidatePath }));
vi.mock("next/navigation", async () => ({ redirect: (await import("@/tests/helpers/next-mocks")).redirect }));
vi.mock("next/dist/client/components/redirect-error", async () => ({
isRedirectError: (await import("@/tests/helpers/next-mocks")).isRedirectError,
}));
vi.mock("@/lib/admin-auth", async () => {
const m = await import("@/tests/helpers/next-mocks");
return { isAdminAuthenticated: m.isAdminAuthenticated, clearAdminSessionCookie: m.clearAdminSessionCookie };
});
import { eq } from "drizzle-orm";
import { createMediaAssetAction, deleteMediaAssetAction } from "@/app/_admin/media/actions";
import { db } from "@/lib/db";
import { mediaAsset } from "@/lib/db/schema";
import { removeManagedMediaFile } from "@/lib/media-storage";
import { createMediaAsset, createMediaUsage } from "@/tests/helpers/factories";
import { canManageUploads } from "@/tests/helpers/fs-capability";
import { adminAuth, captureRedirect, formDataFrom, resetNextMocks } from "@/tests/helpers/next-mocks";
beforeEach(() => {
resetNextMocks();
});
describe("createMediaAssetAction", () => {
it("errors when no file is provided", async () => {
const url = await captureRedirect(() => createMediaAssetAction(formDataFrom({ kind: "IMAGE", label: "L" })));
expect(url).toContain("error=");
expect(await db.$count(mediaAsset)).toBe(0);
});
it.skipIf(!canManageUploads)("creates an asset from an uploaded file", async () => {
const file = new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], "pic.png", { type: "image/png" });
const url = await captureRedirect(() =>
createMediaAssetAction(formDataFrom({ kind: "IMAGE", label: "Pic", file })),
);
expect(url).toContain("success=");
const assets = await db.select().from(mediaAsset);
expect(assets.length).toBe(1);
expect(assets[0].source).toBe("UPLOAD");
await removeManagedMediaFile(assets[0].url);
});
it("redirects unauthenticated callers to the admin root", async () => {
adminAuth.authenticated = false;
const url = await captureRedirect(() => createMediaAssetAction(formDataFrom({ kind: "IMAGE", label: "L" })));
expect(url).toBe("/");
});
});
describe("deleteMediaAssetAction", () => {
it("errors when the asset does not exist", async () => {
const url = await captureRedirect(() => deleteMediaAssetAction(formDataFrom({ assetId: "missing" })));
expect(url).toContain("error=");
});
it("refuses to delete an asset that is still in use", async () => {
const asset = await createMediaAsset();
await createMediaUsage(asset.id);
const url = await captureRedirect(() => deleteMediaAssetAction(formDataFrom({ assetId: asset.id })));
expect(url).toContain("error=");
expect((await db.query.mediaAsset.findFirst({ where: eq(mediaAsset.id, asset.id) })) ?? null).not.toBeNull();
});
it("deletes an unused external asset", async () => {
const asset = await createMediaAsset({ url: "https://cdn/external.png" });
const url = await captureRedirect(() => deleteMediaAssetAction(formDataFrom({ assetId: asset.id })));
expect(url).toContain("success=");
expect((await db.query.mediaAsset.findFirst({ where: eq(mediaAsset.id, asset.id) })) ?? null).toBeNull();
});
it("redirects unauthenticated callers to the admin root", async () => {
adminAuth.authenticated = false;
const url = await captureRedirect(() => deleteMediaAssetAction(formDataFrom({ assetId: "x" })));
expect(url).toBe("/");
});
});
+228
View File
@@ -0,0 +1,228 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("next/cache", async () => ({ revalidatePath: (await import("@/tests/helpers/next-mocks")).revalidatePath }));
vi.mock("next/navigation", async () => ({ redirect: (await import("@/tests/helpers/next-mocks")).redirect }));
vi.mock("next/dist/client/components/redirect-error", async () => ({
isRedirectError: (await import("@/tests/helpers/next-mocks")).isRedirectError,
}));
vi.mock("@/lib/admin-auth", async () => {
const m = await import("@/tests/helpers/next-mocks");
return { isAdminAuthenticated: m.isAdminAuthenticated, clearAdminSessionCookie: m.clearAdminSessionCookie };
});
import {
deleteCategoryAction,
deleteProjectAction,
saveProjectAction,
upsertCategoryAction,
} from "@/app/_admin/portfolio/actions";
import { and, eq } from "drizzle-orm";
import { db } from "@/lib/db";
import {
category as categoryTable,
mediaUsage as mediaUsageTable,
portfolioAsset as portfolioAssetTable,
portfolioProject as portfolioProjectTable,
} from "@/lib/db/schema";
import { createCategory, createProject } from "@/tests/helpers/factories";
import { adminAuth, captureRedirect, formDataFrom, resetNextMocks } from "@/tests/helpers/next-mocks";
beforeEach(() => {
resetNextMocks();
});
function categoryForm(overrides: Record<string, string> = {}) {
return formDataFrom({
slug: "branding",
nameAr: "الهوية",
nameEn: "Branding",
nameDe: "Branding",
descriptionAr: "وصف",
descriptionEn: "Description",
descriptionDe: "Beschreibung",
sortOrder: "1",
isActive: "on",
...overrides,
});
}
function projectForm(categoryId: string, overrides: Record<string, string> = {}) {
const assets = JSON.stringify([
{
kind: "IMAGE",
altAr: "ع",
altEn: "Alt",
altDe: "Alt",
sortOrder: 0,
media: { mode: "external", url: "https://cdn/asset.png", kind: "IMAGE", label: "Asset" },
},
]);
const coverMedia = JSON.stringify({ mode: "external", url: "https://cdn/cover.png", kind: "IMAGE", label: "Cover" });
return formDataFrom({
categoryId,
slug: "case-study",
viewMode: "GRID",
titleAr: "عنوان",
titleEn: "Title",
titleDe: "Titel",
summaryAr: "ملخص",
summaryEn: "Summary",
summaryDe: "Zusammenfassung",
clientName: "Client",
projectYear: "2025",
serviceLabelAr: "خدمة",
serviceLabelEn: "Service",
serviceLabelDe: "Service",
previewUrl: "https://example.com",
sortOrder: "0",
isFeatured: "",
isPublished: "on",
sections: "[]",
assets,
coverMedia,
...overrides,
});
}
describe("upsertCategoryAction", () => {
it("creates a category", async () => {
const url = await captureRedirect(() => upsertCategoryAction(categoryForm()));
expect(url).toContain("success=");
const category = await db.query.category.findFirst({ where: eq(categoryTable.slug, "branding") });
expect(category?.nameEn).toBe("Branding");
expect(category?.isActive).toBe(true);
});
it("updates an existing category", async () => {
const existing = await createCategory({ slug: "old", nameEn: "Old" });
const url = await captureRedirect(() =>
upsertCategoryAction(categoryForm({ id: existing.id, slug: "old", nameEn: "Renamed" })),
);
expect(url).toContain("success=");
const category = await db.query.category.findFirst({ where: eq(categoryTable.id, existing.id) });
expect(category?.nameEn).toBe("Renamed");
});
it("reports a unique-constraint violation on duplicate slugs", async () => {
await createCategory({ slug: "branding" });
const url = await captureRedirect(() => upsertCategoryAction(categoryForm({ slug: "branding" })));
expect(url).toContain("error=");
expect(decodeURIComponent(url)).toContain("eindeutig");
});
it("reports validation errors for an invalid slug", async () => {
const url = await captureRedirect(() => upsertCategoryAction(categoryForm({ slug: "Not Valid" })));
expect(url).toContain("error=");
});
it("redirects unauthenticated callers to the admin root", async () => {
adminAuth.authenticated = false;
const url = await captureRedirect(() => upsertCategoryAction(categoryForm()));
expect(url).toBe("/");
});
});
describe("deleteCategoryAction", () => {
it("refuses to delete a category that has projects", async () => {
const category = await createCategory();
await createProject({ categoryId: category.id });
const url = await captureRedirect(() => deleteCategoryAction(formDataFrom({ id: category.id })));
expect(url).toContain("error=");
expect((await db.query.category.findFirst({ where: eq(categoryTable.id, category.id) })) ?? null).not.toBeNull();
});
it("deletes an empty category", async () => {
const category = await createCategory();
const url = await captureRedirect(() => deleteCategoryAction(formDataFrom({ id: category.id })));
expect(url).toContain("success=");
expect((await db.query.category.findFirst({ where: eq(categoryTable.id, category.id) })) ?? null).toBeNull();
});
});
describe("saveProjectAction", () => {
it("creates a published project with cover and asset media usages", async () => {
const category = await createCategory();
const url = await captureRedirect(() => saveProjectAction(projectForm(category.id)));
expect(url).toContain("success=");
const project = await db.query.portfolioProject.findFirst({ where: eq(portfolioProjectTable.slug, "case-study") });
expect(project).not.toBeNull();
expect(project?.isPublished).toBe(true);
expect(project?.publishedAt).not.toBeNull();
expect(project?.coverImagePath).toBe("https://cdn/cover.png");
expect(await db.$count(portfolioAssetTable, eq(portfolioAssetTable.projectId, project!.id))).toBe(1);
const usages = await db.select().from(mediaUsageTable).where(and(eq(mediaUsageTable.entityType, "portfolio-project"), eq(mediaUsageTable.entityId, project!.id)));
const usageTypes = usages.map((u) => u.usageType).sort();
expect(usageTypes).toEqual(["PORTFOLIO_ASSET", "PORTFOLIO_COVER"]);
});
it("updates an existing project and replaces its assets", async () => {
const category = await createCategory();
const created = await captureRedirect(() => saveProjectAction(projectForm(category.id)));
void created;
const project = await db.query.portfolioProject.findFirst({ where: eq(portfolioProjectTable.slug, "case-study") });
const url = await captureRedirect(() =>
saveProjectAction(projectForm(category.id, { id: project!.id, titleEn: "Updated Title" })),
);
expect(url).toContain("success=");
const updated = await db.query.portfolioProject.findFirst({ where: eq(portfolioProjectTable.id, project!.id) });
expect(updated?.titleEn).toBe("Updated Title");
// assets are replaced, not duplicated
expect(await db.$count(portfolioAssetTable, eq(portfolioAssetTable.projectId, project!.id))).toBe(1);
});
it("keeps the original publishedAt when re-saving an already published project", async () => {
const category = await createCategory();
await captureRedirect(() => saveProjectAction(projectForm(category.id)));
const first = await db.query.portfolioProject.findFirst({ where: eq(portfolioProjectTable.slug, "case-study") });
const originalPublishedAt = first!.publishedAt;
await captureRedirect(() => saveProjectAction(projectForm(category.id, { id: first!.id })));
const second = await db.query.portfolioProject.findFirst({ where: eq(portfolioProjectTable.id, first!.id) });
expect(second?.publishedAt?.toISOString()).toBe(originalPublishedAt?.toISOString());
});
it("reports validation errors and creates nothing", async () => {
const category = await createCategory();
const url = await captureRedirect(() => saveProjectAction(projectForm(category.id, { titleEn: "" })));
expect(url).toContain("error=");
expect(await db.$count(portfolioProjectTable)).toBe(0);
});
it("reports a unique-constraint violation on duplicate slugs", async () => {
const category = await createCategory();
await createProject({ categoryId: category.id, slug: "case-study" });
const url = await captureRedirect(() => saveProjectAction(projectForm(category.id)));
expect(url).toContain("error=");
expect(decodeURIComponent(url)).toContain("eindeutig");
});
it("redirects unauthenticated callers to the admin root", async () => {
adminAuth.authenticated = false;
const url = await captureRedirect(() => saveProjectAction(projectForm("cat")));
expect(url).toBe("/");
});
});
describe("deleteProjectAction", () => {
it("deletes a project and its media usages", async () => {
const project = await createProject();
const url = await captureRedirect(() => deleteProjectAction(formDataFrom({ id: project.id })));
expect(url).toContain("success=");
expect((await db.query.portfolioProject.findFirst({ where: eq(portfolioProjectTable.id, project.id) })) ?? null).toBeNull();
});
it("errors when the project does not exist", async () => {
const url = await captureRedirect(() => deleteProjectAction(formDataFrom({ id: "missing" })));
expect(url).toContain("error=");
});
it("redirects unauthenticated callers to the admin root", async () => {
adminAuth.authenticated = false;
const url = await captureRedirect(() => deleteProjectAction(formDataFrom({ id: "x" })));
expect(url).toBe("/");
});
});
@@ -0,0 +1,104 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("next/cache", async () => ({ revalidatePath: (await import("@/tests/helpers/next-mocks")).revalidatePath }));
vi.mock("next/navigation", async () => ({ redirect: (await import("@/tests/helpers/next-mocks")).redirect }));
vi.mock("next/dist/client/components/redirect-error", async () => ({
isRedirectError: (await import("@/tests/helpers/next-mocks")).isRedirectError,
}));
vi.mock("@/lib/admin-auth", async () => {
const m = await import("@/tests/helpers/next-mocks");
return { isAdminAuthenticated: m.isAdminAuthenticated, clearAdminSessionCookie: m.clearAdminSessionCookie };
});
import {
saveSiteBrandSettingsAction,
saveSiteLocalizationSettingsAction,
} from "@/app/_admin/site-settings/actions";
import { getSiteSettings, getSiteSettingsMediaBindings } from "@/lib/app-config";
import { adminAuth, captureRedirect, formDataFrom, resetNextMocks } from "@/tests/helpers/next-mocks";
beforeEach(() => {
resetNextMocks();
});
describe("saveSiteBrandSettingsAction", () => {
it("saves a normalized primary color", async () => {
const url = await captureRedirect(() => saveSiteBrandSettingsAction(formDataFrom({ primaryColor: "#123456" })));
expect(url).toContain("success=");
expect((await getSiteSettings()).brand.primaryColor).toBe("#123456");
});
it("falls back to the default color for invalid input", async () => {
await captureRedirect(() => saveSiteBrandSettingsAction(formDataFrom({ primaryColor: "not-a-color" })));
expect((await getSiteSettings()).brand.primaryColor).toBe("#dc5a35");
});
it("wires an external favicon into media bindings", async () => {
const faviconMedia = JSON.stringify({ mode: "external", url: "https://cdn/f.svg", kind: "IMAGE", label: "F" });
const url = await captureRedirect(() =>
saveSiteBrandSettingsAction(formDataFrom({ primaryColor: "#222222", faviconMedia })),
);
expect(url).toContain("success=");
const bindings = await getSiteSettingsMediaBindings();
expect(bindings.favicon?.url).toBe("https://cdn/f.svg");
});
it("errors on an invalid media json payload", async () => {
const url = await captureRedirect(() =>
saveSiteBrandSettingsAction(formDataFrom({ primaryColor: "#222222", faviconMedia: "{not json" })),
);
expect(url).toContain("error=");
});
it("redirects unauthenticated callers to the admin root", async () => {
adminAuth.authenticated = false;
const url = await captureRedirect(() => saveSiteBrandSettingsAction(formDataFrom({ primaryColor: "#123456" })));
expect(url).toBe("/");
});
});
const localizationForm = {
defaultLocale: "en",
siteNameAr: "الموقع",
siteNameEn: "The Site",
siteNameDe: "Die Seite",
titleTemplateAr: "{pageTitle} | {siteName}",
titleTemplateEn: "{pageTitle} | {siteName}",
titleTemplateDe: "{pageTitle} | {siteName}",
siteDescriptionAr: "وصف",
siteDescriptionEn: "Description",
siteDescriptionDe: "Beschreibung",
subheadAr: "",
subheadEn: "",
subheadDe: "",
};
describe("saveSiteLocalizationSettingsAction", () => {
it("saves valid localization settings and the default locale", async () => {
const url = await captureRedirect(() => saveSiteLocalizationSettingsAction(formDataFrom(localizationForm)));
expect(url).toContain("success=");
const settings = await getSiteSettings();
expect(settings.defaultLocale).toBe("en");
expect(settings.locales.en.siteName).toBe("The Site");
});
it("requires a site name for every locale", async () => {
const url = await captureRedirect(() =>
saveSiteLocalizationSettingsAction(formDataFrom({ ...localizationForm, siteNameEn: "" })),
);
expect(url).toContain("error=");
});
it("requires the {pageTitle} token in every title template", async () => {
const url = await captureRedirect(() =>
saveSiteLocalizationSettingsAction(formDataFrom({ ...localizationForm, titleTemplateDe: "{siteName} only" })),
);
expect(url).toContain("error=");
});
it("redirects unauthenticated callers to the admin root", async () => {
adminAuth.authenticated = false;
const url = await captureRedirect(() => saveSiteLocalizationSettingsAction(formDataFrom(localizationForm)));
expect(url).toBe("/");
});
});
+91
View File
@@ -0,0 +1,91 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("next/cache", async () => ({ revalidatePath: (await import("@/tests/helpers/next-mocks")).revalidatePath }));
vi.mock("next/navigation", async () => ({ redirect: (await import("@/tests/helpers/next-mocks")).redirect }));
vi.mock("next/dist/client/components/redirect-error", async () => ({
isRedirectError: (await import("@/tests/helpers/next-mocks")).isRedirectError,
}));
vi.mock("@/lib/admin-auth", async () => {
const m = await import("@/tests/helpers/next-mocks");
return { isAdminAuthenticated: m.isAdminAuthenticated, clearAdminSessionCookie: m.clearAdminSessionCookie };
});
const { sendTestEmail } = vi.hoisted(() => ({ sendTestEmail: vi.fn(async () => {}) }));
vi.mock("@/lib/mail", () => ({ sendTestEmail }));
import { saveMailSettingsAction, sendTestEmailAction } from "@/app/_admin/smtp/actions";
import { getMailSettings } from "@/lib/app-config";
import { adminAuth, captureRedirect, formDataFrom, resetNextMocks } from "@/tests/helpers/next-mocks";
beforeEach(() => {
resetNextMocks();
sendTestEmail.mockClear();
});
const validForm = {
smtpHost: "smtp.example.com",
smtpPort: "465",
smtpUsername: "mailer",
smtpPassword: "secret",
smtpSecure: "on",
mailFromEmail: "from@example.com",
mailFromName: "Studio",
mailContactRecipient: "contact@example.com",
mailTestRecipient: "test@example.com",
};
describe("saveMailSettingsAction", () => {
it("persists valid settings and redirects with success", async () => {
const url = await captureRedirect(() => saveMailSettingsAction(formDataFrom(validForm)));
expect(url).toContain("success=");
const settings = await getMailSettings();
expect(settings.smtp.host).toBe("smtp.example.com");
expect(settings.smtp.port).toBe(465);
expect(settings.smtp.secure).toBe(true);
expect(settings.recipients.contact).toBe("contact@example.com");
});
it("rejects an invalid port with an error flash", async () => {
const url = await captureRedirect(() =>
saveMailSettingsAction(formDataFrom({ ...validForm, smtpPort: "not-a-number" })),
);
expect(url).toContain("error=");
});
it("retains the existing password when the field is left blank", async () => {
await captureRedirect(() => saveMailSettingsAction(formDataFrom(validForm)));
await captureRedirect(() =>
saveMailSettingsAction(formDataFrom({ ...validForm, smtpPassword: "" })),
);
const settings = await getMailSettings();
expect(settings.smtp.password).toBe("secret");
});
it("redirects unauthenticated callers to the admin root", async () => {
adminAuth.authenticated = false;
const url = await captureRedirect(() => saveMailSettingsAction(formDataFrom(validForm)));
expect(url).toBe("/");
});
});
describe("sendTestEmailAction", () => {
it("sends a test email and redirects with success", async () => {
const url = await captureRedirect(() => sendTestEmailAction());
expect(sendTestEmail).toHaveBeenCalledTimes(1);
expect(url).toContain("success=");
});
it("redirects with an error when sending fails", async () => {
sendTestEmail.mockRejectedValueOnce(new Error("SMTP host is required."));
const url = await captureRedirect(() => sendTestEmailAction());
expect(url).toContain("error=");
});
it("redirects unauthenticated callers to the admin root", async () => {
adminAuth.authenticated = false;
const url = await captureRedirect(() => sendTestEmailAction());
expect(url).toBe("/");
expect(sendTestEmail).not.toHaveBeenCalled();
});
});
+81
View File
@@ -0,0 +1,81 @@
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("next/headers", () => ({
headers: async () => new Headers({ "x-forwarded-for": "203.0.113.7" }),
cookies: async () => ({ get: () => undefined, set: () => {}, delete: () => {} }),
}));
import {
getAdminLockState,
isAdminAuthConfigured,
isPasswordValid,
registerFailedAdminAttempt,
resetAdminFailedAttempts,
} from "@/lib/admin-auth";
afterEach(() => {
vi.unstubAllEnvs();
});
describe("admin auth configuration", () => {
it("is configured only when both password and secret are set", () => {
vi.stubEnv("ADMIN_PASSWORD", "");
vi.stubEnv("ADMIN_AUTH_SECRET", "");
expect(isAdminAuthConfigured()).toBe(false);
vi.stubEnv("ADMIN_PASSWORD", "pw");
vi.stubEnv("ADMIN_AUTH_SECRET", "");
expect(isAdminAuthConfigured()).toBe(false);
vi.stubEnv("ADMIN_PASSWORD", "pw");
vi.stubEnv("ADMIN_AUTH_SECRET", "secret");
expect(isAdminAuthConfigured()).toBe(true);
});
});
describe("isPasswordValid", () => {
it("returns false when auth is not configured", () => {
vi.stubEnv("ADMIN_PASSWORD", "");
vi.stubEnv("ADMIN_AUTH_SECRET", "");
expect(isPasswordValid("anything")).toBe(false);
});
it("accepts the correct password and rejects wrong ones", () => {
vi.stubEnv("ADMIN_PASSWORD", "s3cret-password");
vi.stubEnv("ADMIN_AUTH_SECRET", "hmac-secret");
expect(isPasswordValid("s3cret-password")).toBe(true);
expect(isPasswordValid("wrong")).toBe(false);
expect(isPasswordValid("s3cret-passwordX")).toBe(false); // length mismatch
});
});
describe("login lockout", () => {
it("locks the IP after the failed-attempt threshold", async () => {
expect((await getAdminLockState()).locked).toBe(false);
for (let i = 0; i < 4; i += 1) {
const state = await registerFailedAdminAttempt();
expect(state.locked).toBe(false);
}
expect((await getAdminLockState()).locked).toBe(false);
const fifth = await registerFailedAdminAttempt();
expect(fifth.locked).toBe(true);
expect(fifth.remainingSeconds).toBeGreaterThan(0);
const lockState = await getAdminLockState();
expect(lockState.locked).toBe(true);
expect(lockState.remainingSeconds).toBeGreaterThan(0);
expect(lockState.remainingSeconds).toBeLessThanOrEqual(15 * 60);
});
it("clears the lock on reset", async () => {
for (let i = 0; i < 5; i += 1) {
await registerFailedAdminAttempt();
}
expect((await getAdminLockState()).locked).toBe(true);
await resetAdminFailedAttempts();
expect((await getAdminLockState()).locked).toBe(false);
});
});
+52
View File
@@ -0,0 +1,52 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { GET as healthGet } from "@/app/api/health/route";
import { GET as defaultLocaleGet } from "@/app/api/site/default-locale/route";
import { setMaintenanceMode, updateSiteSettings, getSiteSettings } from "@/lib/app-config";
import { db } from "@/lib/db";
afterEach(() => {
vi.restoreAllMocks();
});
describe("GET /api/health", () => {
it("reports ok when the database responds", async () => {
const response = await healthGet();
expect(response.status).toBe(200);
const body = await response.json();
expect(body.status).toBe("ok");
expect(body.checks.database).toBe("up");
expect(typeof body.timestamp).toBe("string");
});
it("reports degraded (503) when the database query throws", async () => {
vi.spyOn(db, "execute").mockRejectedValueOnce(new Error("db down"));
const response = await healthGet();
expect(response.status).toBe(503);
const body = await response.json();
expect(body.status).toBe("degraded");
expect(body.checks.database).toBe("down");
});
});
describe("GET /api/site/default-locale", () => {
it("returns the runtime default locale and maintenance flag with no-store", async () => {
const response = await defaultLocaleGet();
expect(response.headers.get("Cache-Control")).toBe("no-store, max-age=0");
const body = await response.json();
expect(body.defaultLocale).toBe("de");
expect(body.maintenanceEnabled).toBe(false);
});
it("reflects updated settings and maintenance state", async () => {
const settings = await getSiteSettings();
settings.defaultLocale = "ar";
await updateSiteSettings(settings);
await setMaintenanceMode(true);
const response = await defaultLocaleGet();
const body = await response.json();
expect(body.defaultLocale).toBe("ar");
expect(body.maintenanceEnabled).toBe(true);
});
});
+141
View File
@@ -0,0 +1,141 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_SITE_NAME,
MAINTENANCE_MODE_KEY,
SITE_NAME_KEY,
SITE_SETTINGS_ENTITY_ID,
SITE_SETTINGS_ENTITY_TYPE,
SITE_SETTINGS_FAVICON_FIELD_KEY,
SITE_SETTINGS_LOGO_LIGHT_FIELD_KEY,
buildDefaultMailSettings,
buildDefaultMarqueeSettings,
getMailSettings,
getMaintenanceMode,
getMarqueeSettings,
getSiteSettings,
getSiteSettingsMediaBindings,
setMaintenanceMode,
updateMailSettings,
updateMarqueeSettings,
updateSiteSettings,
} from "@/lib/app-config";
import { eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { appConfig, mediaUsage } from "@/lib/db/schema";
import { createMediaAsset } from "@/tests/helpers/factories";
describe("maintenance mode", () => {
it("defaults to false when unset", async () => {
expect(await getMaintenanceMode()).toBe(false);
});
it("persists and reads back the enabled flag", async () => {
await setMaintenanceMode(true);
expect(await getMaintenanceMode()).toBe(true);
const row = await db.query.appConfig.findFirst({ where: eq(appConfig.key, MAINTENANCE_MODE_KEY) });
expect(row?.value).toBe("true");
await setMaintenanceMode(false);
expect(await getMaintenanceMode()).toBe(false);
});
});
describe("site settings", () => {
it("returns defaults (with fallback name) when nothing stored", async () => {
const settings = await getSiteSettings();
expect(settings.defaultLocale).toBe("de");
expect(settings.locales.en.siteName).toBe(DEFAULT_SITE_NAME);
});
it("uses the stored siteName key as the fallback name", async () => {
await db.insert(appConfig).values({ key: SITE_NAME_KEY, value: "My Studio" });
const settings = await getSiteSettings();
expect(settings.locales.ar.siteName).toBe("My Studio");
});
it("round-trips an updated settings object", async () => {
const next = await getSiteSettings();
next.defaultLocale = "ar";
next.brand.primaryColor = "#123456";
next.locales.en.siteName = "Updated EN";
await updateSiteSettings(next);
const reloaded = await getSiteSettings();
expect(reloaded.defaultLocale).toBe("ar");
expect(reloaded.brand.primaryColor).toBe("#123456");
expect(reloaded.locales.en.siteName).toBe("Updated EN");
});
});
describe("mail settings", () => {
it("returns defaults when unset", async () => {
expect(await getMailSettings()).toEqual(buildDefaultMailSettings());
});
it("round-trips stored mail settings", async () => {
const next = buildDefaultMailSettings();
next.smtp.host = "smtp.test";
next.smtp.port = 465;
next.sender.email = "from@test";
next.recipients.contact = "c@test";
await updateMailSettings(next);
const reloaded = await getMailSettings();
expect(reloaded.smtp.host).toBe("smtp.test");
expect(reloaded.smtp.port).toBe(465);
expect(reloaded.recipients.contact).toBe("c@test");
});
});
describe("marquee settings", () => {
it("returns defaults when unset", async () => {
const settings = await getMarqueeSettings();
expect(settings.locales.de.row1).toBe(buildDefaultMarqueeSettings().locales.de.row1);
});
it("stores german-synced values", async () => {
const next = buildDefaultMarqueeSettings();
next.locales.de.row1 = "GERMAN ROW";
next.locales.en.row1 = "will be overwritten";
await updateMarqueeSettings(next);
const reloaded = await getMarqueeSettings();
expect(reloaded.locales.de.row1).toBe("GERMAN ROW");
expect(reloaded.locales.en.row1).toBe("GERMAN ROW");
expect(reloaded.locales.ar.row1).toBe("GERMAN ROW");
});
});
describe("getSiteSettingsMediaBindings", () => {
it("returns nulls when there are no usages", async () => {
const bindings = await getSiteSettingsMediaBindings();
expect(bindings).toEqual({ siteLogoLight: null, siteLogoDark: null, favicon: null, defaultOgImage: null });
});
it("maps media usages to their field bindings", async () => {
const logo = await createMediaAsset({ url: "https://cdn/logo.png" });
const favicon = await createMediaAsset({ url: "https://cdn/favicon.svg" });
await db.insert(mediaUsage).values({
assetId: logo.id,
usageType: "GENERIC",
entityType: SITE_SETTINGS_ENTITY_TYPE,
entityId: SITE_SETTINGS_ENTITY_ID,
fieldKey: SITE_SETTINGS_LOGO_LIGHT_FIELD_KEY,
});
await db.insert(mediaUsage).values({
assetId: favicon.id,
usageType: "GENERIC",
entityType: SITE_SETTINGS_ENTITY_TYPE,
entityId: SITE_SETTINGS_ENTITY_ID,
fieldKey: SITE_SETTINGS_FAVICON_FIELD_KEY,
});
const bindings = await getSiteSettingsMediaBindings();
expect(bindings.siteLogoLight?.assetId).toBe(logo.id);
expect(bindings.siteLogoLight?.url).toBe("https://cdn/logo.png");
expect(bindings.favicon?.assetId).toBe(favicon.id);
expect(bindings.favicon?.version).toMatch(/\d{4}-\d{2}-\d{2}T/); // updatedAt ISO string
expect(bindings.siteLogoDark).toBeNull();
});
});
+123
View File
@@ -0,0 +1,123 @@
import { readFile } from "fs/promises";
import { describe, expect, it } from "vitest";
import { eq } from "drizzle-orm";
import { resolveMediaSelection } from "@/lib/media-service";
import { resolveMediaUploadPath } from "@/lib/media-storage";
import { db } from "@/lib/db";
import { mediaAsset } from "@/lib/db/schema";
import { createMediaAsset } from "@/tests/helpers/factories";
import { canManageUploads } from "@/tests/helpers/fs-capability";
describe("resolveMediaSelection — library mode", () => {
it("returns the referenced asset", async () => {
const asset = await createMediaAsset({ url: "https://cdn/lib.png" });
const result = await resolveMediaSelection({
media: { mode: "library", assetId: asset.id, url: "", label: "", kind: "IMAGE" },
uploadFile: null,
folder: "covers",
fallbackLabel: "Cover",
required: false,
});
expect(result.assetId).toBe(asset.id);
expect(result.url).toBe("https://cdn/lib.png");
});
it("throws when the referenced asset is missing", async () => {
await expect(
resolveMediaSelection({
media: { mode: "library", assetId: "nope", url: "", label: "", kind: "IMAGE" },
uploadFile: null,
folder: "covers",
fallbackLabel: "Cover",
required: true,
}),
).rejects.toThrow(/not found/i);
});
});
describe("resolveMediaSelection — external mode", () => {
it("creates a new external asset from the url", async () => {
const result = await resolveMediaSelection({
media: { mode: "external", assetId: "", url: "https://cdn/new/photo.png", label: "Photo", kind: "IMAGE" },
uploadFile: null,
folder: "covers",
fallbackLabel: "Cover",
required: true,
});
expect(result.createdAssetId).toBeTruthy();
expect(result.url).toBe("https://cdn/new/photo.png");
const stored = await db.query.mediaAsset.findFirst({ where: eq(mediaAsset.id, result.assetId!) });
expect(stored?.source).toBe("EXTERNAL");
expect(stored?.fileName).toBe("photo.png");
expect(stored?.label).toBe("Photo");
});
it("returns empty selection for a not-required empty url", async () => {
const result = await resolveMediaSelection({
media: { mode: "external", assetId: "", url: "", label: "", kind: "IMAGE" },
uploadFile: null,
folder: "covers",
fallbackLabel: "Cover",
required: false,
});
expect(result.assetId).toBeNull();
expect(result.url).toBe("");
});
});
describe("resolveMediaSelection — missing configuration", () => {
it("throws when required and no media object is present", async () => {
await expect(
resolveMediaSelection({ media: undefined, uploadFile: null, folder: "covers", fallbackLabel: "L", required: true }),
).rejects.toThrow(/missing/i);
});
it("returns empty selection when not required and no media object is present", async () => {
const result = await resolveMediaSelection({
media: undefined,
uploadFile: null,
folder: "covers",
fallbackLabel: "L",
required: false,
});
expect(result.assetId).toBeNull();
});
it("throws for a required upload with no file", async () => {
await expect(
resolveMediaSelection({
media: { mode: "upload", assetId: "", url: "", label: "", kind: "IMAGE" },
uploadFile: null,
folder: "covers",
fallbackLabel: "L",
required: true,
}),
).rejects.toThrow(/required/i);
});
});
describe("resolveMediaSelection — upload mode (filesystem)", () => {
it.skipIf(!canManageUploads)("saves the file and creates an UPLOAD asset", async () => {
const file = new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], "shot.png", { type: "image/png" });
const result = await resolveMediaSelection({
media: { mode: "upload", assetId: "", url: "", label: "Shot", kind: "IMAGE" },
uploadFile: file,
folder: "tests",
fallbackLabel: "L",
required: true,
});
expect(result.uploadedUrl).toBeTruthy();
const stored = await db.query.mediaAsset.findFirst({ where: eq(mediaAsset.id, result.assetId!) });
expect(stored?.source).toBe("UPLOAD");
// File actually written to disk
const bytes = await readFile(resolveMediaUploadPath(result.url));
expect(bytes.length).toBeGreaterThan(0);
// cleanup
const { removeManagedMediaFile } = await import("@/lib/media-storage");
await removeManagedMediaFile(result.url);
});
});
+134
View File
@@ -0,0 +1,134 @@
import { describe, expect, it } from "vitest";
import {
countMediaUsageReferences,
createMediaAsset,
deleteEntityMediaUsages,
getAdminMediaAssets,
getMediaAssetById,
getMediaOptions,
getPortfolioMediaBindings,
replaceEntityMediaUsages,
} from "@/lib/media";
import { db } from "@/lib/db";
import { mediaUsage } from "@/lib/db/schema";
import { createMediaAsset as seedAsset } from "@/tests/helpers/factories";
describe("createMediaAsset / getMediaAssetById", () => {
it("creates and reads back an asset with usages", async () => {
const created = await createMediaAsset({
source: "EXTERNAL",
kind: "IMAGE",
url: "https://cdn/x.png",
fileName: "x.png",
label: "X",
});
const found = await getMediaAssetById(created.id);
expect(found?.url).toBe("https://cdn/x.png");
expect(found?.usages).toEqual([]);
});
it("returns null for a missing asset", async () => {
expect(await getMediaAssetById("nope")).toBeNull();
});
});
describe("getMediaOptions", () => {
it("filters by kind", async () => {
await seedAsset({ kind: "IMAGE" });
await seedAsset({ kind: "DOCUMENT" });
const images = await getMediaOptions({ kind: "IMAGE" });
expect(images.every((a) => a.kind === "IMAGE")).toBe(true);
const all = await getMediaOptions();
expect(all.length).toBe(2);
});
});
describe("getAdminMediaAssets", () => {
it("returns newest first with usage details", async () => {
const a = await seedAsset();
await createMediaUsageFor(a.id);
const list = await getAdminMediaAssets();
expect(list.length).toBe(1);
expect(list[0].usages.length).toBe(1);
});
});
describe("replaceEntityMediaUsages", () => {
it("replaces existing usages transactionally", async () => {
const a1 = await seedAsset();
const a2 = await seedAsset();
await replaceEntityMediaUsages({
entityType: "portfolio-project",
entityId: "p1",
usages: [{ assetId: a1.id, usageType: "PORTFOLIO_COVER", fieldKey: "cover" }],
});
expect(await countMediaUsageReferences(a1.id)).toBe(1);
await replaceEntityMediaUsages({
entityType: "portfolio-project",
entityId: "p1",
usages: [{ assetId: a2.id, usageType: "PORTFOLIO_COVER", fieldKey: "cover" }],
});
expect(await countMediaUsageReferences(a1.id)).toBe(0);
expect(await countMediaUsageReferences(a2.id)).toBe(1);
});
it("clears usages when given an empty list", async () => {
const a1 = await seedAsset();
await replaceEntityMediaUsages({
entityType: "portfolio-project",
entityId: "p2",
usages: [{ assetId: a1.id, usageType: "PORTFOLIO_ASSET", fieldKey: "a" }],
});
await replaceEntityMediaUsages({ entityType: "portfolio-project", entityId: "p2", usages: [] });
expect(await countMediaUsageReferences(a1.id)).toBe(0);
});
});
describe("deleteEntityMediaUsages", () => {
it("removes only the target entity's usages", async () => {
const a1 = await seedAsset();
await replaceEntityMediaUsages({
entityType: "portfolio-project",
entityId: "keep",
usages: [{ assetId: a1.id, usageType: "PORTFOLIO_ASSET", fieldKey: "a" }],
});
await replaceEntityMediaUsages({
entityType: "portfolio-project",
entityId: "drop",
usages: [{ assetId: a1.id, usageType: "PORTFOLIO_ASSET", fieldKey: "b" }],
});
await deleteEntityMediaUsages("portfolio-project", "drop");
expect(await countMediaUsageReferences(a1.id)).toBe(1);
});
});
describe("getPortfolioMediaBindings", () => {
it("routes usages into cover / section / asset buckets", async () => {
const cover = await seedAsset();
const section = await seedAsset();
const asset = await seedAsset();
await db.insert(mediaUsage).values([
{ assetId: cover.id, usageType: "PORTFOLIO_COVER", entityType: "portfolio-project", entityId: "proj", fieldKey: "cover" },
{ assetId: section.id, usageType: "PORTFOLIO_SECTION", entityType: "portfolio-project", entityId: "proj", fieldKey: "sec_1" },
{ assetId: asset.id, usageType: "PORTFOLIO_ASSET", entityType: "portfolio-project", entityId: "proj", fieldKey: "ast_1" },
]);
const bindings = await getPortfolioMediaBindings("proj");
expect(bindings.coverAssetId).toBe(cover.id);
expect(bindings.sectionAssetIds.sec_1).toBe(section.id);
expect(bindings.assetIds.ast_1).toBe(asset.id);
});
it("returns empty bindings for an unknown project", async () => {
const bindings = await getPortfolioMediaBindings("missing");
expect(bindings).toEqual({ coverAssetId: null, sectionAssetIds: {}, assetIds: {} });
});
});
async function createMediaUsageFor(assetId: string) {
await db.insert(mediaUsage).values({ assetId, usageType: "GENERIC", entityType: "e", entityId: "1", fieldKey: "f" });
}
+142
View File
@@ -0,0 +1,142 @@
import { describe, expect, it } from "vitest";
import {
getActivePortfolioCategories,
getActivePortfolioCategoryBySlug,
getAdminPortfolioCategories,
getAdminPortfolioProjectById,
getAdminPortfolioProjects,
getPublishedPortfolioProjectBySlug,
getPublishedPortfolioProjects,
} from "@/lib/portfolio";
import { eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { category, mediaUsage, portfolioAsset, portfolioProject, portfolioSection } from "@/lib/db/schema";
import {
createAsset,
createCategory,
createMediaAsset,
createProject,
createSection,
} from "@/tests/helpers/factories";
describe("categories", () => {
it("lists admin categories with project counts, ordered", async () => {
const a = await createCategory({ slug: "a", sortOrder: 2 });
await createCategory({ slug: "b", sortOrder: 1 });
await createProject({ categoryId: a.id });
const categories = await getAdminPortfolioCategories();
expect(categories.map((c) => c.slug)).toEqual(["b", "a"]); // sortOrder asc
expect(categories.find((c) => c.slug === "a")?.projectCount).toBe(1);
expect(categories.find((c) => c.slug === "b")?.projectCount).toBe(0);
});
it("returns only active categories publicly", async () => {
await createCategory({ slug: "on", isActive: true });
await createCategory({ slug: "off", isActive: false });
const active = await getActivePortfolioCategories();
expect(active.map((c) => c.slug)).toEqual(["on"]);
});
it("finds an active category by slug and ignores inactive ones", async () => {
await createCategory({ slug: "visible", isActive: true });
await createCategory({ slug: "hidden", isActive: false });
expect((await getActivePortfolioCategoryBySlug("visible"))?.slug).toBe("visible");
expect(await getActivePortfolioCategoryBySlug("hidden")).toBeNull();
});
});
describe("admin projects", () => {
it("filters by status and category", async () => {
const cat = await createCategory();
await createProject({ categoryId: cat.id, slug: "pub", isPublished: true });
await createProject({ categoryId: cat.id, slug: "draft", isPublished: false });
const published = await getAdminPortfolioProjects({ status: "published" });
expect(published.map((p) => p.slug)).toEqual(["pub"]);
const drafts = await getAdminPortfolioProjects({ status: "draft" });
expect(drafts.map((p) => p.slug)).toEqual(["draft"]);
const byCategory = await getAdminPortfolioProjects({ categoryId: cat.id });
expect(byCategory.length).toBe(2);
});
it("maps localized content and nested sections/assets", async () => {
const project = await createProject({ slug: "mapped" });
await createSection(project.id, { titleEn: "Intro" });
await createAsset(project.id, { altEn: "Cover" });
const detail = await getAdminPortfolioProjectById(project.id);
expect(detail?.title.en).toBe("Title");
expect(detail?.sections[0].title.en).toBe("Intro");
expect(detail?.assets[0].alt.en).toBe("Cover");
});
it("attaches media bindings to a project fetched by id", async () => {
const project = await createProject();
const cover = await createMediaAsset();
await db.insert(mediaUsage).values({
assetId: cover.id,
usageType: "PORTFOLIO_COVER",
entityType: "portfolio-project",
entityId: project.id,
fieldKey: "cover",
});
const detail = await getAdminPortfolioProjectById(project.id);
expect(detail?.coverMediaAssetId).toBe(cover.id);
});
it("returns null for a missing project id", async () => {
expect(await getAdminPortfolioProjectById("missing")).toBeNull();
});
});
describe("published projects", () => {
it("returns only published projects in active categories", async () => {
const activeCat = await createCategory({ isActive: true });
const inactiveCat = await createCategory({ isActive: false });
await createProject({ categoryId: activeCat.id, slug: "shown", isPublished: true });
await createProject({ categoryId: activeCat.id, slug: "hidden-draft", isPublished: false });
await createProject({ categoryId: inactiveCat.id, slug: "hidden-cat", isPublished: true });
const projects = await getPublishedPortfolioProjects();
expect(projects.map((p) => p.slug)).toEqual(["shown"]);
});
it("filters published projects by category slug", async () => {
const catA = await createCategory({ slug: "cat-a", isActive: true });
const catB = await createCategory({ slug: "cat-b", isActive: true });
await createProject({ categoryId: catA.id, slug: "in-a", isPublished: true });
await createProject({ categoryId: catB.id, slug: "in-b", isPublished: true });
const projects = await getPublishedPortfolioProjects({ categorySlug: "cat-a" });
expect(projects.map((p) => p.slug)).toEqual(["in-a"]);
});
it("finds a published project by slug and hides drafts", async () => {
await createProject({ slug: "live", isPublished: true });
await createProject({ slug: "wip", isPublished: false });
expect((await getPublishedPortfolioProjectBySlug("live"))?.slug).toBe("live");
expect(await getPublishedPortfolioProjectBySlug("wip")).toBeNull();
});
});
describe("referential integrity", () => {
it("restricts deleting a category that still has projects", async () => {
const cat = await createCategory();
await createProject({ categoryId: cat.id });
await expect(db.delete(category).where(eq(category.id, cat.id))).rejects.toThrow();
});
it("cascades section and asset deletion when a project is removed", async () => {
const project = await createProject();
await createSection(project.id);
await createAsset(project.id);
await db.delete(portfolioProject).where(eq(portfolioProject.id, project.id));
expect(await db.$count(portfolioSection)).toBe(0);
expect(await db.$count(portfolioAsset)).toBe(0);
});
});
+2 -1
View File
@@ -39,7 +39,7 @@ function createMockRequest(url: string) {
describe("middleware locale runtime config", () => {
beforeEach(() => {
vi.resetModules();
process.env.NODE_ENV = "production";
vi.stubEnv("NODE_ENV", "production");
delete process.env.SITE_RUNTIME_ORIGIN;
createMiddlewareMock.mockReset();
intlHandlerMock.mockReset();
@@ -49,6 +49,7 @@ describe("middleware locale runtime config", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});
it("passes the runtime default locale into next-intl middleware", async () => {
+2 -1
View File
@@ -10,6 +10,7 @@ import {
resolveMediaUploadPath,
sanitizeBaseName,
} from "../lib/media-storage";
import { canManageUploads } from "./helpers/fs-capability";
const createdFiles: string[] = [];
@@ -39,7 +40,7 @@ describe("media storage helpers", () => {
expect(resolvedPath.endsWith(path.join("assets", "test.svg"))).toBe(true);
});
it("removes a managed file from disk", async () => {
it.skipIf(!canManageUploads)("removes a managed file from disk", async () => {
const relativePath = `/uploads/media/tests/${Date.now()}-temp.txt`;
const absolutePath = resolveMediaUploadPath(relativePath);
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { readFlash, withFlash } from "@/lib/admin-feedback";
describe("withFlash", () => {
it("returns the plain path when there are no messages", () => {
expect(withFlash("/admin/smtp", {})).toBe("/admin/smtp");
});
it("appends a success message", () => {
expect(withFlash("/admin/smtp", { success: "Saved." })).toBe("/admin/smtp?success=Saved.");
});
it("appends an error message", () => {
expect(withFlash("/admin/smtp", { error: "Nope." })).toBe("/admin/smtp?error=Nope.");
});
it("appends both and url-encodes values", () => {
const result = withFlash("/admin/smtp", { success: "a b", error: "x&y" });
const params = new URL(result, "http://local").searchParams;
expect(params.get("success")).toBe("a b");
expect(params.get("error")).toBe("x&y");
});
});
describe("readFlash", () => {
it("reads success and error from resolved search params", () => {
expect(readFlash({ success: "ok", error: "bad" })).toEqual({ success: "ok", error: "bad" });
});
it("returns undefined fields when params are missing", () => {
expect(readFlash(undefined)).toEqual({ success: undefined, error: undefined });
expect(readFlash(null)).toEqual({ success: undefined, error: undefined });
expect(readFlash({})).toEqual({ success: undefined, error: undefined });
});
});
+88
View File
@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import { getAdminNavigation } from "@/lib/admin-navigation";
const copy = {
overview: "Overview",
maintenance: "Maintenance",
uiKit: "UI Kit",
portfolio: "Portfolio",
media: "Media",
siteSettings: "Site Settings",
brandSettings: "Brand",
localizationSettings: "Localization",
marquee: "Marquee",
smtp: "SMTP",
};
describe("getAdminNavigation", () => {
it("returns the full set of top-level sections", () => {
const nav = getAdminNavigation(copy, "overview");
const labels = nav.map((item) => item.label);
expect(labels).toEqual([
"Overview",
"Maintenance",
"UI Kit",
"Media",
"Site Settings",
"Marquee",
"SMTP",
"Portfolio",
]);
});
it("marks the active top-level section", () => {
const nav = getAdminNavigation(copy, "smtp");
expect(nav.find((item) => item.label === "SMTP")?.active).toBe(true);
expect(nav.find((item) => item.label === "Overview")?.active).toBe(false);
});
it("expands site-settings and marks the active child", () => {
const nav = getAdminNavigation(copy, "site-settings", undefined, "localization");
const siteSettings = nav.find((item) => item.label === "Site Settings");
expect(siteSettings?.expanded).toBe(true);
expect(siteSettings?.active).toBe(false); // has a child selected
const localization = siteSettings?.children?.find((c) => c.label === "Localization");
expect(localization?.active).toBe(true);
});
it("marks the parent active when no child is selected", () => {
const nav = getAdminNavigation(copy, "site-settings");
const siteSettings = nav.find((item) => item.label === "Site Settings");
expect(siteSettings?.active).toBe(true);
expect(siteSettings?.expanded).toBe(true);
});
it("maps portfolio children and de-duplicates hrefs", () => {
const nav = getAdminNavigation(copy, "portfolio", "projects");
const portfolio = nav.find((item) => item.label === "Portfolio");
expect(portfolio?.expanded).toBe(true);
const hrefs = portfolio?.children?.map((c) => c.href) ?? [];
expect(new Set(hrefs).size).toBe(hrefs.length); // unique
const projects = portfolio?.children?.find((c) => c.label === "Projects");
expect(projects?.active).toBe(true);
});
it("activates the new-project child", () => {
const nav = getAdminNavigation(copy, "portfolio", "new-project");
const portfolio = nav.find((item) => item.label === "Portfolio");
expect(portfolio?.children?.find((c) => c.label === "Add Project")?.active).toBe(true);
});
it("falls back to default child labels when copy omits them", () => {
const minimal = { ...copy, brandSettings: undefined, localizationSettings: undefined, marquee: undefined, smtp: undefined };
const nav = getAdminNavigation(minimal, "overview");
const siteSettings = nav.find((item) => item.label === "Site Settings");
expect(siteSettings?.children?.map((c) => c.label)).toEqual(["Brand", "Localization"]);
expect(nav.find((item) => item.href.endsWith("/marquee"))?.label).toBe("Marquee");
expect(nav.find((item) => item.href.endsWith("/smtp"))?.label).toBe("SMTP");
});
it("gives every item an icon and href", () => {
const nav = getAdminNavigation(copy, "overview");
for (const item of nav) {
expect(item.icon).toBeTruthy();
expect(typeof item.href).toBe("string");
}
});
});
+147
View File
@@ -0,0 +1,147 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
buildAdminUrl,
buildSiteUrl,
fromDevelopmentAdminPath,
getAdminAppPath,
getAdminBaseUrl,
getAdminHost,
getRequestHostname,
getSiteBaseUrl,
getSiteHost,
hasDedicatedAdminHost,
INTERNAL_ADMIN_PREFIX,
isAdminHost,
isDevelopmentAdminPath,
isInternalAdminPath,
isLegacyAdminPath,
toInternalAdminPath,
} from "@/lib/admin-routing";
afterEach(() => {
vi.unstubAllEnvs();
});
describe("getRequestHostname", () => {
it("prefers the first non-empty candidate", () => {
expect(getRequestHostname("root.mohfarawati.de", "internal")).toBe("root.mohfarawati.de");
});
it("strips ports and takes the first comma-separated proxy value", () => {
expect(getRequestHostname(undefined, "root.mohfarawati.de:443, proxy")).toBe("root.mohfarawati.de");
});
it("lowercases the hostname", () => {
expect(getRequestHostname("ROOT.MohFarawati.de")).toBe("root.mohfarawati.de");
});
it("falls back to empty string when nothing matches", () => {
expect(getRequestHostname(undefined, null, "")).toBe("");
});
});
describe("host configuration", () => {
it("defaults admin and site hosts", () => {
expect(getAdminHost()).toBe("root.mohfarawati.de");
expect(getSiteHost()).toBe("mohfarawati.de");
expect(hasDedicatedAdminHost()).toBe(true);
});
it("reads ADMIN_HOST override and normalizes case/whitespace", () => {
vi.stubEnv("ADMIN_HOST", " Admin.Example.COM ");
expect(getAdminHost()).toBe("admin.example.com");
});
it("derives the site host from NEXT_PUBLIC_SITE_URL", () => {
vi.stubEnv("NEXT_PUBLIC_SITE_URL", "https://example.org/some/path");
expect(getSiteHost()).toBe("example.org");
});
it("hasDedicatedAdminHost is false when admin and site hosts match", () => {
vi.stubEnv("ADMIN_HOST", "example.com");
vi.stubEnv("NEXT_PUBLIC_SITE_URL", "https://example.com");
expect(hasDedicatedAdminHost()).toBe(false);
});
it("isAdminHost compares against the configured admin host", () => {
expect(isAdminHost("root.mohfarawati.de")).toBe(true);
expect(isAdminHost("mohfarawati.de")).toBe(false);
});
});
describe("path predicates", () => {
it("recognizes legacy /root paths", () => {
expect(isLegacyAdminPath("/root")).toBe(true);
expect(isLegacyAdminPath("/root/portfolio")).toBe(true);
expect(isLegacyAdminPath("/rooting")).toBe(false);
expect(isLegacyAdminPath("/")).toBe(false);
});
it("recognizes development /root paths", () => {
expect(isDevelopmentAdminPath("/root")).toBe(true);
expect(isDevelopmentAdminPath("/root/media")).toBe(true);
expect(isDevelopmentAdminPath("/rootx")).toBe(false);
});
it("recognizes internal admin paths", () => {
expect(isInternalAdminPath(INTERNAL_ADMIN_PREFIX)).toBe(true);
expect(isInternalAdminPath(`${INTERNAL_ADMIN_PREFIX}/smtp`)).toBe(true);
expect(isInternalAdminPath("/admin")).toBe(false);
});
});
describe("path translation", () => {
it("maps public paths to internal admin paths", () => {
expect(toInternalAdminPath("/")).toBe(INTERNAL_ADMIN_PREFIX);
expect(toInternalAdminPath("/portfolio")).toBe(`${INTERNAL_ADMIN_PREFIX}/portfolio`);
expect(toInternalAdminPath("portfolio")).toBe(`${INTERNAL_ADMIN_PREFIX}/portfolio`);
});
it("strips the dev /root prefix", () => {
expect(fromDevelopmentAdminPath("/root")).toBe("/");
expect(fromDevelopmentAdminPath("/root/portfolio")).toBe("/portfolio");
expect(fromDevelopmentAdminPath("/portfolio")).toBe("/portfolio");
});
});
describe("getAdminAppPath", () => {
it("returns bare paths when a dedicated admin host exists", () => {
// default env: admin host != site host -> dedicated host branch
expect(getAdminAppPath("/")).toBe("/");
expect(getAdminAppPath("/smtp")).toBe("/smtp");
});
it("uses the /root dev prefix when no dedicated host and not production", () => {
vi.stubEnv("ADMIN_HOST", "example.com");
vi.stubEnv("NEXT_PUBLIC_SITE_URL", "https://example.com");
vi.stubEnv("NODE_ENV", "development");
expect(getAdminAppPath("/")).toBe("/root");
expect(getAdminAppPath("/portfolio")).toBe("/root/portfolio");
});
it("returns bare paths in production even without a dedicated host", () => {
vi.stubEnv("ADMIN_HOST", "example.com");
vi.stubEnv("NEXT_PUBLIC_SITE_URL", "https://example.com");
vi.stubEnv("NODE_ENV", "production");
expect(getAdminAppPath("/portfolio")).toBe("/portfolio");
});
});
describe("url builders", () => {
it("builds admin urls from NEXT_PUBLIC_ADMIN_URL", () => {
expect(getAdminBaseUrl()).toBe("https://root.mohfarawati.de");
expect(buildAdminUrl("/smtp")).toBe("https://root.mohfarawati.de/smtp");
});
it("trims trailing slashes from configured base urls", () => {
vi.stubEnv("NEXT_PUBLIC_ADMIN_URL", "https://admin.example.com/");
expect(getAdminBaseUrl()).toBe("https://admin.example.com");
});
it("builds site urls from NEXT_PUBLIC_SITE_URL", () => {
expect(getSiteBaseUrl()).toBe("https://mohfarawati.de");
expect(buildSiteUrl("/about")).toBe("https://mohfarawati.de/about");
expect(buildSiteUrl("/")).toBe("https://mohfarawati.de/");
});
});
+122
View File
@@ -0,0 +1,122 @@
import { readFileSync, readdirSync, statSync } from "fs";
import path from "path";
import { describe, expect, it } from "vitest";
const ROOT = process.cwd();
function walk(dir: string, filter: (file: string) => boolean): string[] {
const absolute = path.join(ROOT, dir);
const results: string[] = [];
let entries: string[];
try {
entries = readdirSync(absolute);
} catch {
return results;
}
for (const entry of entries) {
if (entry === "node_modules" || entry === ".next") continue;
const full = path.join(absolute, entry);
const rel = path.relative(ROOT, full);
if (statSync(full).isDirectory()) {
results.push(...walk(rel, filter));
} else if (filter(full)) {
results.push(rel);
}
}
return results;
}
const read = (rel: string) => readFileSync(path.join(ROOT, rel), "utf8");
const isSource = (file: string) => /\.(ts|tsx)$/.test(file) && !file.endsWith(".d.ts");
const componentFiles = walk("components", isSource);
const appFiles = walk("app", isSource);
const libFiles = walk("lib", isSource);
const actionFiles = [...appFiles].filter((file) => /(^|\/)actions\.tsx?$/.test(file));
describe("architecture: data access boundaries", () => {
it("no component imports the Prisma client", () => {
const offenders = componentFiles.filter((file) => /["']@\/lib\/prisma["']/.test(read(file)));
expect(offenders).toEqual([]);
});
it("no client component imports the Prisma client (defense in depth)", () => {
const offenders = [...componentFiles, ...appFiles].filter((file) => {
const source = read(file);
return /["']use client["']/.test(source) && /lib\/prisma/.test(source);
});
expect(offenders).toEqual([]);
});
it("code imports admin server actions from the canonical _admin source, never the mirrors", () => {
const offenders = [...componentFiles, ...appFiles, ...libFiles].filter((file) =>
/from\s+["']@\/app\/(root|admin-internal)\//.test(read(file)),
);
expect(offenders).toEqual([]);
});
it("lib modules never import from the app layer", () => {
const offenders = libFiles.filter((file) => {
const source = read(file);
return /from\s+["']@\/app\//.test(source) || /from\s+["']\.\.\/app\//.test(source);
});
expect(offenders).toEqual([]);
});
});
describe("architecture: server actions", () => {
it("finds the expected server action files", () => {
expect(actionFiles.length).toBeGreaterThan(0);
});
it('every actions file starts with the "use server" directive', () => {
for (const file of actionFiles) {
const firstMeaningfulLine = read(file)
.split("\n")
.map((line) => line.trim())
.find((line) => line.length > 0);
expect(firstMeaningfulLine, file).toMatch(/^["']use server["'];?$/);
}
});
it("every admin action file enforces authentication", () => {
const adminActionFiles = actionFiles.filter((file) => file.includes(`${path.sep}_admin${path.sep}`));
expect(adminActionFiles.length).toBeGreaterThan(0);
for (const file of adminActionFiles) {
const source = read(file);
expect(
/ensureAdmin\s*\(/.test(source) ||
/requireAdminAuth\s*\(/.test(source) ||
/isAdminAuthenticated\s*\(/.test(source),
file,
).toBe(true);
}
});
});
describe("architecture: admin mirror parity", () => {
const canonicalPages = walk("app/_admin", (file) => /page\.tsx$/.test(file));
it("has admin pages to mirror", () => {
expect(canonicalPages.length).toBeGreaterThan(0);
});
for (const mirror of ["root", "admin-internal"]) {
it(`mirrors every _admin page under app/${mirror} via a re-export`, () => {
const missing: string[] = [];
for (const page of canonicalPages) {
const mirrored = page.replace(`app${path.sep}_admin${path.sep}`, `app${path.sep}${mirror}${path.sep}`);
try {
const source = read(mirrored);
if (!source.includes("_admin")) {
missing.push(`${mirrored} (does not re-export _admin)`);
}
} catch {
missing.push(`${mirrored} (missing)`);
}
}
expect(missing).toEqual([]);
});
}
});
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { isCheckedFormValue } from "@/lib/form-data";
describe("isCheckedFormValue", () => {
it("treats standard checkbox values as checked", () => {
expect(isCheckedFormValue("on")).toBe(true);
expect(isCheckedFormValue("true")).toBe(true);
expect(isCheckedFormValue("1")).toBe(true);
});
it("treats other values as unchecked", () => {
expect(isCheckedFormValue("off")).toBe(false);
expect(isCheckedFormValue("false")).toBe(false);
expect(isCheckedFormValue("0")).toBe(false);
expect(isCheckedFormValue("")).toBe(false);
expect(isCheckedFormValue(null)).toBe(false);
expect(isCheckedFormValue("yes")).toBe(false);
});
});
+103
View File
@@ -0,0 +1,103 @@
import { describe, expect, it } from "vitest";
import {
FALLBACK_LOCALE,
getDirection,
getLocalizedPath,
getLocalizedPathWithDefault,
isSupportedLocale,
resolveLocale,
stripLocalePrefix,
} from "@/lib/locale";
describe("isSupportedLocale", () => {
it("accepts the three app locales", () => {
expect(isSupportedLocale("de")).toBe(true);
expect(isSupportedLocale("en")).toBe(true);
expect(isSupportedLocale("ar")).toBe(true);
});
it("rejects everything else", () => {
expect(isSupportedLocale("fr")).toBe(false);
expect(isSupportedLocale("")).toBe(false);
expect(isSupportedLocale(undefined)).toBe(false);
expect(isSupportedLocale(null)).toBe(false);
});
});
describe("resolveLocale", () => {
it("keeps supported locales", () => {
expect(resolveLocale("ar", "de")).toBe("ar");
});
it("falls back for unsupported locales", () => {
expect(resolveLocale("fr", "en")).toBe("en");
expect(resolveLocale(undefined, FALLBACK_LOCALE)).toBe("de");
expect(resolveLocale(null, "ar")).toBe("ar");
});
});
describe("getDirection", () => {
it("is rtl for arabic only", () => {
expect(getDirection("ar")).toBe("rtl");
expect(getDirection("de")).toBe("ltr");
expect(getDirection("en")).toBe("ltr");
expect(getDirection("fr")).toBe("ltr");
});
});
describe("stripLocalePrefix", () => {
it("removes a bare locale prefix", () => {
expect(stripLocalePrefix("/de")).toBe("/");
expect(stripLocalePrefix("/ar")).toBe("/");
});
it("removes a nested locale prefix", () => {
expect(stripLocalePrefix("/en/about")).toBe("/about");
expect(stripLocalePrefix("/ar/portfolio/x")).toBe("/portfolio/x");
});
it("returns unprefixed paths unchanged", () => {
expect(stripLocalePrefix("/about")).toBe("/about");
expect(stripLocalePrefix("/")).toBe("/");
expect(stripLocalePrefix("")).toBe("/");
});
it("does not strip lookalike segments", () => {
expect(stripLocalePrefix("/design")).toBe("/design");
});
});
describe("getLocalizedPathWithDefault", () => {
it("keeps the default locale on the bare domain", () => {
expect(getLocalizedPathWithDefault("ar", "/", "ar")).toBe("/");
expect(getLocalizedPathWithDefault("de", "/", "ar")).toBe("/de");
expect(getLocalizedPathWithDefault("en", "/", "ar")).toBe("/en");
});
it("builds nested paths against the configured default", () => {
expect(getLocalizedPathWithDefault("ar", "/coming-soon", "ar")).toBe("/coming-soon");
expect(getLocalizedPathWithDefault("de", "/coming-soon", "ar")).toBe("/de/coming-soon");
expect(getLocalizedPathWithDefault("en", "/portfolio", "de")).toBe("/en/portfolio");
});
it("re-bases an already-prefixed path onto the requested locale", () => {
expect(getLocalizedPathWithDefault("en", "/ar/contact", "de")).toBe("/en/contact");
expect(getLocalizedPathWithDefault("ar", "/de/about", "ar")).toBe("/about");
});
it("normalizes an empty path to root", () => {
expect(getLocalizedPathWithDefault("de", "", "de")).toBe("/");
expect(getLocalizedPathWithDefault("en", "", "de")).toBe("/en");
});
it("falls back to the default locale for unsupported input", () => {
expect(getLocalizedPathWithDefault("fr", "/about", "de")).toBe("/about");
});
it("getLocalizedPath is an alias of getLocalizedPathWithDefault", () => {
expect(getLocalizedPath("en", "/about", "de")).toBe(
getLocalizedPathWithDefault("en", "/about", "de"),
);
});
});
+114
View File
@@ -0,0 +1,114 @@
import { describe, expect, it, vi } from "vitest";
import type { MailSettings } from "@/lib/mail-settings";
import { createSmtpTransport, sendContactMessage, sendMail, sendTestEmail } from "@/lib/mail";
function settings(overrides: Partial<MailSettings> = {}): MailSettings {
return {
smtp: { host: "smtp.example.com", port: 587, secure: false, username: "mailer", password: "secret", ...overrides.smtp },
sender: { email: "hello@example.com", name: "Studio", ...overrides.sender },
recipients: { contact: "contact@example.com", test: "test@example.com", ...overrides.recipients },
};
}
function mockTransport() {
const sendMailMock = vi.fn().mockResolvedValue({});
const createTransport = vi.fn().mockReturnValue({ sendMail: sendMailMock });
return { sendMailMock, createTransport };
}
describe("createSmtpTransport", () => {
it("builds the transport with host, port, secure, and auth", () => {
const { createTransport } = mockTransport();
createSmtpTransport(settings({ smtp: { host: "h", port: 465, secure: true, username: "u", password: "p" } }), createTransport);
expect(createTransport).toHaveBeenCalledWith({
host: "h",
port: 465,
secure: true,
auth: { user: "u", pass: "p" },
});
});
it("requires host, username and password", () => {
const { createTransport } = mockTransport();
expect(() => createSmtpTransport(settings({ smtp: { host: "", port: 587, secure: false, username: "u", password: "p" } }), createTransport)).toThrow(/host/i);
expect(() => createSmtpTransport(settings({ smtp: { host: "h", port: 587, secure: false, username: "", password: "p" } }), createTransport)).toThrow(/username/i);
expect(() => createSmtpTransport(settings({ smtp: { host: "h", port: 587, secure: false, username: "u", password: "" } }), createTransport)).toThrow(/password/i);
});
});
describe("sendMail", () => {
it("formats the from header with the sender name", async () => {
const { sendMailMock, createTransport } = mockTransport();
await sendMail({ to: "x@y.z", subject: "S", text: "T" }, { settings: settings(), createTransport });
expect(sendMailMock).toHaveBeenCalledWith(expect.objectContaining({ from: "Studio <hello@example.com>", to: "x@y.z" }));
});
it("omits the display name when sender name is blank", async () => {
const { sendMailMock, createTransport } = mockTransport();
await sendMail({ to: "x@y.z", subject: "S", text: "T" }, { settings: settings({ sender: { email: "hello@example.com", name: "" } }), createTransport });
expect(sendMailMock).toHaveBeenCalledWith(expect.objectContaining({ from: "hello@example.com" }));
});
it("requires a from email", async () => {
const { createTransport } = mockTransport();
await expect(
sendMail({ to: "x@y.z", subject: "S", text: "T" }, { settings: settings({ sender: { email: "", name: "" } }), createTransport }),
).rejects.toThrow(/from email/i);
});
});
describe("sendContactMessage", () => {
it("targets the contact recipient with reply-to and full body", async () => {
const { sendMailMock, createTransport } = mockTransport();
await sendContactMessage(
{ locale: "en", name: "Jane", email: "jane@x.z", phone: "123", company: "Acme", message: "Hi there team." },
{ settings: settings(), createTransport },
);
const call = sendMailMock.mock.calls[0][0];
expect(call).toMatchObject({ to: "contact@example.com", subject: "New contact message", replyTo: "jane@x.z" });
expect(call.text).toContain("Name: Jane");
expect(call.text).toContain("Phone: 123");
expect(call.text).toContain("Company: Acme");
expect(call.text).toContain("Hi there team.");
});
it("falls back to the test recipient when contact is empty", async () => {
const { sendMailMock, createTransport } = mockTransport();
await sendContactMessage(
{ locale: "de", name: "Jane", email: "jane@x.z", message: "Fallback works fine." },
{ settings: settings({ recipients: { contact: "", test: "fallback@x.z" } }), createTransport },
);
expect(sendMailMock).toHaveBeenCalledWith(expect.objectContaining({ to: "fallback@x.z" }));
});
it("renders dashes for missing optional fields", async () => {
const { sendMailMock, createTransport } = mockTransport();
await sendContactMessage(
{ locale: "en", name: "Jane", email: "jane@x.z", message: "No phone or company." },
{ settings: settings(), createTransport },
);
const call = sendMailMock.mock.calls[0][0];
expect(call.text).toContain("Phone: -");
expect(call.text).toContain("Company: -");
});
});
describe("sendTestEmail", () => {
it("sends to the test recipient", async () => {
const { sendMailMock, createTransport } = mockTransport();
await sendTestEmail({ settings: settings(), createTransport });
expect(sendMailMock).toHaveBeenCalledWith(expect.objectContaining({ to: "test@example.com", subject: "SMTP test email" }));
});
it("falls back to the contact recipient when test is empty", async () => {
const { sendMailMock, createTransport } = mockTransport();
await sendTestEmail({ settings: settings({ recipients: { contact: "c@x.z", test: "" } }), createTransport });
expect(sendMailMock).toHaveBeenCalledWith(expect.objectContaining({ to: "c@x.z" }));
});
it("propagates transport failures", async () => {
const createTransport = vi.fn().mockReturnValue({ sendMail: vi.fn().mockRejectedValue(new Error("Auth failed.")) });
await expect(sendTestEmail({ settings: settings(), createTransport })).rejects.toThrow("Auth failed.");
});
});
+85
View File
@@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import {
buildDefaultMarqueeSettings,
parseMarqueeSettingsValue,
splitMarqueeRowItems,
syncMarqueeSettingsToGermanSource,
} from "@/lib/marquee-settings";
describe("buildDefaultMarqueeSettings", () => {
it("provides all four rows for every locale", () => {
const settings = buildDefaultMarqueeSettings();
for (const locale of ["ar", "en", "de"] as const) {
expect(settings.locales[locale].row1).toContain("Next.js");
expect(settings.locales[locale].row2).toContain("TypeScript");
expect(settings.locales[locale].row3).toContain("JavaScript");
expect(settings.locales[locale].row4).toContain("Frontend Strategy");
}
});
});
describe("syncMarqueeSettingsToGermanSource", () => {
it("copies the german rows over english and arabic", () => {
const base = buildDefaultMarqueeSettings();
base.locales.de.row1 = "GERMAN";
base.locales.en.row1 = "english";
base.locales.ar.row1 = "arabic";
const synced = syncMarqueeSettingsToGermanSource(base);
expect(synced.locales.de.row1).toBe("GERMAN");
expect(synced.locales.en.row1).toBe("GERMAN");
expect(synced.locales.ar.row1).toBe("GERMAN");
});
it("returns independent copies (no shared references)", () => {
const synced = syncMarqueeSettingsToGermanSource(buildDefaultMarqueeSettings());
synced.locales.en.row1 = "changed";
expect(synced.locales.de.row1).not.toBe("changed");
});
});
describe("parseMarqueeSettingsValue", () => {
it("returns german-synced defaults for empty input", () => {
const settings = parseMarqueeSettingsValue(null);
expect(settings.locales.en.row1).toBe(settings.locales.de.row1);
expect(settings.locales.de.row1).toContain("Next.js");
});
it("returns defaults for invalid json", () => {
const settings = parseMarqueeSettingsValue("{not json");
expect(settings.locales.de.row1).toContain("Next.js");
});
it("normalizes stored values, trims, and syncs to german", () => {
const settings = parseMarqueeSettingsValue(
JSON.stringify({
locales: {
de: { row1: " Custom Row 1 ", row2: "R2", row3: "R3", row4: "R4" },
en: { row1: "IGNORED" },
},
}),
);
expect(settings.locales.de.row1).toBe("Custom Row 1");
// english is overwritten by the german source
expect(settings.locales.en.row1).toBe("Custom Row 1");
expect(settings.locales.ar.row2).toBe("R2");
});
it("falls back to per-row defaults when a row is blank", () => {
const settings = parseMarqueeSettingsValue(
JSON.stringify({ locales: { de: { row1: " ", row2: "", row3: "R3", row4: "R4" } } }),
);
expect(settings.locales.de.row1).toContain("Next.js");
expect(settings.locales.de.row3).toBe("R3");
});
});
describe("splitMarqueeRowItems", () => {
it("splits on newlines and commas and trims blanks", () => {
expect(splitMarqueeRowItems("A\nB, C\n\n , D")).toEqual(["A", "B", "C", "D"]);
});
it("returns an empty array for whitespace-only input", () => {
expect(splitMarqueeRowItems(" \n ")).toEqual([]);
});
});

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