Compare commits
20
Commits
b8a02f9e90
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a5c275c52 | ||
|
|
b2b90bd1bf | ||
|
|
aadc93e870 | ||
|
|
0f436ec05a | ||
|
|
1264b577a1 | ||
|
|
61a58b31a7 | ||
|
|
cdf0380f79 | ||
|
|
0a5f77d8de | ||
|
|
e377877e7e | ||
|
|
9c40d9030f | ||
|
|
777dcbab7b | ||
|
|
e2e06be86e | ||
|
|
0f48381894 | ||
|
|
59094eba85 | ||
|
|
dd72f013d4 | ||
|
|
86243b79c2 | ||
|
|
4892f27006 | ||
|
|
f7355b5356 | ||
|
|
dcffa87be7 | ||
|
|
d1a1a67b84 |
+26
-4
@@ -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.
|
||||
|
||||
Executable
+21
@@ -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."
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,63 +63,16 @@ 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"
|
||||
/>
|
||||
{/* 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="mx-auto flex w-full max-w-[60rem] flex-col items-center text-center">
|
||||
<HeroTitle locale={localeKey} lines={lines} className="mx-auto tracking-normal" />
|
||||
|
||||
<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>
|
||||
<HeroMotionItem className="mt-12">
|
||||
<LaunchCountdown
|
||||
targetIso={LAUNCH_DATE_ISO}
|
||||
arabic={isArabic}
|
||||
@@ -137,23 +85,6 @@ export default async function ComingSoonPage({ params }: ComingSoonPageProps) {
|
||||
}}
|
||||
/>
|
||||
</HeroMotionItem>
|
||||
|
||||
<HeroMotionItem className="mt-12 flex w-full flex-col items-center justify-center gap-3 sm:w-auto sm:flex-row">
|
||||
<Button asChild size="lg" className="w-full sm:w-auto">
|
||||
<Link href={getLocalizedPath(localeKey, "/contact", siteSettings.defaultLocale)}>
|
||||
<Mail className="h-4 w-4" />
|
||||
{t("primaryCta")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="lg" className="w-full sm:w-auto">
|
||||
<Link href={getLocalizedPath(localeKey, "/", siteSettings.defaultLocale)}>
|
||||
{t("secondaryCta")}
|
||||
<DirectionIcon className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</HeroMotionItem>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</HeroContentMotion>
|
||||
</HeroShell>
|
||||
|
||||
@@ -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({
|
||||
|
||||
+76
-110
@@ -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,13 +368,8 @@ 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: {
|
||||
const projectResult = await db.transaction(async (tx) => {
|
||||
const projectValues = {
|
||||
categoryId: parsed.categoryId,
|
||||
slug: parsed.slug,
|
||||
viewMode: parsed.viewMode,
|
||||
@@ -384,56 +388,36 @@ export async function saveProjectAction(formData: FormData) {
|
||||
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();
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { PortfolioProjectViewMode, PortfolioSectionType } from "@prisma/client";
|
||||
import type { PortfolioProjectViewMode, PortfolioSectionType } from "@/lib/db/enums";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
<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>
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-xl font-semibold tracking-[-0.04em] text-foreground">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-title font-semibold text-foreground">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="text-sm leading-7 text-muted-foreground">{item.description}</p>
|
||||
<p className="text-small leading-relaxed text-muted-foreground">{item.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</AppCard>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
<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")}
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-2xl font-semibold tracking-[-0.05em] text-foreground">
|
||||
</span>
|
||||
<h3 className="mt-5 text-title font-semibold text-foreground">
|
||||
{step.title}
|
||||
</h3>
|
||||
<p className="text-sm leading-7 text-muted-foreground">{step.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</AppCard>
|
||||
<p className="mt-2 text-small leading-relaxed text-muted-foreground">
|
||||
{step.description}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
</ol>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
<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}`}
|
||||
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(" ")}
|
||||
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="flex h-full flex-col gap-6 p-6">
|
||||
<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="flex flex-1 flex-col 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"
|
||||
className="border-border text-caption text-muted-foreground"
|
||||
>
|
||||
{metaItem}
|
||||
</Badge>
|
||||
))}
|
||||
</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">
|
||||
<h3
|
||||
className={cn(
|
||||
"mt-4 font-semibold tracking-tight text-foreground",
|
||||
featured ? "text-h3 sm:text-h2" : "text-h3",
|
||||
)}
|
||||
>
|
||||
{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 className="mt-2 line-clamp-2 text-small leading-relaxed text-muted-foreground">
|
||||
{item.summary}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
||||
{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-foreground transition-colors duration-300 group-hover:bg-secondary"
|
||||
className="bg-secondary/70 text-caption text-foreground"
|
||||
>
|
||||
{stackItem}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<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"
|
||||
>
|
||||
<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" />
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</AppCard>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -71,6 +71,7 @@ export type ProjectCardCopy = {
|
||||
cta: string;
|
||||
meta: string[];
|
||||
featured?: boolean;
|
||||
coverImagePath?: string | null;
|
||||
};
|
||||
|
||||
export type ProjectsSectionCopy = {
|
||||
|
||||
@@ -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,13 +83,22 @@ 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 aria-hidden="true" className="text-[0.95rem] leading-none">
|
||||
👋
|
||||
</span>
|
||||
}
|
||||
@@ -81,12 +106,37 @@ export function HomeHero({
|
||||
{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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 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"
|
||||
>
|
||||
<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">
|
||||
<CalendarDays className="h-3.5 w-3.5" />
|
||||
{item.projectYear}
|
||||
</p>
|
||||
</div>
|
||||
<h2 className="mt-3 text-xl font-semibold text-foreground">
|
||||
{getLocalizedValue(item.title, locale)}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm 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">
|
||||
<section>
|
||||
<div className="rounded-surface border border-border bg-surface-1 px-6 py-16 text-center text-small text-muted-foreground">
|
||||
{emptyLabel}
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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">
|
||||
<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}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<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 line-clamp-2 text-small leading-relaxed text-muted-foreground">
|
||||
{getLocalizedValue(item.summary, locale)}
|
||||
</p>
|
||||
|
||||
<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
@@ -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}
|
||||
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
+24
-19
@@ -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,10 +259,12 @@ 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 {
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
+59
-110
@@ -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,
|
||||
return usages.reduce<SiteSettingsMediaBindings>((result, usage) => {
|
||||
const binding = {
|
||||
assetId: usage.assetId,
|
||||
url: usage.assetUrl,
|
||||
version: usage.updatedAt.toISOString(),
|
||||
};
|
||||
|
||||
if (usage.fieldKey === SITE_SETTINGS_LOGO_LIGHT_FIELD_KEY) {
|
||||
result.siteLogoLight = binding;
|
||||
}
|
||||
|
||||
if (usage.fieldKey === SITE_SETTINGS_LOGO_DARK_FIELD_KEY) {
|
||||
result.siteLogoDark = {
|
||||
assetId: usage.asset.id,
|
||||
url: usage.asset.url,
|
||||
version: usage.updatedAt.toISOString(),
|
||||
};
|
||||
result.siteLogoDark = binding;
|
||||
}
|
||||
|
||||
if (usage.fieldKey === SITE_SETTINGS_FAVICON_FIELD_KEY) {
|
||||
result.favicon = {
|
||||
assetId: usage.asset.id,
|
||||
url: usage.asset.url,
|
||||
version: usage.updatedAt.toISOString(),
|
||||
};
|
||||
result.favicon = 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(),
|
||||
};
|
||||
result.defaultOgImage = binding;
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
getDefaultSiteSettingsMediaBindings(),
|
||||
);
|
||||
}, getDefaultSiteSettingsMediaBindings());
|
||||
} catch {
|
||||
return getDefaultSiteSettingsMediaBindings();
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
@@ -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 };
|
||||
@@ -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");
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1786049545718,
|
||||
"tag": "0000_fixed_venom",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,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,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
@@ -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,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,4 +1,4 @@
|
||||
import { PortfolioSectionType } from "@prisma/client";
|
||||
import { PortfolioSectionType } from "@/lib/db/enums";
|
||||
import { z } from "zod";
|
||||
|
||||
import { mediaFieldInputSchema } from "./media-validation";
|
||||
|
||||
+70
-145
@@ -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 } : {}),
|
||||
const conditions = [
|
||||
...(filters?.categoryId ? [eq(portfolioProject.categoryId, filters.categoryId)] : []),
|
||||
...(filters?.status === "draft"
|
||||
? { isPublished: false }
|
||||
? [eq(portfolioProject.isPublished, false)]
|
||||
: filters?.status === "published"
|
||||
? { isPublished: true }
|
||||
: {}),
|
||||
},
|
||||
include: {
|
||||
? [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" }],
|
||||
sections: { orderBy: [asc(portfolioSection.sortOrder), asc(portfolioSection.createdAt)] },
|
||||
assets: { orderBy: [asc(portfolioAsset.sortOrder), asc(portfolioAsset.createdAt)] },
|
||||
},
|
||||
assets: {
|
||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
|
||||
},
|
||||
},
|
||||
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" }],
|
||||
sections: { orderBy: [asc(portfolioSection.sortOrder), asc(portfolioSection.createdAt)] },
|
||||
assets: { orderBy: [asc(portfolioAsset.sortOrder), asc(portfolioAsset.createdAt)] },
|
||||
},
|
||||
assets: {
|
||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
|
||||
},
|
||||
},
|
||||
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)] },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Generated
+2628
-164
File diff suppressed because it is too large
Load Diff
+15
-11
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
@@ -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"
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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: {
|
||||
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
}));
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
})();
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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("/");
|
||||
});
|
||||
});
|
||||
@@ -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("/");
|
||||
});
|
||||
});
|
||||
@@ -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("/");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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/");
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user