Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba63f75ea8 |
@@ -39,7 +39,7 @@ For new features, architecture changes, domain rule changes, or public behavior
|
|||||||
Update documentation only when implementation changes affect:
|
Update documentation only when implementation changes affect:
|
||||||
- feature scope
|
- feature scope
|
||||||
- business rules
|
- business rules
|
||||||
- architecture
|
- architectureڑڑ
|
||||||
- public behavior
|
- public behavior
|
||||||
|
|
||||||
Do not update documentation for small isolated fixes or purely visual changes.
|
Do not update documentation for small isolated fixes or purely visual changes.
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
"name": "dev",
|
"name": "dev",
|
||||||
"runtimeExecutable": "npm",
|
"runtimeExecutable": "npm",
|
||||||
"runtimeArgs": ["run", "dev"],
|
"runtimeArgs": ["run", "dev"],
|
||||||
"port": 3014
|
"port": 3000
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,9 @@
|
|||||||
# Local dev: app runs on host (npm run dev), only Postgres runs in Docker.
|
DATABASE_URL="postgresql://USER:PASSWORD@HOST:5432/moh_sass?schema=public"
|
||||||
# Production: docker-compose.yml runs the full stack (deploy via `make deploy`).
|
NEXT_PUBLIC_APP_URL="https://mohfarawati.de"
|
||||||
|
NEXT_PUBLIC_SITE_URL="https://mohfarawati.de"
|
||||||
# --- Database ---
|
NEXT_PUBLIC_ADMIN_URL="https://root.mohfarawati.de"
|
||||||
# Local: localhost:5432 (exposed from Docker). Server: db:5432 (Compose internal).
|
|
||||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/moh_sass"
|
|
||||||
|
|
||||||
# --- URLs ---
|
|
||||||
NEXT_PUBLIC_APP_URL="http://localhost:3014"
|
|
||||||
NEXT_PUBLIC_SITE_URL="http://localhost:3014"
|
|
||||||
NEXT_PUBLIC_ADMIN_URL="http://rootmohfarawati.localhost:3014"
|
|
||||||
ADMIN_HOST="rootmohfarawati.localhost"
|
|
||||||
|
|
||||||
NEXT_TELEMETRY_DISABLED="1"
|
NEXT_TELEMETRY_DISABLED="1"
|
||||||
|
|
||||||
# --- Admin auth ---
|
|
||||||
ADMIN_PASSWORD="change-me"
|
ADMIN_PASSWORD="change-me"
|
||||||
ADMIN_AUTH_SECRET="replace-with-a-long-random-secret"
|
ADMIN_AUTH_SECRET="replace-with-a-long-random-secret"
|
||||||
ADMIN_BASIC_AUTH_USER="change-me"
|
ADMIN_BASIC_AUTH_USER="change-me"
|
||||||
ADMIN_BASIC_AUTH_PASS="change-me"
|
ADMIN_BASIC_AUTH_PASS="change-me"
|
||||||
|
|
||||||
# --- Production only (server .env) ---
|
|
||||||
# The full stack runs via docker-compose.yml behind Traefik. On the server set:
|
|
||||||
# NEXT_PUBLIC_SITE_URL="https://mohfarawati.de"
|
|
||||||
# NEXT_PUBLIC_ADMIN_URL="https://root.mohfarawati.de"
|
|
||||||
# ADMIN_HOST="root.mohfarawati.de"
|
|
||||||
# Traefik wiring (override only if your server differs from the defaults):
|
|
||||||
# TRAEFIK_NETWORK="proxy" # the external network Traefik is on
|
|
||||||
# TRAEFIK_ENTRYPOINT="websecure" # HTTPS entrypoint name
|
|
||||||
# TRAEFIK_CERTRESOLVER="cf" # cert resolver that covers *.mohfarawati.de
|
|
||||||
# DATABASE_URL is set by docker-compose.yml to the internal db service — do not set it here.
|
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# commit-msg: enforce the project's commit subject style.
|
|
||||||
#
|
|
||||||
# The subject (first line) MUST be: VERB - Short description
|
|
||||||
# - VERB : an ALL-CAPS verb, >= 3 letters
|
|
||||||
# (e.g. ADDED, FIXED, STYLED, DOCUMENTED, CLEANED, REVERTED,
|
|
||||||
# CONFIGURED, IMPROVED, POLISHED, REMOVED, RENAMED, REFACTORED)
|
|
||||||
# - then " - " (space hyphen space)
|
|
||||||
# - then a description (capitalized, imperative, no trailing period).
|
|
||||||
#
|
|
||||||
# Good:
|
|
||||||
# FIXED - Load .env in drizzle.config so drizzle-kit targets the right DB
|
|
||||||
# STYLED - Turn the admin portfolio overview into a professional table
|
|
||||||
#
|
|
||||||
# Bad (rejected):
|
|
||||||
# Flatten portfolio category routes to /portfolio/[slug] (no VERB prefix)
|
|
||||||
# feat(hero): add ambient backdrop (wrong style)
|
|
||||||
#
|
|
||||||
# Install once: git config core.hooksPath .githooks
|
|
||||||
# Emergency skip: git commit --no-verify
|
|
||||||
#
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
msg_file="$1"
|
|
||||||
|
|
||||||
# First non-empty, non-comment line = the subject.
|
|
||||||
subject="$(grep -vE '^[[:space:]]*#' "$msg_file" | grep -vE '^[[:space:]]*$' | head -n1 || true)"
|
|
||||||
|
|
||||||
# Let git's own housekeeping commits through untouched.
|
|
||||||
case "$subject" in
|
|
||||||
Merge\ * | Revert\ * | fixup!\ * | squash!\ *) exit 0 ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
pattern='^[A-Z]{3,} - .+'
|
|
||||||
if [[ ! "$subject" =~ $pattern ]]; then
|
|
||||||
cat >&2 <<EOF
|
|
||||||
|
|
||||||
✗ commit rejected: subject does not match the project style.
|
|
||||||
|
|
||||||
Required: VERB - Short description
|
|
||||||
Example: FIXED - Load .env so drizzle-kit targets the right DB
|
|
||||||
|
|
||||||
Your subject was:
|
|
||||||
${subject:-(empty)}
|
|
||||||
|
|
||||||
VERB must be ALL-CAPS (>= 3 letters), then " - ", then the description.
|
|
||||||
(Emergency skip: git commit --no-verify)
|
|
||||||
|
|
||||||
EOF
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# Pre-push test gate. The deploy server only ever PULLS, so the real place to
|
|
||||||
# stop broken code is right here — before anything leaves this machine.
|
|
||||||
#
|
|
||||||
# The suite needs no external database: integration tests spin up an in-process
|
|
||||||
# PGlite database per worker (see tests/helpers/integration-setup.ts), so
|
|
||||||
# `npm test` runs standalone. A real failure blocks the push; the run ends with a
|
|
||||||
# compact copy-pasteable summary (scripts/test-summary.mjs).
|
|
||||||
#
|
|
||||||
# Install once: git config core.hooksPath .githooks
|
|
||||||
# Emergency skip: git push --no-verify
|
|
||||||
#
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
cd "$(git rev-parse --show-toplevel)"
|
|
||||||
|
|
||||||
echo "▶ pre-push: running the full test suite…"
|
|
||||||
node scripts/test-summary.mjs
|
|
||||||
|
|
||||||
echo "✓ all tests green — pushing."
|
|
||||||
@@ -8,7 +8,7 @@ jobs:
|
|||||||
quality:
|
quality:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/moh_sass
|
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/moh_sass?schema=public
|
||||||
NEXT_TELEMETRY_DISABLED: "1"
|
NEXT_TELEMETRY_DISABLED: "1"
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -29,10 +29,5 @@ jobs:
|
|||||||
- name: Lint
|
- name: Lint
|
||||||
run: npm run lint
|
run: npm run lint
|
||||||
|
|
||||||
- name: Test
|
|
||||||
# Integration tests use an in-process PGlite database, so no service
|
|
||||||
# container is needed — the suite runs standalone.
|
|
||||||
run: npm test
|
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|||||||
@@ -12,30 +12,29 @@ npm run lint # Run ESLint
|
|||||||
npm run test # Run all tests with Vitest
|
npm run test # Run all tests with Vitest
|
||||||
npx vitest run tests/some-file.test.ts # Run a single test file
|
npx vitest run tests/some-file.test.ts # Run a single test file
|
||||||
|
|
||||||
# Database (Drizzle ORM + drizzle-kit)
|
# Database
|
||||||
npm run db:generate # Generate a migration from schema changes
|
npm run prisma:generate # Regenerate Prisma client after schema changes
|
||||||
npm run db:migrate # Apply migrations
|
npm run db:migrate # Apply migrations (production)
|
||||||
npm run db:push # Push schema to DB directly (dev shortcut, no migration)
|
npm run db:migrate:dev # Create and apply dev migration
|
||||||
npm run db:studio # Open Drizzle Studio to browse the database
|
npm run db:seed # Seed the database
|
||||||
```
|
```
|
||||||
|
|
||||||
### Docker / task runner (see Makefile)
|
### Docker (production/staging)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make start # Start DB container + dev server (http://localhost:3014)
|
make start # Build and start all containers
|
||||||
make stop # Stop containers
|
make stop # Stop containers
|
||||||
make db-up # Start only the database container
|
make deploy # Pull + rebuild + restart
|
||||||
make migrate # Apply Drizzle migrations
|
make logs # Follow container logs
|
||||||
make studio # Open Drizzle Studio
|
make db-init # Generate client, apply migrations, and seed (first run)
|
||||||
make psql # Open a psql shell on the database
|
make db-shell # Open psql shell
|
||||||
make test # Run the whole test suite + copy-paste summary
|
make app-shell # Open shell in app container
|
||||||
make deploy # (on server) Pull + rebuild + restart
|
|
||||||
make health # Hit /api/health via public URL
|
make health # Hit /api/health via public URL
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
This is a multilingual Next.js (App Router) portfolio site with an admin workspace. Stack: TypeScript, next-intl, Drizzle ORM + PostgreSQL, Tailwind CSS, Radix UI, framer-motion, nodemailer.
|
This is a multilingual Next.js (App Router) portfolio site with an admin workspace. Stack: TypeScript, next-intl, Prisma + PostgreSQL, Tailwind CSS, Radix UI, framer-motion, nodemailer.
|
||||||
|
|
||||||
### Routing overview
|
### Routing overview
|
||||||
|
|
||||||
@@ -47,7 +46,7 @@ Localized routes for `de`, `en`, `ar`. Default locale is dynamic (stored in `App
|
|||||||
**Admin workspace** — `app/_admin/` (canonical source)
|
**Admin workspace** — `app/_admin/` (canonical source)
|
||||||
Accessed via a dedicated subdomain (`root.mohfarawati.de`) in production, or via the `/root` path prefix in development. The middleware rewrites both to `app/admin-internal/`. The `app/root/` and `app/admin-internal/` directories mirror `app/_admin/` — treat `app/_admin/` as the source of truth.
|
Accessed via a dedicated subdomain (`root.mohfarawati.de`) in production, or via the `/root` path prefix in development. The middleware rewrites both to `app/admin-internal/`. The `app/root/` and `app/admin-internal/` directories mirror `app/_admin/` — treat `app/_admin/` as the source of truth.
|
||||||
|
|
||||||
The full routing rewrite logic lives in `lib/admin-routing.ts` and `proxy.ts` (the Next.js 16 middleware entry point — this project has no `middleware.ts`).
|
The full routing rewrite logic lives in `lib/admin-routing.ts` and `middleware.ts`.
|
||||||
|
|
||||||
### i18n
|
### i18n
|
||||||
|
|
||||||
@@ -59,7 +58,7 @@ The full routing rewrite logic lives in `lib/admin-routing.ts` and `proxy.ts` (t
|
|||||||
|
|
||||||
### Persistence
|
### Persistence
|
||||||
|
|
||||||
The Drizzle client is in `lib/db/index.ts` (postgres.js driver); the schema is in `lib/db/schema.ts` and DB enums in `lib/db/enums.ts`. All DB access must go through server-side modules in `lib/`. Client components must never access the database.
|
Prisma client is in `lib/prisma.ts`. All DB access must go through server-side modules in `lib/`. Client components must never access Prisma.
|
||||||
|
|
||||||
`AppConfig` is a key-value table used for all runtime configuration: site settings, SMTP, marquee, maintenance mode, default locale. `lib/app-config.ts` is the aggregate entry point; individual settings are in `lib/site-settings.ts`, `lib/mail-settings.ts`, `lib/marquee-settings.ts`.
|
`AppConfig` is a key-value table used for all runtime configuration: site settings, SMTP, marquee, maintenance mode, default locale. `lib/app-config.ts` is the aggregate entry point; individual settings are in `lib/site-settings.ts`, `lib/mail-settings.ts`, `lib/marquee-settings.ts`.
|
||||||
|
|
||||||
@@ -78,22 +77,18 @@ The Drizzle client is in `lib/db/index.ts` (postgres.js driver); the schema is i
|
|||||||
|---|---|
|
|---|---|
|
||||||
| i18n routing | `i18n/routing.ts` |
|
| i18n routing | `i18n/routing.ts` |
|
||||||
| Admin routing logic | `lib/admin-routing.ts` |
|
| Admin routing logic | `lib/admin-routing.ts` |
|
||||||
| Middleware (routing + auth) | `proxy.ts` |
|
| Middleware (routing + auth) | `middleware.ts` |
|
||||||
| DB client (Drizzle) | `lib/db/index.ts` |
|
| Prisma client | `lib/prisma.ts` |
|
||||||
| DB schema | `lib/db/schema.ts` |
|
|
||||||
| AppConfig aggregate | `lib/app-config.ts` |
|
| AppConfig aggregate | `lib/app-config.ts` |
|
||||||
| Portfolio queries | `lib/portfolio.ts` |
|
| Portfolio queries | `lib/portfolio.ts` |
|
||||||
| Media handling | `lib/media.ts`, `lib/media-storage.ts` |
|
| Media handling | `lib/media.ts` |
|
||||||
| Contact flow | `lib/mail.ts` |
|
| Contact flow | `lib/mail.ts` |
|
||||||
| SEO (metadata, robots, sitemap, JSON-LD) | `lib/metadata.ts`, `lib/seo-settings.ts`, `app/robots.ts`, `app/sitemap.ts` — see `docs/SEO.md` |
|
|
||||||
| Admin session token (middleware + auth) | `lib/admin-session-token.ts` |
|
|
||||||
|
|
||||||
### Documentation to read by task scope
|
### Documentation to read by task scope
|
||||||
|
|
||||||
- **Small UI/copy/style fixes**: read only the relevant files
|
- **Small UI/copy/style fixes**: read only the relevant files
|
||||||
- **Feature changes**: read `specs/<feature>.md` + `docs/ARCHITECTURE.md` if structure is affected
|
- **Feature changes**: read `specs/<feature>.md` + `docs/ARCHITECTURE.md` if structure is affected
|
||||||
- **Cross-cutting/architecture changes**: read `docs/ARCHITECTURE.md`, `docs/DOMAIN_RULES.md`, `docs/FEATURES.md`, and the relevant `specs/` file
|
- **Cross-cutting/architecture changes**: read `docs/ARCHITECTURE.md`, `docs/DOMAIN_RULES.md`, `docs/FEATURES.md`, and the relevant `specs/` file
|
||||||
- **SEO / metadata / robots / sitemap**: read `docs/SEO.md` first
|
|
||||||
|
|
||||||
Update `docs/` and `specs/` only when the change affects feature scope, business rules, architecture, or public behavior.
|
Update `docs/` and `specs/` only when the change affects feature scope, business rules, architecture, or public behavior.
|
||||||
|
|
||||||
@@ -115,46 +110,16 @@ SITE_RUNTIME_ORIGIN Internal origin for middleware to fetch runtime state
|
|||||||
|
|
||||||
## Working rules
|
## Working rules
|
||||||
|
|
||||||
- **Tests are mandatory for every logic change, in the SAME change.** New behaviour → new tests covering the intent (positive **and** negative cases), not one happy example. Deliberate change → update the affected tests and say which/why. A test that fails unexpectedly is a real bug → fix the code, not the test. Test the real thing — integration tests use a real (in-process PGlite) database, so do NOT mock our own `lib/`/DB layer; only true external boundaries (the admin session, third-party APIs, SMTP) may be substituted. Keep the suite green (`make test`); the `pre-push` hook (`.githooks/pre-push`) enforces it. Frontend/UI is verified manually; add component tests only for components with real logic.
|
|
||||||
- Before making any change, first explain the plan briefly and list the files that will be touched.
|
- Before making any change, first explain the plan briefly and list the files that will be touched.
|
||||||
- Make the smallest safe change that solves the task.
|
- Make the smallest safe change that solves the task.
|
||||||
- Do not modify unrelated files.
|
- Do not modify unrelated files.
|
||||||
- Preserve existing architecture, naming, and folder conventions.
|
- Preserve existing architecture, naming, and folder conventions.
|
||||||
- Prefer server-side logic in `lib/*` and keep business logic out of UI components.
|
- Prefer server-side logic in `lib/*` and keep business logic out of UI components.
|
||||||
- Never access the database (Drizzle) from client components.
|
- Never access Prisma from client components.
|
||||||
- For admin-related changes, treat `app/_admin/` as the canonical source of truth unless explicitly told otherwise.
|
- For admin-related changes, treat `app/_admin/` as the canonical source of truth unless explicitly told otherwise.
|
||||||
- Do not add new dependencies unless absolutely necessary and explicitly justified.
|
- Do not add new dependencies unless absolutely necessary and explicitly justified.
|
||||||
- After code changes, run only the minimum relevant checks (for example: targeted test, lint on changed files, or build if necessary).
|
- After code changes, run only the minimum relevant checks (for example: targeted test, lint on changed files, or build if necessary).
|
||||||
- If a task may affect routing, auth, i18n, or runtime config, inspect `proxy.ts`, `lib/admin-routing.ts`, `i18n/routing.ts`, and the relevant `lib/app-config.ts` modules first.
|
- If a task may affect routing, auth, i18n, or runtime config, inspect `middleware.ts`, `lib/admin-routing.ts`, `i18n/routing.ts`, and the relevant `lib/app-config.ts` modules first.
|
||||||
- For schema or database changes, inspect the Drizzle schema (`lib/db/schema.ts`), the drizzle-kit migration flow, and migration impact before editing.
|
- For schema or database changes, inspect Prisma schema, migration flow, and seed impact before editing.
|
||||||
- Ask before performing large refactors, file moves, destructive changes, or broad formatting changes.
|
- Ask before performing large refactors, file moves, destructive changes, or broad formatting changes.
|
||||||
- When updating behavior, also update docs/specs if the change affects public behavior, business rules, or architecture.
|
- When updating behavior, also update docs/specs if the change affects public behavior, business rules, or architecture.
|
||||||
|
|
||||||
## Git commit messages (MANDATORY)
|
|
||||||
|
|
||||||
Every commit subject **must** follow this exact style, or the `commit-msg` hook
|
|
||||||
(`.githooks/commit-msg`) will reject the commit:
|
|
||||||
|
|
||||||
```
|
|
||||||
VERB - Short description
|
|
||||||
```
|
|
||||||
|
|
||||||
- `VERB` is an ALL-CAPS verb, at least 3 letters — e.g. `ADDED`, `FIXED`,
|
|
||||||
`STYLED`, `REMOVED`, `RENAMED`, `REFACTORED`, `IMPROVED`, `DOCUMENTED`,
|
|
||||||
`CLEANED`, `REVERTED`, `CONFIGURED`, `POLISHED`.
|
|
||||||
- Then exactly `" - "` (space, hyphen, space).
|
|
||||||
- Then a capitalized, imperative description with no trailing period.
|
|
||||||
|
|
||||||
Good: `FIXED - Load .env in drizzle.config so drizzle-kit targets the right DB`
|
|
||||||
Bad (rejected): `Flatten portfolio category routes` — no `VERB` prefix.
|
|
||||||
Bad (rejected): `feat(hero): add backdrop` — Conventional Commits is NOT used here.
|
|
||||||
|
|
||||||
Do **not** add any `Co-Authored-By` / "Generated with Claude Code" attribution
|
|
||||||
lines to commits in this repo. Write the body (when useful) as wrapped prose or
|
|
||||||
bullet points explaining the *why*, matching the existing history.
|
|
||||||
|
|
||||||
The hook (and the `pre-push` test gate) live in `.githooks/` and are activated
|
|
||||||
per clone via `core.hooksPath`. You don't need to remember this: the `prepare`
|
|
||||||
npm script runs `git config core.hooksPath .githooks` automatically on every
|
|
||||||
`npm install`. The deploy server does not need it — it only pulls, never commits
|
|
||||||
or pushes, so no hook ever fires there. Emergency skip: `git commit --no-verify`.
|
|
||||||
@@ -1,109 +1,74 @@
|
|||||||
# mohfarawati.de — task runner.
|
.PHONY: start stop restart deploy logs build ps port health clean-orphans app-shell db-shell db-init db-generate db-migrate db-push db-seed help
|
||||||
# Local dev = DB in Docker, app on host (`npm run dev`).
|
|
||||||
# Production = docker-compose.yml runs the full stack on the server.
|
|
||||||
|
|
||||||
.DEFAULT_GOAL := help
|
|
||||||
.PHONY: help install start stop restart db-up db-down \
|
|
||||||
migrate generate db-generate db-push studio psql \
|
|
||||||
build deploy deploy-logs deploy-down \
|
|
||||||
test test-watch \
|
|
||||||
health help
|
|
||||||
|
|
||||||
## --- Help ------------------------------------------------------------------
|
|
||||||
|
|
||||||
help:
|
|
||||||
@echo "Local development (DB in Docker, app on host):"
|
|
||||||
@echo " make start Start DB + dev server (http://localhost:3014)"
|
|
||||||
@echo " make stop Stop the database container"
|
|
||||||
@echo " make restart Restart DB + dev server"
|
|
||||||
@echo " make install Install host dependencies"
|
|
||||||
@echo ""
|
|
||||||
@echo "Database:"
|
|
||||||
@echo " make db-up Start only the database container"
|
|
||||||
@echo " make db-down Stop the database container"
|
|
||||||
@echo " make migrate Apply Drizzle migrations"
|
|
||||||
@echo " make db-generate Generate a migration from schema changes"
|
|
||||||
@echo " make db-push Push schema to DB (dev shortcut, no migration)"
|
|
||||||
@echo " make studio Open Drizzle Studio to browse the database"
|
|
||||||
@echo " make psql Open a psql shell on the database"
|
|
||||||
@echo ""
|
|
||||||
@echo "Quality:"
|
|
||||||
@echo " make build Production build"
|
|
||||||
@echo " make test Run the whole test suite + copy-paste summary"
|
|
||||||
@echo " make test-watch Run the test suite in watch mode"
|
|
||||||
@echo ""
|
|
||||||
@echo "Deploy (run on the SERVER, with .env filled in):"
|
|
||||||
@echo " make deploy Pull latest, rebuild, restart"
|
|
||||||
@echo " make deploy-logs Follow the app logs"
|
|
||||||
@echo " make deploy-down Stop the production stack"
|
|
||||||
@echo " make health Check app health endpoint via public domain"
|
|
||||||
|
|
||||||
## --- Local development (DB in Docker, app on host) -------------------------
|
|
||||||
|
|
||||||
install:
|
|
||||||
npm install
|
|
||||||
|
|
||||||
start:
|
start:
|
||||||
-@docker compose stop app 2>/dev/null || true
|
docker compose up -d --build
|
||||||
docker compose up -d db
|
|
||||||
@for _ in $$(seq 1 20); do docker compose exec -T db pg_isready -U postgres >/dev/null 2>&1 && break; sleep 1; done
|
|
||||||
npm run dev
|
|
||||||
|
|
||||||
stop:
|
stop:
|
||||||
docker compose down
|
docker compose down
|
||||||
|
|
||||||
restart: stop start
|
restart: stop start
|
||||||
|
|
||||||
## --- Database --------------------------------------------------------------
|
|
||||||
|
|
||||||
db-up:
|
|
||||||
docker compose up -d db
|
|
||||||
|
|
||||||
db-down:
|
|
||||||
docker compose down
|
|
||||||
|
|
||||||
migrate:
|
|
||||||
npm run db:migrate
|
|
||||||
|
|
||||||
generate: db-generate ## Alias for db-generate
|
|
||||||
|
|
||||||
db-generate:
|
|
||||||
npm run db:generate
|
|
||||||
|
|
||||||
db-push:
|
|
||||||
npm run db:push
|
|
||||||
|
|
||||||
studio:
|
|
||||||
npm run db:studio
|
|
||||||
|
|
||||||
psql:
|
|
||||||
docker compose exec db psql -U postgres -d moh_sass
|
|
||||||
|
|
||||||
## --- Quality ---------------------------------------------------------------
|
|
||||||
|
|
||||||
build:
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
test:
|
|
||||||
@node scripts/test-summary.mjs
|
|
||||||
|
|
||||||
test-watch:
|
|
||||||
npm run test:watch
|
|
||||||
|
|
||||||
## --- Deploy (server) -------------------------------------------------------
|
|
||||||
# Run these ON THE SERVER, inside the repo. docker-compose.yml is the full stack
|
|
||||||
# (app + db); the server does `git pull` + `make deploy`.
|
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
git pull
|
git pull
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
@git log -1 --oneline
|
@git log -1 --oneline
|
||||||
|
|
||||||
deploy-logs:
|
logs:
|
||||||
docker compose logs -f --tail=200
|
docker compose logs -f --tail=200
|
||||||
|
|
||||||
deploy-down:
|
build:
|
||||||
docker compose down
|
docker compose build
|
||||||
|
|
||||||
|
ps:
|
||||||
|
docker compose ps
|
||||||
|
|
||||||
|
port:
|
||||||
|
@echo "Site: http://localhost:3014"
|
||||||
|
@echo "Admin: http://rootmohfarawati.localhost:3014"
|
||||||
|
|
||||||
|
clean-orphans:
|
||||||
|
docker compose up -d --remove-orphans
|
||||||
|
|
||||||
|
app-shell:
|
||||||
|
docker compose exec app sh
|
||||||
|
|
||||||
|
db-shell:
|
||||||
|
docker compose exec db psql -U postgres -d moh_sass
|
||||||
|
|
||||||
|
db-init:
|
||||||
|
docker compose exec app sh -lc "npm run db:migrate && npm run db:seed"
|
||||||
|
|
||||||
|
db-generate:
|
||||||
|
docker compose exec app npm run db:generate
|
||||||
|
|
||||||
|
db-migrate:
|
||||||
|
docker compose exec app npm run db:migrate
|
||||||
|
|
||||||
|
db-push:
|
||||||
|
docker compose exec app npm run db:push
|
||||||
|
|
||||||
|
db-seed:
|
||||||
|
docker compose exec app npm run db:seed
|
||||||
|
|
||||||
health:
|
health:
|
||||||
curl -sS https://mohfarawati.de/api/health
|
curl -sS https://mohfarawati.de/api/health
|
||||||
|
|
||||||
|
help:
|
||||||
|
@echo "Available targets:"
|
||||||
|
@echo " make start Start all containers"
|
||||||
|
@echo " make stop Stop and remove containers"
|
||||||
|
@echo " make restart Restart all containers"
|
||||||
|
@echo " make deploy Pull latest code and deploy updated stack"
|
||||||
|
@echo " make logs Follow container logs"
|
||||||
|
@echo " make build Build images"
|
||||||
|
@echo " make ps Show container status"
|
||||||
|
@echo " make port Show app public URL"
|
||||||
|
@echo " make clean-orphans Remove orphaned old containers"
|
||||||
|
@echo " make app-shell Open shell in app container"
|
||||||
|
@echo " make db-shell Open PostgreSQL shell"
|
||||||
|
@echo " make db-init Apply migrations and run seed (first run)"
|
||||||
|
@echo " make db-generate Generate a Drizzle migration from schema changes"
|
||||||
|
@echo " make db-migrate Apply pending Drizzle migrations"
|
||||||
|
@echo " make db-push Push schema directly (dev convenience)"
|
||||||
|
@echo " make db-seed Seed database data"
|
||||||
|
@echo " make health Check app health endpoint via public domain"
|
||||||
|
|||||||
@@ -3,19 +3,9 @@ import { getLocale, getTranslations } from "next-intl/server";
|
|||||||
|
|
||||||
import { Container } from "@/components/layout/container";
|
import { Container } from "@/components/layout/container";
|
||||||
import { PageHero } from "@/components/layout/page-hero";
|
import { PageHero } from "@/components/layout/page-hero";
|
||||||
import { MotionFade } from "@/components/motion-fade";
|
|
||||||
import { AppCard } from "@/components/ui/app-card";
|
import { AppCard } from "@/components/ui/app-card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { CapabilitiesSection } from "@/components/home/capabilities-section";
|
|
||||||
import { ProcessSection } from "@/components/home/process-section";
|
|
||||||
import { ContactCtaSection } from "@/components/home/contact-cta-section";
|
|
||||||
import type {
|
|
||||||
CapabilitiesSectionCopy,
|
|
||||||
ContactCtaSectionCopy,
|
|
||||||
ProcessSectionCopy,
|
|
||||||
} from "@/components/home/types";
|
|
||||||
import { getSiteSettings } from "@/lib/app-config";
|
import { getSiteSettings } from "@/lib/app-config";
|
||||||
import { getLocalizedPath, resolveLocale } from "@/lib/locale";
|
import { resolveLocale } from "@/lib/locale";
|
||||||
import { buildLocalizedMetadata } from "@/lib/metadata";
|
import { buildLocalizedMetadata } from "@/lib/metadata";
|
||||||
|
|
||||||
type AboutPageProps = {
|
type AboutPageProps = {
|
||||||
@@ -46,38 +36,6 @@ export default async function AboutPage({ params }: AboutPageProps) {
|
|||||||
const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale);
|
const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale);
|
||||||
const t = await getTranslations({ locale: localeKey, namespace: "aboutPage" });
|
const t = await getTranslations({ locale: localeKey, namespace: "aboutPage" });
|
||||||
|
|
||||||
const contactHref = getLocalizedPath(localeKey, "/contact", siteSettings.defaultLocale);
|
|
||||||
|
|
||||||
const capabilitiesCopy: CapabilitiesSectionCopy = {
|
|
||||||
eyebrow: t("capabilities.eyebrow"),
|
|
||||||
title: t("capabilities.title"),
|
|
||||||
description: t("capabilities.description"),
|
|
||||||
items: t.raw("capabilities.items") as CapabilitiesSectionCopy["items"],
|
|
||||||
};
|
|
||||||
|
|
||||||
const processCopy: ProcessSectionCopy = {
|
|
||||||
eyebrow: t("process.eyebrow"),
|
|
||||||
title: t("process.title"),
|
|
||||||
description: t("process.description"),
|
|
||||||
steps: t.raw("process.steps") as ProcessSectionCopy["steps"],
|
|
||||||
};
|
|
||||||
|
|
||||||
const contactCtaCopy: ContactCtaSectionCopy = {
|
|
||||||
eyebrow: t("contactCta.eyebrow"),
|
|
||||||
title: t("contactCta.title"),
|
|
||||||
description: t("contactCta.description"),
|
|
||||||
contactCta: t("contactCta.contactCta"),
|
|
||||||
githubCta: t("contactCta.githubCta"),
|
|
||||||
emailLabel: t("contactCta.emailLabel"),
|
|
||||||
emailValue: t("contactCta.emailValue"),
|
|
||||||
availabilityLabel: t("contactCta.availabilityLabel"),
|
|
||||||
availabilityValue: t("contactCta.availabilityValue"),
|
|
||||||
githubHref: t("contactCta.githubHref"),
|
|
||||||
};
|
|
||||||
|
|
||||||
const storyParagraphs = t.raw("story.paragraphs") as string[];
|
|
||||||
const toolItems = t.raw("tools.items") as string[];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHero
|
<PageHero
|
||||||
@@ -87,62 +45,10 @@ export default async function AboutPage({ params }: AboutPageProps) {
|
|||||||
description={t("description")}
|
description={t("description")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Container className="flex flex-col gap-16 pb-16 sm:gap-20 lg:gap-24 lg:pb-20">
|
<Container className="pb-16 lg:pb-20">
|
||||||
<MotionFade>
|
<AppCard level={3} padding="lg" className="mx-auto max-w-3xl text-center">
|
||||||
<section className="mx-auto max-w-3xl space-y-6">
|
<p className="text-lg font-medium text-foreground">{t("placeholder")}</p>
|
||||||
<p className="eyebrow text-caption font-medium text-brand-primary">
|
</AppCard>
|
||||||
{t("story.eyebrow")}
|
|
||||||
</p>
|
|
||||||
<h2 className="text-balance text-h2 text-foreground">{t("story.title")}</h2>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{storyParagraphs.map((paragraph) => (
|
|
||||||
<p key={paragraph} className="text-body-lg text-muted-foreground">
|
|
||||||
{paragraph}
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</MotionFade>
|
|
||||||
|
|
||||||
<MotionFade delay={0.06}>
|
|
||||||
<CapabilitiesSection copy={capabilitiesCopy} />
|
|
||||||
</MotionFade>
|
|
||||||
|
|
||||||
<MotionFade delay={0.08}>
|
|
||||||
<AppCard padding="lg">
|
|
||||||
<div className="space-y-5">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="eyebrow text-caption font-medium text-brand-primary">
|
|
||||||
{t("tools.eyebrow")}
|
|
||||||
</p>
|
|
||||||
<h3 className="text-title font-semibold text-foreground">{t("tools.title")}</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{toolItems.map((tool) => (
|
|
||||||
<Badge key={tool} variant="secondary">
|
|
||||||
{tool}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AppCard>
|
|
||||||
</MotionFade>
|
|
||||||
|
|
||||||
<MotionFade delay={0.1}>
|
|
||||||
<ProcessSection copy={processCopy} />
|
|
||||||
</MotionFade>
|
|
||||||
|
|
||||||
<MotionFade delay={0.12}>
|
|
||||||
<p className="mx-auto max-w-2xl text-center text-small text-muted-foreground">
|
|
||||||
<span className="text-brand-primary">{t("personalNote.eyebrow")}</span>
|
|
||||||
{" — "}
|
|
||||||
{t("personalNote.text")}
|
|
||||||
</p>
|
|
||||||
</MotionFade>
|
|
||||||
|
|
||||||
<MotionFade delay={0.14}>
|
|
||||||
<ContactCtaSection copy={contactCtaCopy} contactHref={contactHref} />
|
|
||||||
</MotionFade>
|
|
||||||
</Container>
|
</Container>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,15 +4,12 @@ import { getLocale } from "next-intl/server";
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
import { SiteAmbientBackdrop } from "@/components/layout/site-ambient-backdrop";
|
import { SiteAmbientBackdrop } from "@/components/layout/site-ambient-backdrop";
|
||||||
import { PageTransition } from "@/components/layout/page-transition";
|
|
||||||
import { SiteDock } from "@/components/layout/site-dock";
|
|
||||||
import { SiteFooter } from "@/components/layout/site-footer";
|
import { SiteFooter } from "@/components/layout/site-footer";
|
||||||
|
import { SiteHeader } from "@/components/layout/site-header";
|
||||||
import { ScrollSmootherProvider } from "@/components/scroll-smoother-provider";
|
import { ScrollSmootherProvider } from "@/components/scroll-smoother-provider";
|
||||||
import { JsonLd } from "@/components/seo/json-ld";
|
|
||||||
import { isSuperAdmin } from "@/lib/admin-auth";
|
import { isSuperAdmin } from "@/lib/admin-auth";
|
||||||
import { getMaintenanceMode, getSeoSettings, getSiteSettings, getSiteSettingsMediaBindings } from "@/lib/app-config";
|
import { getMaintenanceMode, getSiteSettings, getSiteSettingsMediaBindings } from "@/lib/app-config";
|
||||||
import { getLocalizedPath, resolveLocale } from "@/lib/locale";
|
import { getLocalizedPath, resolveLocale } from "@/lib/locale";
|
||||||
import { buildSiteJsonLd } from "@/lib/metadata";
|
|
||||||
|
|
||||||
type SiteLayoutProps = {
|
type SiteLayoutProps = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -27,14 +24,13 @@ export const revalidate = 0;
|
|||||||
export default async function SiteLayout({ children, params }: SiteLayoutProps) {
|
export default async function SiteLayout({ children, params }: SiteLayoutProps) {
|
||||||
noStore();
|
noStore();
|
||||||
await params;
|
await params;
|
||||||
const [maintenanceEnabled, siteSettings, seo, mediaBindings] = await Promise.all([
|
const [maintenanceEnabled, mediaBindings, siteSettings] = await Promise.all([
|
||||||
getMaintenanceMode(),
|
getMaintenanceMode(),
|
||||||
getSiteSettings(),
|
|
||||||
getSeoSettings(),
|
|
||||||
getSiteSettingsMediaBindings(),
|
getSiteSettingsMediaBindings(),
|
||||||
|
getSiteSettings(),
|
||||||
]);
|
]);
|
||||||
const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale);
|
const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale);
|
||||||
// Server-side decision only. The dock receives just this boolean and uses
|
// Server-side decision only. The header receives just this boolean and uses
|
||||||
// it purely to show/hide the Admin shortcut link — it grants no access by
|
// it purely to show/hide the Admin shortcut link — it grants no access by
|
||||||
// itself. Every admin page/action re-checks the session independently, so
|
// itself. Every admin page/action re-checks the session independently, so
|
||||||
// this value is not a security boundary, only a UI convenience.
|
// this value is not a security boundary, only a UI convenience.
|
||||||
@@ -46,16 +42,18 @@ export default async function SiteLayout({ children, params }: SiteLayoutProps)
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<JsonLd data={buildSiteJsonLd({ settings: siteSettings, seo, bindings: mediaBindings, locale: localeKey })} />
|
|
||||||
<ScrollSmootherProvider />
|
<ScrollSmootherProvider />
|
||||||
<SiteAmbientBackdrop />
|
<SiteAmbientBackdrop />
|
||||||
<SiteDock defaultLocale={siteSettings.defaultLocale} isSuperAdmin={authenticated} />
|
<SiteHeader
|
||||||
|
lightLogoUrl={mediaBindings.siteLogoLight?.url}
|
||||||
|
darkLogoUrl={mediaBindings.siteLogoDark?.url}
|
||||||
|
defaultLocale={siteSettings.defaultLocale}
|
||||||
|
isSuperAdmin={authenticated}
|
||||||
|
/>
|
||||||
<div id="smooth-wrapper">
|
<div id="smooth-wrapper">
|
||||||
<div id="smooth-content">
|
<div id="smooth-content">
|
||||||
<div className="site-content-frame flex min-h-screen flex-col">
|
<div className="site-content-frame flex min-h-screen flex-col">
|
||||||
<main className="flex-1">
|
<main className="flex-1">{children}</main>
|
||||||
<PageTransition>{children}</PageTransition>
|
|
||||||
</main>
|
|
||||||
<SiteFooter defaultLocale={siteSettings.defaultLocale} />
|
<SiteFooter defaultLocale={siteSettings.defaultLocale} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,11 +5,18 @@ import { Container } from "@/components/layout/container";
|
|||||||
import { HomeHero } from "@/components/layout/home-hero";
|
import { HomeHero } from "@/components/layout/home-hero";
|
||||||
import { MotionFade } from "@/components/motion-fade";
|
import { MotionFade } from "@/components/motion-fade";
|
||||||
import { MagicBentoSection } from "@/components/home/magic-bento-section";
|
import { MagicBentoSection } from "@/components/home/magic-bento-section";
|
||||||
|
import { CapabilitiesSection } from "@/components/home/capabilities-section";
|
||||||
import { ContactCtaSection } from "@/components/home/contact-cta-section";
|
import { ContactCtaSection } from "@/components/home/contact-cta-section";
|
||||||
import { MarqueeSection } from "@/components/home/marquee-section";
|
import { MarqueeSection } from "@/components/home/marquee-section";
|
||||||
|
import { ProcessSection } from "@/components/home/process-section";
|
||||||
|
import { ProjectsSection } from "@/components/home/projects-section";
|
||||||
import type {
|
import type {
|
||||||
|
CapabilitiesSectionCopy,
|
||||||
ContactCtaSectionCopy,
|
ContactCtaSectionCopy,
|
||||||
MarqueeSectionCopy,
|
MarqueeSectionCopy,
|
||||||
|
ProcessSectionCopy,
|
||||||
|
ProjectCardCopy,
|
||||||
|
ProjectsSectionCopy,
|
||||||
} from "@/components/home/types";
|
} from "@/components/home/types";
|
||||||
import { buildLocalizedMetadata } from "@/lib/metadata";
|
import { buildLocalizedMetadata } from "@/lib/metadata";
|
||||||
import { getLocalizedPath, resolveLocale } from "@/lib/locale";
|
import { getLocalizedPath, resolveLocale } from "@/lib/locale";
|
||||||
@@ -18,6 +25,10 @@ import {
|
|||||||
getSiteSettings,
|
getSiteSettings,
|
||||||
splitMarqueeRowItems,
|
splitMarqueeRowItems,
|
||||||
} from "@/lib/app-config";
|
} from "@/lib/app-config";
|
||||||
|
import {
|
||||||
|
getLocalizedValue,
|
||||||
|
getPublishedPortfolioProjects,
|
||||||
|
} from "@/lib/portfolio";
|
||||||
import { type MarqueeRow } from "@/components/layout/stacked-marquee-section";
|
import { type MarqueeRow } from "@/components/layout/stacked-marquee-section";
|
||||||
|
|
||||||
type HomePageProps = {
|
type HomePageProps = {
|
||||||
@@ -54,9 +65,10 @@ export default async function HomePage({ params }: HomePageProps) {
|
|||||||
siteSettings.defaultLocale,
|
siteSettings.defaultLocale,
|
||||||
);
|
);
|
||||||
|
|
||||||
const [t, marqueeSettings] = await Promise.all([
|
const [t, marqueeSettings, publishedProjects] = await Promise.all([
|
||||||
getTranslations({ locale: localeKey, namespace: "homepage" }),
|
getTranslations({ locale: localeKey, namespace: "homepage" }),
|
||||||
getMarqueeSettings(),
|
getMarqueeSettings(),
|
||||||
|
getPublishedPortfolioProjects(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const bentoCards = [
|
const bentoCards = [
|
||||||
@@ -100,6 +112,28 @@ export default async function HomePage({ params }: HomePageProps) {
|
|||||||
description: t("marquee.description"),
|
description: t("marquee.description"),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const projectsCopy: ProjectsSectionCopy = {
|
||||||
|
eyebrow: t("projects.eyebrow"),
|
||||||
|
title: t("projects.title"),
|
||||||
|
description: t("projects.description"),
|
||||||
|
stackLabel: t("projects.stackLabel"),
|
||||||
|
fallbackItems: t.raw("projects.fallbackItems") as ProjectsSectionCopy["fallbackItems"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const capabilitiesCopy: CapabilitiesSectionCopy = {
|
||||||
|
eyebrow: t("capabilities.eyebrow"),
|
||||||
|
title: t("capabilities.title"),
|
||||||
|
description: t("capabilities.description"),
|
||||||
|
items: t.raw("capabilities.items") as CapabilitiesSectionCopy["items"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const processCopy: ProcessSectionCopy = {
|
||||||
|
eyebrow: t("process.eyebrow"),
|
||||||
|
title: t("process.title"),
|
||||||
|
description: t("process.description"),
|
||||||
|
steps: t.raw("process.steps") as ProcessSectionCopy["steps"],
|
||||||
|
};
|
||||||
|
|
||||||
const contactCtaCopy: ContactCtaSectionCopy = {
|
const contactCtaCopy: ContactCtaSectionCopy = {
|
||||||
eyebrow: t("contactCta.eyebrow"),
|
eyebrow: t("contactCta.eyebrow"),
|
||||||
title: t("contactCta.title"),
|
title: t("contactCta.title"),
|
||||||
@@ -123,6 +157,45 @@ export default async function HomePage({ params }: HomePageProps) {
|
|||||||
|
|
||||||
const portfolioHref = getLocalizedPath(localeKey, "/portfolio", siteSettings.defaultLocale);
|
const portfolioHref = getLocalizedPath(localeKey, "/portfolio", siteSettings.defaultLocale);
|
||||||
const contactHref = getLocalizedPath(localeKey, "/contact", siteSettings.defaultLocale);
|
const contactHref = getLocalizedPath(localeKey, "/contact", siteSettings.defaultLocale);
|
||||||
|
const stackSets = t.raw("projects.stackSets") as string[][];
|
||||||
|
|
||||||
|
const selectedProjects = [
|
||||||
|
...publishedProjects.filter((project) => project.isFeatured),
|
||||||
|
...publishedProjects.filter((project) => !project.isFeatured),
|
||||||
|
].slice(0, 3);
|
||||||
|
|
||||||
|
const projectCards: ProjectCardCopy[] = selectedProjects.map((project, index) => ({
|
||||||
|
title: getLocalizedValue(project.title, localeKey),
|
||||||
|
summary: getLocalizedValue(project.summary, localeKey),
|
||||||
|
stack: stackSets[index] ?? stackSets[stackSets.length - 1] ?? [],
|
||||||
|
href: getLocalizedPath(
|
||||||
|
localeKey,
|
||||||
|
`/portfolio/${project.slug}`,
|
||||||
|
siteSettings.defaultLocale,
|
||||||
|
),
|
||||||
|
cta: t("projects.cta"),
|
||||||
|
meta: [
|
||||||
|
getLocalizedValue(project.category.name, localeKey),
|
||||||
|
getLocalizedValue(project.serviceLabel, localeKey),
|
||||||
|
String(project.projectYear),
|
||||||
|
],
|
||||||
|
featured: index === 0,
|
||||||
|
coverImagePath: project.coverImagePath,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const fallbackProjectCards: ProjectCardCopy[] = projectsCopy.fallbackItems.map((item) => ({
|
||||||
|
title: item.title,
|
||||||
|
summary: item.summary,
|
||||||
|
stack: item.stack,
|
||||||
|
href: item.href === "/portfolio" ? portfolioHref : item.href === "/contact" ? contactHref : item.href,
|
||||||
|
cta: t("projects.cta"),
|
||||||
|
meta: [t("projects.fallbackMeta")],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mergedProjectCards = [
|
||||||
|
...projectCards,
|
||||||
|
...fallbackProjectCards.slice(0, Math.max(0, 3 - projectCards.length)),
|
||||||
|
].slice(0, 3);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -153,7 +226,19 @@ export default async function HomePage({ params }: HomePageProps) {
|
|||||||
<MarqueeSection copy={marqueeCopy} rows={marqueeRows} />
|
<MarqueeSection copy={marqueeCopy} rows={marqueeRows} />
|
||||||
</MotionFade>
|
</MotionFade>
|
||||||
|
|
||||||
|
<MotionFade delay={0.08}>
|
||||||
|
<ProjectsSection copy={projectsCopy} items={mergedProjectCards} />
|
||||||
|
</MotionFade>
|
||||||
|
|
||||||
<MotionFade delay={0.1}>
|
<MotionFade delay={0.1}>
|
||||||
|
<CapabilitiesSection copy={capabilitiesCopy} />
|
||||||
|
</MotionFade>
|
||||||
|
|
||||||
|
<MotionFade delay={0.12}>
|
||||||
|
<ProcessSection copy={processCopy} />
|
||||||
|
</MotionFade>
|
||||||
|
|
||||||
|
<MotionFade delay={0.14}>
|
||||||
<ContactCtaSection copy={contactCtaCopy} contactHref={contactHref} />
|
<ContactCtaSection copy={contactCtaCopy} contactHref={contactHref} />
|
||||||
</MotionFade>
|
</MotionFade>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -4,21 +4,16 @@ import { notFound } from "next/navigation";
|
|||||||
|
|
||||||
import { Container } from "@/components/layout/container";
|
import { Container } from "@/components/layout/container";
|
||||||
import { PageHero } from "@/components/layout/page-hero";
|
import { PageHero } from "@/components/layout/page-hero";
|
||||||
import { PortfolioCategoryFilter } from "@/components/site/portfolio-category-filter";
|
|
||||||
import { PortfolioProjectDetail } from "@/components/site/portfolio-project-detail";
|
import { PortfolioProjectDetail } from "@/components/site/portfolio-project-detail";
|
||||||
import { PortfolioProjectGrid } from "@/components/site/portfolio-project-grid";
|
import { getSiteSettings } from "@/lib/app-config";
|
||||||
import { JsonLd } from "@/components/seo/json-ld";
|
import { buildLocalizedMetadata } from "@/lib/metadata";
|
||||||
import { getSeoSettings, getSiteSettings } from "@/lib/app-config";
|
|
||||||
import { buildLocalizedMetadata, buildProjectJsonLd } from "@/lib/metadata";
|
|
||||||
import { resolveLocale } from "@/lib/locale";
|
import { resolveLocale } from "@/lib/locale";
|
||||||
import {
|
import {
|
||||||
getActivePortfolioCategories,
|
|
||||||
getLocalizedValue,
|
getLocalizedValue,
|
||||||
getPublishedPortfolioProjects,
|
getPublishedPortfolioProjectBySlug,
|
||||||
resolvePortfolioSlug,
|
|
||||||
} from "@/lib/portfolio";
|
} from "@/lib/portfolio";
|
||||||
|
|
||||||
type PortfolioSlugPageProps = {
|
type PortfolioItemPageProps = {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
locale: string;
|
locale: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -27,115 +22,48 @@ type PortfolioSlugPageProps = {
|
|||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function generateMetadata({ params }: PortfolioSlugPageProps): Promise<Metadata> {
|
export async function generateMetadata({ params }: PortfolioItemPageProps): Promise<Metadata> {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
const siteSettings = await getSiteSettings();
|
const siteSettings = await getSiteSettings();
|
||||||
const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale);
|
const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale);
|
||||||
const resolved = await resolvePortfolioSlug(slug);
|
const item = await getPublishedPortfolioProjectBySlug(slug);
|
||||||
|
|
||||||
if (!resolved) {
|
if (!item) {
|
||||||
const t = await getTranslations({ locale: localeKey, namespace: "portfolioPage" });
|
|
||||||
return await buildLocalizedMetadata({
|
return await buildLocalizedMetadata({
|
||||||
locale: localeKey,
|
locale: localeKey,
|
||||||
pathname: `/portfolio/${slug}`,
|
pathname: `/portfolio/${slug}`,
|
||||||
title: t("title"),
|
title: "Portfolio",
|
||||||
description: t("intro"),
|
description: "Portfolio item",
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resolved.kind === "category") {
|
|
||||||
const t = await getTranslations({ locale: localeKey, namespace: "portfolioPage" });
|
|
||||||
return await buildLocalizedMetadata({
|
|
||||||
locale: localeKey,
|
|
||||||
pathname: `/portfolio/${slug}`,
|
|
||||||
title: getLocalizedValue(resolved.category.name, localeKey),
|
|
||||||
description: getLocalizedValue(resolved.category.description, localeKey) || t("intro"),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return await buildLocalizedMetadata({
|
return await buildLocalizedMetadata({
|
||||||
locale: localeKey,
|
locale: localeKey,
|
||||||
pathname: `/portfolio/${slug}`,
|
pathname: `/portfolio/${slug}`,
|
||||||
title: getLocalizedValue(resolved.project.title, localeKey),
|
title: getLocalizedValue(item.title, localeKey),
|
||||||
description: getLocalizedValue(resolved.project.summary, localeKey),
|
description: getLocalizedValue(item.summary, localeKey),
|
||||||
image: resolved.project.coverImagePath,
|
|
||||||
type: "article",
|
|
||||||
publishedTime: resolved.project.publishedAt,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function PortfolioSlugPage({ params }: PortfolioSlugPageProps) {
|
export default async function PortfolioItemPage({
|
||||||
|
params,
|
||||||
|
}: PortfolioItemPageProps) {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
const siteSettings = await getSiteSettings();
|
const siteSettings = await getSiteSettings();
|
||||||
const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale);
|
const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale);
|
||||||
const resolved = await resolvePortfolioSlug(slug);
|
const item = await getPublishedPortfolioProjectBySlug(slug);
|
||||||
|
|
||||||
if (!resolved) {
|
if (!item) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resolved.kind === "category") {
|
const t = await getTranslations({ locale: localeKey, namespace: "portfolioDetail" });
|
||||||
const { category } = resolved;
|
|
||||||
const [categories, projects] = await Promise.all([
|
|
||||||
getActivePortfolioCategories(),
|
|
||||||
getPublishedPortfolioProjects({ categorySlug: slug }),
|
|
||||||
]);
|
|
||||||
const t = await getTranslations({ locale: localeKey, namespace: "portfolioPage" });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHero
|
|
||||||
locale={localeKey}
|
|
||||||
badge={t("heroBadge")}
|
|
||||||
title={getLocalizedValue(category.name, localeKey)}
|
|
||||||
description={getLocalizedValue(category.description, localeKey) || t("intro")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Container className="flex flex-col gap-section pb-12 lg:pb-16">
|
|
||||||
<PortfolioCategoryFilter
|
|
||||||
locale={localeKey}
|
|
||||||
defaultLocale={siteSettings.defaultLocale}
|
|
||||||
categories={categories}
|
|
||||||
allLabel={t("all")}
|
|
||||||
activeCategorySlug={category.slug}
|
|
||||||
/>
|
|
||||||
<PortfolioProjectGrid
|
|
||||||
locale={localeKey}
|
|
||||||
defaultLocale={siteSettings.defaultLocale}
|
|
||||||
projects={projects}
|
|
||||||
emptyLabel={t("empty")}
|
|
||||||
openLabel={t("open")}
|
|
||||||
/>
|
|
||||||
</Container>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { project: item } = resolved;
|
|
||||||
const [t, seo] = await Promise.all([
|
|
||||||
getTranslations({ locale: localeKey, namespace: "portfolioDetail" }),
|
|
||||||
getSeoSettings(),
|
|
||||||
]);
|
|
||||||
const title = getLocalizedValue(item.title, localeKey);
|
const title = getLocalizedValue(item.title, localeKey);
|
||||||
const category = getLocalizedValue(item.category.name, localeKey);
|
const category = getLocalizedValue(item.category.name, localeKey);
|
||||||
const summary = getLocalizedValue(item.summary, localeKey);
|
const summary = getLocalizedValue(item.summary, localeKey);
|
||||||
const jsonLd = buildProjectJsonLd({
|
|
||||||
settings: siteSettings,
|
|
||||||
seo,
|
|
||||||
locale: localeKey,
|
|
||||||
pathname: `/portfolio/${slug}`,
|
|
||||||
title,
|
|
||||||
description: summary,
|
|
||||||
image: item.coverImagePath,
|
|
||||||
datePublished: item.publishedAt,
|
|
||||||
genre: category,
|
|
||||||
keywords: [getLocalizedValue(item.serviceLabel, localeKey), String(item.projectYear)].filter(Boolean),
|
|
||||||
clientName: item.clientName,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<JsonLd data={jsonLd} />
|
|
||||||
<PageHero
|
<PageHero
|
||||||
locale={localeKey}
|
locale={localeKey}
|
||||||
badge={category}
|
badge={category}
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { getLocale, getTranslations } from "next-intl/server";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
import { Container } from "@/components/layout/container";
|
||||||
|
import { PageHero } from "@/components/layout/page-hero";
|
||||||
|
import { PortfolioCategoryFilter } from "@/components/site/portfolio-category-filter";
|
||||||
|
import { PortfolioProjectGrid } from "@/components/site/portfolio-project-grid";
|
||||||
|
import { getSiteSettings } from "@/lib/app-config";
|
||||||
|
import { buildLocalizedMetadata } from "@/lib/metadata";
|
||||||
|
import { resolveLocale } from "@/lib/locale";
|
||||||
|
import {
|
||||||
|
getActivePortfolioCategories,
|
||||||
|
getActivePortfolioCategoryBySlug,
|
||||||
|
getLocalizedValue,
|
||||||
|
getPublishedPortfolioProjects,
|
||||||
|
} from "@/lib/portfolio";
|
||||||
|
|
||||||
|
type PortfolioCategoryPageProps = {
|
||||||
|
params: Promise<{
|
||||||
|
locale: string;
|
||||||
|
slug: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: PortfolioCategoryPageProps): Promise<Metadata> {
|
||||||
|
const { slug } = await params;
|
||||||
|
const siteSettings = await getSiteSettings();
|
||||||
|
const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale);
|
||||||
|
const t = await getTranslations({ locale: localeKey, namespace: "portfolioPage" });
|
||||||
|
const category = await getActivePortfolioCategoryBySlug(slug);
|
||||||
|
|
||||||
|
if (!category) {
|
||||||
|
return await buildLocalizedMetadata({
|
||||||
|
locale: localeKey,
|
||||||
|
pathname: `/portfolio/category/${slug}`,
|
||||||
|
title: t("title"),
|
||||||
|
description: t("intro"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return await buildLocalizedMetadata({
|
||||||
|
locale: localeKey,
|
||||||
|
pathname: `/portfolio/category/${slug}`,
|
||||||
|
title: getLocalizedValue(category.name, localeKey),
|
||||||
|
description: getLocalizedValue(category.description, localeKey) || t("intro"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function PortfolioCategoryPage({
|
||||||
|
params,
|
||||||
|
}: PortfolioCategoryPageProps) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const [siteSettings, categories, category, projects] = await Promise.all([
|
||||||
|
getSiteSettings(),
|
||||||
|
getActivePortfolioCategories(),
|
||||||
|
getActivePortfolioCategoryBySlug(slug),
|
||||||
|
getPublishedPortfolioProjects({ categorySlug: slug }),
|
||||||
|
]);
|
||||||
|
const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale);
|
||||||
|
const t = await getTranslations({ locale: localeKey, namespace: "portfolioPage" });
|
||||||
|
|
||||||
|
if (!category) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHero
|
||||||
|
locale={localeKey}
|
||||||
|
badge={t("heroBadge")}
|
||||||
|
title={getLocalizedValue(category.name, localeKey)}
|
||||||
|
description={getLocalizedValue(category.description, localeKey) || t("intro")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Container className="flex flex-col gap-section pb-12 lg:pb-16">
|
||||||
|
<PortfolioCategoryFilter
|
||||||
|
locale={localeKey}
|
||||||
|
defaultLocale={siteSettings.defaultLocale}
|
||||||
|
categories={categories}
|
||||||
|
allLabel={t("all")}
|
||||||
|
activeCategorySlug={category.slug}
|
||||||
|
/>
|
||||||
|
<PortfolioProjectGrid
|
||||||
|
locale={localeKey}
|
||||||
|
defaultLocale={siteSettings.defaultLocale}
|
||||||
|
projects={projects}
|
||||||
|
emptyLabel={t("empty")}
|
||||||
|
openLabel={t("open")}
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { getLocale } from "next-intl/server";
|
||||||
|
import { permanentRedirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { getSiteSettings } from "@/lib/app-config";
|
||||||
|
import { getLocalizedPath, resolveLocale } from "@/lib/locale";
|
||||||
|
|
||||||
|
type PortfolioCategoryIndexPageProps = {
|
||||||
|
params: Promise<{
|
||||||
|
locale: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function PortfolioCategoryIndexPage({
|
||||||
|
params,
|
||||||
|
}: PortfolioCategoryIndexPageProps) {
|
||||||
|
await params;
|
||||||
|
const siteSettings = await getSiteSettings();
|
||||||
|
const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale);
|
||||||
|
|
||||||
|
permanentRedirect(getLocalizedPath(localeKey, "/portfolio", siteSettings.defaultLocale));
|
||||||
|
}
|
||||||
@@ -50,7 +50,7 @@ export default async function PortfolioPage({
|
|||||||
const selectedCategory = resolvedSearchParams?.category ?? "";
|
const selectedCategory = resolvedSearchParams?.category ?? "";
|
||||||
|
|
||||||
if (selectedCategory) {
|
if (selectedCategory) {
|
||||||
redirect(getLocalizedPath(localeKey, `/portfolio/${selectedCategory}`, siteSettings.defaultLocale));
|
redirect(getLocalizedPath(localeKey, `/portfolio/category/${selectedCategory}`, siteSettings.defaultLocale));
|
||||||
}
|
}
|
||||||
|
|
||||||
const [categories, projects] = await Promise.all([
|
const [categories, projects] = await Promise.all([
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ export async function generateMetadata({ params }: SuccessPageProps): Promise<Me
|
|||||||
pathname: "/success",
|
pathname: "/success",
|
||||||
title: t("title"),
|
title: t("title"),
|
||||||
description: t("text"),
|
description: t("text"),
|
||||||
noIndex: true,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { unstable_noStore as noStore } from "next/cache";
|
import { unstable_noStore as noStore } from "next/cache";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowLeft, ArrowRight, Mail } from "lucide-react";
|
||||||
import { getLocale, getTranslations } from "next-intl/server";
|
import { getLocale, getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
import { FloatingPreferences } from "@/components/layout/floating-preferences";
|
import { FloatingPreferences } from "@/components/layout/floating-preferences";
|
||||||
import { HeroContentMotion, HeroMotionItem, HeroShell, HeroTitle } from "@/components/layout/site-hero";
|
import { HeroContentMotion, HeroMotionItem, HeroShell, HeroTitle } from "@/components/layout/site-hero";
|
||||||
import { LaunchCountdown } from "@/components/site/launch-countdown";
|
import { LaunchCountdown } from "@/components/site/launch-countdown";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { getSiteSettings } from "@/lib/app-config";
|
import { getSiteSettings } from "@/lib/app-config";
|
||||||
import { buildLocalizedMetadata } from "@/lib/metadata";
|
import { buildLocalizedMetadata } from "@/lib/metadata";
|
||||||
import { resolveLocale } from "@/lib/locale";
|
import { getLocalizedPath, resolveLocale } from "@/lib/locale";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
// Target launch date for the countdown. Edit this single line to change it.
|
// Target launch date for the countdown. Edit this single line to change it.
|
||||||
const LAUNCH_DATE_ISO = "2026-08-28T12:00:00Z";
|
const LAUNCH_DATE_ISO = "2026-08-28T12:00:00Z";
|
||||||
@@ -34,7 +38,6 @@ export async function generateMetadata({ params }: ComingSoonPageProps): Promise
|
|||||||
title: siteSettings.locales[localeKey].siteName,
|
title: siteSettings.locales[localeKey].siteName,
|
||||||
description: t("description"),
|
description: t("description"),
|
||||||
applyTitleTemplate: false,
|
applyTitleTemplate: false,
|
||||||
noIndex: true,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +48,7 @@ export default async function ComingSoonPage({ params }: ComingSoonPageProps) {
|
|||||||
const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale);
|
const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale);
|
||||||
const t = await getTranslations({ locale: localeKey, namespace: "comingSoon" });
|
const t = await getTranslations({ locale: localeKey, namespace: "comingSoon" });
|
||||||
const isArabic = localeKey === "ar";
|
const isArabic = localeKey === "ar";
|
||||||
|
const DirectionIcon = isArabic ? ArrowLeft : ArrowRight;
|
||||||
|
|
||||||
const lines = [
|
const lines = [
|
||||||
{
|
{
|
||||||
@@ -64,28 +68,92 @@ export default async function ComingSoonPage({ params }: ComingSoonPageProps) {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const trackedEyebrow = isArabic ? "tracking-normal" : "uppercase tracking-[0.28em]";
|
||||||
|
const trackedLabel = isArabic ? "tracking-normal" : "uppercase tracking-[0.24em]";
|
||||||
|
const trackedPill = isArabic ? "tracking-normal" : "uppercase tracking-[0.12em]";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-screen overflow-hidden">
|
<div className="relative min-h-screen overflow-hidden">
|
||||||
<FloatingPreferences locale={localeKey} defaultLocale={siteSettings.defaultLocale} />
|
<FloatingPreferences locale={localeKey} defaultLocale={siteSettings.defaultLocale} />
|
||||||
|
|
||||||
<HeroShell className="h-screen" showBridges>
|
<HeroShell className="min-h-screen" showBridges>
|
||||||
<HeroContentMotion className="relative z-10 w-full">
|
<HeroContentMotion className="relative z-10 w-full">
|
||||||
<div className="mx-auto flex w-full max-w-[60rem] flex-col items-center text-center">
|
<div className="mx-auto w-full max-w-[60rem]">
|
||||||
<HeroTitle locale={localeKey} lines={lines} className="mx-auto tracking-normal" />
|
<div className="relative overflow-hidden rounded-[calc(var(--radius-surface)+10px)] border border-foreground/10 bg-background/45 px-6 py-12 shadow-panel backdrop-blur-[26px] dark:border-foreground/12 dark:bg-background/25 sm:px-14 sm:py-16">
|
||||||
|
{/* top hairline highlight */}
|
||||||
<HeroMotionItem className="mt-12">
|
<span
|
||||||
<LaunchCountdown
|
aria-hidden
|
||||||
targetIso={LAUNCH_DATE_ISO}
|
className="pointer-events-none absolute inset-x-8 top-0 h-px bg-gradient-to-r from-transparent via-foreground/25 to-transparent"
|
||||||
arabic={isArabic}
|
|
||||||
launchedLabel={t("launched")}
|
|
||||||
labels={{
|
|
||||||
days: t("unitDays"),
|
|
||||||
hours: t("unitHours"),
|
|
||||||
minutes: t("unitMinutes"),
|
|
||||||
seconds: t("unitSeconds"),
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</HeroMotionItem>
|
{/* soft brand glow */}
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute -top-28 left-1/2 h-64 w-[38rem] max-w-[120%] -translate-x-1/2 rounded-full bg-[radial-gradient(closest-side,hsl(var(--brand-primary)/0.18),transparent)] blur-2xl"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative flex flex-col items-center text-center">
|
||||||
|
<HeroMotionItem>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-2.5 rounded-pill border border-foreground/10 bg-background/55 px-4 py-2 text-xs font-medium text-[hsl(var(--hero-ink)/0.82)] shadow-[var(--shadow-sm)] backdrop-blur-[18px] dark:border-foreground/12 dark:bg-background/20 dark:text-foreground/82",
|
||||||
|
trackedPill,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="relative flex h-2 w-2">
|
||||||
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-brand-secondary opacity-70" />
|
||||||
|
<span className="relative inline-flex h-2 w-2 rounded-full bg-brand-secondary" />
|
||||||
|
</span>
|
||||||
|
{t("status")}
|
||||||
|
</span>
|
||||||
|
</HeroMotionItem>
|
||||||
|
|
||||||
|
<HeroMotionItem>
|
||||||
|
<p className={cn("mt-7 text-xs font-medium text-foreground/45", trackedEyebrow)}>
|
||||||
|
{t("kicker")}
|
||||||
|
</p>
|
||||||
|
</HeroMotionItem>
|
||||||
|
|
||||||
|
<HeroTitle locale={localeKey} lines={lines} className="mx-auto mt-4 tracking-normal" />
|
||||||
|
|
||||||
|
<HeroMotionItem>
|
||||||
|
<p className="mx-auto mt-8 max-w-[36rem] text-sm leading-7 text-foreground/68 sm:text-base">
|
||||||
|
{t("description")}
|
||||||
|
</p>
|
||||||
|
</HeroMotionItem>
|
||||||
|
|
||||||
|
<HeroMotionItem className="mt-11 flex flex-col items-center gap-4">
|
||||||
|
<span className={cn("text-xs font-medium text-foreground/45", trackedLabel)}>
|
||||||
|
{t("countdownLabel")}
|
||||||
|
</span>
|
||||||
|
<LaunchCountdown
|
||||||
|
targetIso={LAUNCH_DATE_ISO}
|
||||||
|
arabic={isArabic}
|
||||||
|
launchedLabel={t("launched")}
|
||||||
|
labels={{
|
||||||
|
days: t("unitDays"),
|
||||||
|
hours: t("unitHours"),
|
||||||
|
minutes: t("unitMinutes"),
|
||||||
|
seconds: t("unitSeconds"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</HeroMotionItem>
|
||||||
|
|
||||||
|
<HeroMotionItem className="mt-12 flex w-full flex-col items-center justify-center gap-3 sm:w-auto sm:flex-row">
|
||||||
|
<Button asChild size="lg" className="w-full sm:w-auto">
|
||||||
|
<Link href={getLocalizedPath(localeKey, "/contact", siteSettings.defaultLocale)}>
|
||||||
|
<Mail className="h-4 w-4" />
|
||||||
|
{t("primaryCta")}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild variant="outline" size="lg" className="w-full sm:w-auto">
|
||||||
|
<Link href={getLocalizedPath(localeKey, "/", siteSettings.defaultLocale)}>
|
||||||
|
{t("secondaryCta")}
|
||||||
|
<DirectionIcon className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</HeroMotionItem>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</HeroContentMotion>
|
</HeroContentMotion>
|
||||||
</HeroShell>
|
</HeroShell>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export default async function AdminMaintenancePage({
|
|||||||
headerDescription={copy.subtitle}
|
headerDescription={copy.subtitle}
|
||||||
>
|
>
|
||||||
<MotionFade delay={0.1}>
|
<MotionFade delay={0.1}>
|
||||||
<AppCard>
|
<AppCard layer="single">
|
||||||
<CardContent className="space-y-4 p-6">
|
<CardContent className="space-y-4 p-6">
|
||||||
<p className="text-sm text-muted-foreground">{copy.maintenanceText}</p>
|
<p className="text-sm text-muted-foreground">{copy.maintenanceText}</p>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { MediaKind } from "@/lib/db/enums";
|
import { eq } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { isRedirectError } from "next/dist/client/components/redirect-error";
|
import { isRedirectError } from "next/dist/client/components/redirect-error";
|
||||||
@@ -11,10 +11,9 @@ import { withFlash } from "@/lib/admin-feedback";
|
|||||||
import { countMediaUsageReferences, getMediaAssetById } from "@/lib/media";
|
import { countMediaUsageReferences, getMediaAssetById } from "@/lib/media";
|
||||||
import { createStandaloneMediaAsset, deleteMediaAssetAndFile } from "@/lib/media-service";
|
import { createStandaloneMediaAsset, deleteMediaAssetAndFile } from "@/lib/media-service";
|
||||||
import { isManagedMediaFilePath } from "@/lib/media-storage";
|
import { isManagedMediaFilePath } from "@/lib/media-storage";
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
|
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { mediaAsset } from "@/lib/db/schema";
|
import { mediaAsset } from "@/lib/db/schema";
|
||||||
|
import { MediaKind } from "@/lib/db/enums";
|
||||||
|
|
||||||
async function ensureAdmin() {
|
async function ensureAdmin() {
|
||||||
if (!(await isAdminAuthenticated())) {
|
if (!(await isAdminAuthenticated())) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { eq, inArray } from "drizzle-orm";
|
import { and, count, eq, inArray } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { isRedirectError } from "next/dist/client/components/redirect-error";
|
import { isRedirectError } from "next/dist/client/components/redirect-error";
|
||||||
@@ -30,21 +30,10 @@ import { getSiteSettings } from "@/lib/app-config";
|
|||||||
import {
|
import {
|
||||||
assetInputSchema,
|
assetInputSchema,
|
||||||
categoryInputSchema,
|
categoryInputSchema,
|
||||||
projectDraftInputSchema,
|
|
||||||
projectInputSchema,
|
projectInputSchema,
|
||||||
sectionInputSchema,
|
sectionInputSchema,
|
||||||
} from "@/lib/portfolio-validation";
|
} from "@/lib/portfolio-validation";
|
||||||
|
|
||||||
/** Build a URL-safe slug from a title, falling back to a unique draft slug. */
|
|
||||||
function slugifyForDraft(input: string): string {
|
|
||||||
const base = input
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]+/g, "-")
|
|
||||||
.replace(/^-+|-+$/g, "");
|
|
||||||
|
|
||||||
return base || `draft-${Date.now()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureAdmin() {
|
async function ensureAdmin() {
|
||||||
if (!(await isAdminAuthenticated())) {
|
if (!(await isAdminAuthenticated())) {
|
||||||
await clearAdminSessionCookie();
|
await clearAdminSessionCookie();
|
||||||
@@ -101,18 +90,14 @@ function parseZodError(error: ZodError) {
|
|||||||
return error.issues[0]?.message ?? "Validierung fehlgeschlagen.";
|
return error.issues[0]?.message ?? "Validierung fehlgeschlagen.";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Postgres unique-violation (code 23505, was Prisma's "P2002"). The error shape
|
// Postgres unique-violation SQLSTATE (was Prisma's P2002).
|
||||||
// differs between drivers (postgres.js exposes `.code`; PGlite in tests nests it
|
|
||||||
// or only in the message), so check code, cause.code, and the message text.
|
|
||||||
function isUniqueViolation(error: unknown): boolean {
|
function isUniqueViolation(error: unknown): boolean {
|
||||||
if (typeof error !== "object" || error === null) {
|
return (
|
||||||
return false;
|
typeof error === "object" &&
|
||||||
}
|
error !== null &&
|
||||||
const e = error as { code?: string; cause?: { code?: string }; message?: string };
|
"code" in error &&
|
||||||
if (e.code === "23505" || e.cause?.code === "23505") {
|
(error as { code?: string }).code === "23505"
|
||||||
return true;
|
);
|
||||||
}
|
|
||||||
return typeof e.message === "string" && /23505|duplicate key|unique constraint/i.test(e.message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function revalidatePortfolioPages() {
|
async function revalidatePortfolioPages() {
|
||||||
@@ -154,23 +139,15 @@ export async function upsertCategoryAction(formData: FormData) {
|
|||||||
isActive: normalizeCheckboxValue(formData, "isActive"),
|
isActive: normalizeCheckboxValue(formData, "isActive"),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Categories and projects share the public `/portfolio/[slug]` route, so a
|
const { id: categoryId, ...categoryValues } = parsed;
|
||||||
// slug may only exist on one side. Categories win at resolve time, which
|
|
||||||
// would silently hide a project with the same slug.
|
|
||||||
const [projectWithSlug] = await db
|
|
||||||
.select({ id: portfolioProject.id })
|
|
||||||
.from(portfolioProject)
|
|
||||||
.where(eq(portfolioProject.slug, parsed.slug))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (projectWithSlug) {
|
if (categoryId) {
|
||||||
throw new Error("Kategorie Slug ist bereits als Projekt Slug vergeben.");
|
await db
|
||||||
}
|
.update(category)
|
||||||
|
.set({ ...categoryValues, updatedAt: new Date() })
|
||||||
if (parsed.id) {
|
.where(eq(category.id, categoryId));
|
||||||
await db.update(category).set(parsed).where(eq(category.id, parsed.id));
|
|
||||||
} else {
|
} else {
|
||||||
await db.insert(category).values(parsed);
|
await db.insert(category).values(categoryValues);
|
||||||
}
|
}
|
||||||
|
|
||||||
await revalidatePortfolioPages();
|
await revalidatePortfolioPages();
|
||||||
@@ -185,9 +162,7 @@ export async function upsertCategoryAction(formData: FormData) {
|
|||||||
? parseZodError(error)
|
? parseZodError(error)
|
||||||
: isUniqueViolation(error)
|
: isUniqueViolation(error)
|
||||||
? "Kategorie Slug muss eindeutig sein."
|
? "Kategorie Slug muss eindeutig sein."
|
||||||
: error instanceof Error && error.message.includes("Slug")
|
: "Kategorie konnte nicht gespeichert werden.";
|
||||||
? error.message
|
|
||||||
: "Kategorie konnte nicht gespeichert werden.";
|
|
||||||
|
|
||||||
redirect(withFlash(redirectPath, { error: message }));
|
redirect(withFlash(redirectPath, { error: message }));
|
||||||
}
|
}
|
||||||
@@ -200,9 +175,12 @@ export async function deleteCategoryAction(formData: FormData) {
|
|||||||
const id = String(formData.get("id") ?? "");
|
const id = String(formData.get("id") ?? "");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const projectCount = await db.$count(portfolioProject, eq(portfolioProject.categoryId, id));
|
const [projectCountRow] = await db
|
||||||
|
.select({ value: count() })
|
||||||
|
.from(portfolioProject)
|
||||||
|
.where(eq(portfolioProject.categoryId, id));
|
||||||
|
|
||||||
if (projectCount > 0) {
|
if ((projectCountRow?.value ?? 0) > 0) {
|
||||||
redirect(withFlash(redirectPath, { error: "Kategorie mit Projekten kann nicht geloescht werden." }));
|
redirect(withFlash(redirectPath, { error: "Kategorie mit Projekten kann nicht geloescht werden." }));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,67 +208,28 @@ export async function saveProjectAction(formData: FormData) {
|
|||||||
const createdMediaAssetIds: string[] = [];
|
const createdMediaAssetIds: string[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const intent = String(formData.get("intent") ?? "save");
|
const sections = parseJsonArray(formData.get("sections"), "sections").map((section, index) =>
|
||||||
const isDraft = intent === "draft";
|
|
||||||
|
|
||||||
const parseSection = (section: Record<string, unknown>, index: number) =>
|
|
||||||
sectionInputSchema.parse({
|
sectionInputSchema.parse({
|
||||||
...section,
|
...section,
|
||||||
media: section.media ? mediaFieldInputSchema.parse(section.media) : undefined,
|
media: section.media ? mediaFieldInputSchema.parse(section.media) : undefined,
|
||||||
sortOrder: section.sortOrder ?? index,
|
sortOrder: section.sortOrder ?? index,
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const parseAsset = (asset: Record<string, unknown>, index: number) =>
|
const assets = parseJsonArray(formData.get("assets"), "assets").map((asset, index) =>
|
||||||
assetInputSchema.parse({
|
assetInputSchema.parse({
|
||||||
...asset,
|
...asset,
|
||||||
media: asset.media ? mediaFieldInputSchema.parse(asset.media) : undefined,
|
media: asset.media ? mediaFieldInputSchema.parse(asset.media) : undefined,
|
||||||
sortOrder: asset.sortOrder ?? index,
|
sortOrder: asset.sortOrder ?? index,
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
// A draft keeps only the entries that are already valid; a full save
|
|
||||||
// validates every entry strictly.
|
|
||||||
const sections = parseJsonArray(formData.get("sections"), "sections").flatMap((section, index) => {
|
|
||||||
if (!isDraft) {
|
|
||||||
return [parseSection(section, index)];
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return [parseSection(section, index)];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const assets = parseJsonArray(formData.get("assets"), "assets").flatMap((asset, index) => {
|
|
||||||
if (!isDraft) {
|
|
||||||
return [parseAsset(asset, index)];
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return [parseAsset(asset, index)];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const coverMedia = parseJsonObject(formData.get("coverMedia"), "coverMedia");
|
const coverMedia = parseJsonObject(formData.get("coverMedia"), "coverMedia");
|
||||||
|
|
||||||
const rawSlug = String(formData.get("slug") ?? "").trim();
|
const parsed = projectInputSchema.parse({
|
||||||
const slug =
|
|
||||||
isDraft && !rawSlug
|
|
||||||
? slugifyForDraft(
|
|
||||||
String(formData.get("titleDe") ?? "") ||
|
|
||||||
String(formData.get("titleEn") ?? "") ||
|
|
||||||
String(formData.get("titleAr") ?? ""),
|
|
||||||
)
|
|
||||||
: rawSlug;
|
|
||||||
const rawYear = String(formData.get("projectYear") ?? "").trim();
|
|
||||||
const projectYear = isDraft && !rawYear ? String(new Date().getFullYear()) : rawYear;
|
|
||||||
|
|
||||||
const parsed = (isDraft ? projectDraftInputSchema : projectInputSchema).parse({
|
|
||||||
id: String(formData.get("id") ?? "").trim() || undefined,
|
id: String(formData.get("id") ?? "").trim() || undefined,
|
||||||
categoryId: String(formData.get("categoryId") ?? ""),
|
categoryId: String(formData.get("categoryId") ?? ""),
|
||||||
slug,
|
slug: String(formData.get("slug") ?? ""),
|
||||||
viewMode: String(formData.get("viewMode") ?? "GRID"),
|
viewMode: String(formData.get("viewMode") ?? "GRID"),
|
||||||
titleAr: String(formData.get("titleAr") ?? ""),
|
titleAr: String(formData.get("titleAr") ?? ""),
|
||||||
titleEn: String(formData.get("titleEn") ?? ""),
|
titleEn: String(formData.get("titleEn") ?? ""),
|
||||||
@@ -299,7 +238,7 @@ export async function saveProjectAction(formData: FormData) {
|
|||||||
summaryEn: String(formData.get("summaryEn") ?? ""),
|
summaryEn: String(formData.get("summaryEn") ?? ""),
|
||||||
summaryDe: String(formData.get("summaryDe") ?? ""),
|
summaryDe: String(formData.get("summaryDe") ?? ""),
|
||||||
clientName: String(formData.get("clientName") ?? ""),
|
clientName: String(formData.get("clientName") ?? ""),
|
||||||
projectYear,
|
projectYear: String(formData.get("projectYear") ?? ""),
|
||||||
serviceLabelAr: String(formData.get("serviceLabelAr") ?? ""),
|
serviceLabelAr: String(formData.get("serviceLabelAr") ?? ""),
|
||||||
serviceLabelEn: String(formData.get("serviceLabelEn") ?? ""),
|
serviceLabelEn: String(formData.get("serviceLabelEn") ?? ""),
|
||||||
serviceLabelDe: String(formData.get("serviceLabelDe") ?? ""),
|
serviceLabelDe: String(formData.get("serviceLabelDe") ?? ""),
|
||||||
@@ -308,21 +247,11 @@ export async function saveProjectAction(formData: FormData) {
|
|||||||
coverMedia: coverMedia ? mediaFieldInputSchema.parse(coverMedia) : undefined,
|
coverMedia: coverMedia ? mediaFieldInputSchema.parse(coverMedia) : undefined,
|
||||||
sortOrder: String(formData.get("sortOrder") ?? "0"),
|
sortOrder: String(formData.get("sortOrder") ?? "0"),
|
||||||
isFeatured: normalizeCheckboxValue(formData, "isFeatured"),
|
isFeatured: normalizeCheckboxValue(formData, "isFeatured"),
|
||||||
isPublished: isDraft ? false : normalizeCheckboxValue(formData, "isPublished"),
|
isPublished: normalizeCheckboxValue(formData, "isPublished"),
|
||||||
sections,
|
sections,
|
||||||
assets,
|
assets,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [categoryWithSlug] = await db
|
|
||||||
.select({ id: category.id })
|
|
||||||
.from(category)
|
|
||||||
.where(eq(category.slug, parsed.slug))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (categoryWithSlug) {
|
|
||||||
throw new Error("Projekt Slug ist bereits als Kategorie Slug vergeben.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingProject = parsed.id
|
const existingProject = parsed.id
|
||||||
? (
|
? (
|
||||||
await db
|
await db
|
||||||
@@ -476,12 +405,16 @@ export async function saveProjectAction(formData: FormData) {
|
|||||||
? new Date()
|
? new Date()
|
||||||
: existingProject?.publishedAt ?? new Date()
|
: existingProject?.publishedAt ?? new Date()
|
||||||
: null,
|
: null,
|
||||||
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(portfolioProject.id, parsed.id))
|
.where(eq(portfolioProject.id, parsed.id))
|
||||||
.returning()
|
.returning()
|
||||||
: await tx
|
: await tx
|
||||||
.insert(portfolioProject)
|
.insert(portfolioProject)
|
||||||
.values({ ...projectValues, publishedAt: parsed.isPublished ? new Date() : null })
|
.values({
|
||||||
|
...projectValues,
|
||||||
|
publishedAt: parsed.isPublished ? new Date() : null,
|
||||||
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
await tx.delete(portfolioSection).where(eq(portfolioSection.projectId, currentProject.id));
|
await tx.delete(portfolioSection).where(eq(portfolioSection.projectId, currentProject.id));
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export default async function AdminPortfolioProjectPage({
|
|||||||
</MotionFade>
|
</MotionFade>
|
||||||
|
|
||||||
<MotionFade delay={0.2}>
|
<MotionFade delay={0.2}>
|
||||||
<AppCard level={2}>
|
<AppCard level={2} layer="single">
|
||||||
<CardContent className="flex flex-col gap-4 p-6 lg:flex-row lg:items-center lg:justify-between">
|
<CardContent className="flex flex-col gap-4 p-6 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-foreground">{copy.dangerZone}</p>
|
<p className="text-sm font-medium text-foreground">{copy.dangerZone}</p>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { MediaUsageType } from "@/lib/db/enums";
|
import { inArray } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { isRedirectError } from "next/dist/client/components/redirect-error";
|
import { isRedirectError } from "next/dist/client/components/redirect-error";
|
||||||
@@ -12,9 +12,7 @@ import {
|
|||||||
SITE_SETTINGS_FAVICON_FIELD_KEY,
|
SITE_SETTINGS_FAVICON_FIELD_KEY,
|
||||||
SITE_SETTINGS_LOGO_DARK_FIELD_KEY,
|
SITE_SETTINGS_LOGO_DARK_FIELD_KEY,
|
||||||
SITE_SETTINGS_LOGO_LIGHT_FIELD_KEY,
|
SITE_SETTINGS_LOGO_LIGHT_FIELD_KEY,
|
||||||
getSeoSettings,
|
|
||||||
getSiteSettings,
|
getSiteSettings,
|
||||||
updateSeoSettings,
|
|
||||||
updateSiteSettings,
|
updateSiteSettings,
|
||||||
} from "@/lib/app-config";
|
} from "@/lib/app-config";
|
||||||
import { getAdminAppPath, toInternalAdminPath } from "@/lib/admin-routing";
|
import { getAdminAppPath, toInternalAdminPath } from "@/lib/admin-routing";
|
||||||
@@ -26,25 +24,15 @@ import {
|
|||||||
type SiteSettings,
|
type SiteSettings,
|
||||||
} from "@/lib/site-settings";
|
} from "@/lib/site-settings";
|
||||||
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
||||||
import {
|
|
||||||
normalizeKeywords,
|
|
||||||
normalizeSameAs,
|
|
||||||
normalizeStructuredDataType,
|
|
||||||
normalizeTwitterHandle,
|
|
||||||
normalizeVerificationToken,
|
|
||||||
type SeoSettings,
|
|
||||||
} from "@/lib/seo-settings";
|
|
||||||
import { isCheckedFormValue } from "@/lib/form-data";
|
|
||||||
import { replaceEntityMediaUsages } from "@/lib/media";
|
import { replaceEntityMediaUsages } from "@/lib/media";
|
||||||
import { resolveMediaSelection } from "@/lib/media-service";
|
import { resolveMediaSelection } from "@/lib/media-service";
|
||||||
import { routing } from "@/i18n/routing";
|
import { routing } from "@/i18n/routing";
|
||||||
import { getLocalizedPath } from "@/lib/locale";
|
import { getLocalizedPath } from "@/lib/locale";
|
||||||
import { removeManagedMediaFile } from "@/lib/media-storage";
|
import { removeManagedMediaFile } from "@/lib/media-storage";
|
||||||
import { mediaFieldInputSchema } from "@/lib/media-validation";
|
import { mediaFieldInputSchema } from "@/lib/media-validation";
|
||||||
import { inArray } from "drizzle-orm";
|
|
||||||
|
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { mediaAsset } from "@/lib/db/schema";
|
import { mediaAsset } from "@/lib/db/schema";
|
||||||
|
import { MediaUsageType } from "@/lib/db/enums";
|
||||||
|
|
||||||
async function ensureAdmin() {
|
async function ensureAdmin() {
|
||||||
if (!(await isAdminAuthenticated())) {
|
if (!(await isAdminAuthenticated())) {
|
||||||
@@ -340,63 +328,3 @@ export async function saveSiteLocalizationSettingsAction(formData: FormData) {
|
|||||||
redirect(withFlash(getAdminAppPath("/site-settings/localization"), { error: message }));
|
redirect(withFlash(getAdminAppPath("/site-settings/localization"), { error: message }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveSeoSettingsAction(formData: FormData) {
|
|
||||||
await ensureAdmin();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const currentSettings = await getSeoSettings();
|
|
||||||
const googleRaw = String(formData.get("googleSiteVerification") ?? "").trim();
|
|
||||||
const bingRaw = String(formData.get("bingSiteVerification") ?? "").trim();
|
|
||||||
const twitterRaw = String(formData.get("twitterHandle") ?? "").trim();
|
|
||||||
const googleSiteVerification = normalizeVerificationToken(googleRaw);
|
|
||||||
const bingSiteVerification = normalizeVerificationToken(bingRaw);
|
|
||||||
const twitterHandle = normalizeTwitterHandle(twitterRaw);
|
|
||||||
|
|
||||||
if (googleRaw && !googleSiteVerification) {
|
|
||||||
throw new Error("Google Verification Code darf nur Buchstaben, Zahlen, - und _ enthalten.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bingRaw && !bingSiteVerification) {
|
|
||||||
throw new Error("Bing Verification Code darf nur Buchstaben, Zahlen, - und _ enthalten.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (twitterRaw && !twitterHandle) {
|
|
||||||
throw new Error("X/Twitter Handle ist ungueltig (max. 15 Zeichen, Buchstaben/Zahlen/_).");
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedSettings: SeoSettings = {
|
|
||||||
...currentSettings,
|
|
||||||
allowIndexing: isCheckedFormValue(formData.get("allowIndexing")),
|
|
||||||
googleSiteVerification,
|
|
||||||
bingSiteVerification,
|
|
||||||
twitterHandle,
|
|
||||||
structuredDataType: normalizeStructuredDataType(formData.get("structuredDataType")),
|
|
||||||
structuredDataName: String(formData.get("structuredDataName") ?? "").trim().slice(0, 120),
|
|
||||||
structuredDataJobTitle: String(formData.get("structuredDataJobTitle") ?? "").trim().slice(0, 160),
|
|
||||||
sameAs: normalizeSameAs(formData.get("sameAs")),
|
|
||||||
locales: {
|
|
||||||
ar: { keywords: normalizeKeywords(formData.get("keywordsAr")) },
|
|
||||||
en: { keywords: normalizeKeywords(formData.get("keywordsEn")) },
|
|
||||||
de: { keywords: normalizeKeywords(formData.get("keywordsDe")) },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
await updateSeoSettings(parsedSettings);
|
|
||||||
const siteSettings = await getSiteSettings();
|
|
||||||
await revalidateSiteSettingsPages(siteSettings.defaultLocale);
|
|
||||||
revalidatePath("/sitemap.xml");
|
|
||||||
revalidatePath("/robots.txt");
|
|
||||||
revalidatePath(toInternalAdminPath("/site-settings/seo"));
|
|
||||||
redirect(withFlash(getAdminAppPath("/site-settings/seo"), { success: "SEO Einstellungen gespeichert." }));
|
|
||||||
} catch (error) {
|
|
||||||
if (isRedirectError(error)) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const message =
|
|
||||||
error instanceof Error ? error.message : "SEO Einstellungen konnten nicht gespeichert werden.";
|
|
||||||
|
|
||||||
redirect(withFlash(getAdminAppPath("/site-settings/seo"), { error: message }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
import { redirect } from "next/navigation";
|
|
||||||
|
|
||||||
import { MotionFade } from "@/components/motion-fade";
|
|
||||||
import { SeoSettingsForm } from "@/components/admin/seo-settings-form";
|
|
||||||
import { AdminDashboardShell } from "@/components/admin/admin-dashboard-shell";
|
|
||||||
import { readFlash } from "@/lib/admin-feedback";
|
|
||||||
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
|
||||||
import { buildSiteUrl, getAdminAppPath } from "@/lib/admin-routing";
|
|
||||||
import {
|
|
||||||
getMaintenanceMode,
|
|
||||||
getSeoSettings,
|
|
||||||
getSiteSettings,
|
|
||||||
getSiteSettingsMediaBindings,
|
|
||||||
} from "@/lib/app-config";
|
|
||||||
import { getSiteUrl } from "@/lib/metadata";
|
|
||||||
import { INTERNAL_MANIFEST_PATH } from "@/lib/site-icons";
|
|
||||||
import { getPublishedPortfolioProjects } from "@/lib/portfolio";
|
|
||||||
import { buildSeoChecklist } from "@/lib/seo-report";
|
|
||||||
import buildSitemap from "@/app/sitemap";
|
|
||||||
|
|
||||||
import { saveSeoSettingsAction } from "../actions";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
const copy = {
|
|
||||||
title: "SEO",
|
|
||||||
subtitle: "Indexierung, Verifizierung, strukturierte Daten, Sitemap und robots.txt.",
|
|
||||||
overview: "Uebersicht",
|
|
||||||
maintenance: "Wartungsmodus",
|
|
||||||
uiKit: "UI Kit",
|
|
||||||
media: "Media",
|
|
||||||
siteSettings: "Settings",
|
|
||||||
brandSettings: "Brand",
|
|
||||||
localizationSettings: "Localization",
|
|
||||||
seoSettings: "SEO",
|
|
||||||
smtp: "SMTP",
|
|
||||||
portfolio: "Portfolio",
|
|
||||||
logout: "Ausloggen",
|
|
||||||
backToSite: "Zur Website",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function AdminSeoSettingsPage({
|
|
||||||
searchParams,
|
|
||||||
}: {
|
|
||||||
searchParams?: Promise<{ success?: string; error?: string }>;
|
|
||||||
}) {
|
|
||||||
const flash = readFlash(await searchParams);
|
|
||||||
|
|
||||||
if (!(await isAdminAuthenticated())) {
|
|
||||||
redirect(getAdminAppPath("/"));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function logoutAction() {
|
|
||||||
"use server";
|
|
||||||
|
|
||||||
await clearAdminSessionCookie();
|
|
||||||
redirect(getAdminAppPath("/"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const [seo, siteSettings, bindings, maintenanceEnabled, projects, sitemapEntries] = await Promise.all([
|
|
||||||
getSeoSettings(),
|
|
||||||
getSiteSettings(),
|
|
||||||
getSiteSettingsMediaBindings(),
|
|
||||||
getMaintenanceMode(),
|
|
||||||
getPublishedPortfolioProjects().catch(() => []),
|
|
||||||
buildSitemap().catch(() => []),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const checks = buildSeoChecklist({
|
|
||||||
seo,
|
|
||||||
settings: siteSettings,
|
|
||||||
bindings,
|
|
||||||
maintenanceEnabled,
|
|
||||||
publishedProjectCount: projects.length,
|
|
||||||
sitemapEntryCount: sitemapEntries.length,
|
|
||||||
siteUrl: getSiteUrl().origin,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AdminDashboardShell
|
|
||||||
copy={copy}
|
|
||||||
active="site-settings"
|
|
||||||
flash={flash}
|
|
||||||
siteSettingsChild="seo"
|
|
||||||
logoutAction={logoutAction}
|
|
||||||
headerTitle={copy.title}
|
|
||||||
headerDescription={copy.subtitle}
|
|
||||||
>
|
|
||||||
<MotionFade delay={0.16}>
|
|
||||||
<SeoSettingsForm
|
|
||||||
action={saveSeoSettingsAction}
|
|
||||||
settings={seo}
|
|
||||||
checks={checks}
|
|
||||||
sitemapEntryCount={sitemapEntries.length}
|
|
||||||
links={{
|
|
||||||
sitemap: buildSiteUrl("/sitemap.xml"),
|
|
||||||
robots: buildSiteUrl("/robots.txt"),
|
|
||||||
manifest: buildSiteUrl(INTERNAL_MANIFEST_PATH),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</MotionFade>
|
|
||||||
</AdminDashboardShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { default } from "../../../_admin/site-settings/seo/page";
|
|
||||||
@@ -295,223 +295,6 @@ html[lang="ar"] .eyebrow {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Layered hero backdrop (HeroMotionBackdrop) ---- */
|
|
||||||
.hero-backdrop-base {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background: hsl(var(--background));
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-backdrop-grid {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background-image:
|
|
||||||
linear-gradient(hsl(var(--border) / 0.55) 1px, transparent 1px),
|
|
||||||
linear-gradient(90deg, hsl(var(--border) / 0.55) 1px, transparent 1px);
|
|
||||||
background-size: 30px 30px;
|
|
||||||
background-position: center top;
|
|
||||||
opacity: 0.55;
|
|
||||||
-webkit-mask-image: radial-gradient(ellipse 78% 58% at 50% 30%, #000 0%, transparent 72%);
|
|
||||||
mask-image: radial-gradient(ellipse 78% 58% at 50% 30%, #000 0%, transparent 72%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .hero-backdrop-grid {
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-backdrop-glow {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background:
|
|
||||||
radial-gradient(58% 46% at 50% 20%, hsl(var(--brand-primary) / 0.16), transparent 70%),
|
|
||||||
radial-gradient(46% 42% at 80% 10%, hsl(var(--brand-secondary) / 0.13), transparent 68%),
|
|
||||||
radial-gradient(48% 44% at 16% 18%, hsl(var(--brand-primary) / 0.1), transparent 66%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-orb {
|
|
||||||
position: absolute;
|
|
||||||
border-radius: 9999px;
|
|
||||||
filter: blur(64px);
|
|
||||||
will-change: transform;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-orb-1 {
|
|
||||||
top: -10%;
|
|
||||||
inset-inline-start: -8%;
|
|
||||||
height: 32rem;
|
|
||||||
width: 32rem;
|
|
||||||
background: hsl(var(--brand-primary) / 0.18);
|
|
||||||
animation: hero-orb-drift-a 22s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-orb-2 {
|
|
||||||
top: -6%;
|
|
||||||
inset-inline-end: -10%;
|
|
||||||
height: 28rem;
|
|
||||||
width: 28rem;
|
|
||||||
background: hsl(var(--brand-secondary) / 0.15);
|
|
||||||
animation: hero-orb-drift-b 27s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-orb-3 {
|
|
||||||
bottom: -22%;
|
|
||||||
inset-inline-start: 24%;
|
|
||||||
height: 26rem;
|
|
||||||
width: 26rem;
|
|
||||||
background: hsl(var(--brand-primary) / 0.1);
|
|
||||||
animation: hero-orb-drift-a 31s ease-in-out infinite reverse;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-backdrop-noise {
|
|
||||||
position: absolute;
|
|
||||||
inset: -8%;
|
|
||||||
opacity: 0.26;
|
|
||||||
mix-blend-mode: soft-light;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .hero-backdrop-noise {
|
|
||||||
opacity: 0.1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-backdrop-floor {
|
|
||||||
position: absolute;
|
|
||||||
inset-inline: 0;
|
|
||||||
bottom: 0;
|
|
||||||
height: 40%;
|
|
||||||
background: linear-gradient(to bottom, transparent, hsl(var(--background)) 92%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-backdrop.is-compact .hero-backdrop-grid {
|
|
||||||
opacity: 0.32;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-backdrop.is-compact .hero-orb {
|
|
||||||
opacity: 0.7;
|
|
||||||
filter: blur(72px);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes hero-orb-drift-a {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
transform: translate3d(0, 0, 0) scale(1);
|
|
||||||
}
|
|
||||||
33% {
|
|
||||||
transform: translate3d(70px, -48px, 0) scale(1.1);
|
|
||||||
}
|
|
||||||
66% {
|
|
||||||
transform: translate3d(-42px, 26px, 0) scale(0.94);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes hero-orb-drift-b {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
transform: translate3d(0, 0, 0) scale(1);
|
|
||||||
}
|
|
||||||
33% {
|
|
||||||
transform: translate3d(-80px, 42px, 0) scale(0.92);
|
|
||||||
}
|
|
||||||
66% {
|
|
||||||
transform: translate3d(38px, -24px, 0) scale(1.08);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.hero-orb {
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Hero entrance ----
|
|
||||||
Per-element rise is disabled: the site `template.tsx` now runs one unified
|
|
||||||
page-transition animation on every navigation instead. ---- */
|
|
||||||
.hero-rise {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-content > *:nth-child(1) {
|
|
||||||
animation-delay: 0.06s;
|
|
||||||
}
|
|
||||||
.hero-content > *:nth-child(2) {
|
|
||||||
animation-delay: 0.14s;
|
|
||||||
}
|
|
||||||
.hero-content > *:nth-child(3) {
|
|
||||||
animation-delay: 0.22s;
|
|
||||||
}
|
|
||||||
.hero-content > *:nth-child(4) {
|
|
||||||
animation-delay: 0.3s;
|
|
||||||
}
|
|
||||||
.hero-content > *:nth-child(5) {
|
|
||||||
animation-delay: 0.38s;
|
|
||||||
}
|
|
||||||
.hero-content > *:nth-child(6) {
|
|
||||||
animation-delay: 0.46s;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes hero-rise-in {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(18px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Focused glow directly behind the hero title */
|
|
||||||
.hero-title {
|
|
||||||
position: relative;
|
|
||||||
isolation: isolate;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-title::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
left: 50%;
|
|
||||||
top: 46%;
|
|
||||||
z-index: -1;
|
|
||||||
width: 84%;
|
|
||||||
height: 74%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
background: radial-gradient(closest-side, hsl(var(--brand-primary) / 0.2), transparent 78%);
|
|
||||||
filter: blur(48px);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .hero-title::before {
|
|
||||||
background: radial-gradient(closest-side, hsl(var(--brand-primary) / 0.3), transparent 78%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Scroll cue pinned to the bottom of the home hero */
|
|
||||||
.hero-scroll-anchor {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 1.5rem;
|
|
||||||
left: 50%;
|
|
||||||
z-index: 10;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
opacity: 0;
|
|
||||||
animation: hero-fade-in 0.6s ease 0.7s forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-scroll-anchor:hover {
|
|
||||||
transform: translateX(-50%) translateY(2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes hero-fade-in {
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.hero-rise,
|
|
||||||
.hero-scroll-anchor {
|
|
||||||
animation: none;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-title-line {
|
.hero-title-line {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding-inline: 0.02em;
|
padding-inline: 0.02em;
|
||||||
|
|||||||
@@ -1,49 +1,15 @@
|
|||||||
import type { MetadataRoute } from "next";
|
import type { MetadataRoute } from "next";
|
||||||
import { unstable_noStore as noStore } from "next/cache";
|
|
||||||
|
|
||||||
import { getMaintenanceMode, getSeoSettings } from "@/lib/app-config";
|
export default function robots(): MetadataRoute.Robots {
|
||||||
import { INTERNAL_ADMIN_PREFIX } from "@/lib/admin-routing";
|
const siteUrl = new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "https://mohfarawati.de");
|
||||||
import { getSiteUrl } from "@/lib/metadata";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
/** Paths that must never be crawled even when indexing is enabled. */
|
|
||||||
export const ROBOTS_DISALLOWED_PATHS = [
|
|
||||||
INTERNAL_ADMIN_PREFIX,
|
|
||||||
"/root",
|
|
||||||
"/api/",
|
|
||||||
"/success",
|
|
||||||
"/coming-soon",
|
|
||||||
"/*/success",
|
|
||||||
"/*/coming-soon",
|
|
||||||
];
|
|
||||||
|
|
||||||
export function buildRobots(input: { indexable: boolean }): MetadataRoute.Robots {
|
|
||||||
const siteUrl = getSiteUrl();
|
|
||||||
|
|
||||||
if (!input.indexable) {
|
|
||||||
return {
|
|
||||||
rules: [{ userAgent: "*", disallow: "/" }],
|
|
||||||
host: siteUrl.origin,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rules: [
|
rules: [
|
||||||
{
|
{
|
||||||
userAgent: "*",
|
userAgent: "*",
|
||||||
allow: "/",
|
disallow: ["/admin-internal"],
|
||||||
disallow: ROBOTS_DISALLOWED_PATHS,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
sitemap: new URL("/sitemap.xml", siteUrl).toString(),
|
sitemap: new URL("/sitemap.xml", siteUrl).toString(),
|
||||||
host: siteUrl.origin,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function robots(): Promise<MetadataRoute.Robots> {
|
|
||||||
noStore();
|
|
||||||
const [seo, maintenanceEnabled] = await Promise.all([getSeoSettings(), getMaintenanceMode()]);
|
|
||||||
|
|
||||||
return buildRobots({ indexable: seo.allowIndexing && !maintenanceEnabled });
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export { default } from "../../../_admin/site-settings/seo/page";
|
|
||||||
@@ -2,111 +2,72 @@ import type { MetadataRoute } from "next";
|
|||||||
import { unstable_noStore as noStore } from "next/cache";
|
import { unstable_noStore as noStore } from "next/cache";
|
||||||
|
|
||||||
import { routing } from "@/i18n/routing";
|
import { routing } from "@/i18n/routing";
|
||||||
import { getMaintenanceMode, getSeoSettings, getSiteSettings } from "@/lib/app-config";
|
import { getSiteSettings } from "@/lib/app-config";
|
||||||
import { getLocalizedPath, type AppLocale } from "@/lib/locale";
|
import { getLocalizedPath } from "@/lib/locale";
|
||||||
import { toAbsoluteUrl } from "@/lib/metadata";
|
import { getPublishedPortfolioProjects } from "@/lib/portfolio";
|
||||||
import { getActivePortfolioCategories, getPublishedPortfolioProjects } from "@/lib/portfolio";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
function getSiteUrl(): URL {
|
||||||
|
return new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "https://mohfarawati.de");
|
||||||
|
}
|
||||||
|
|
||||||
type EntryOptions = Pick<MetadataRoute.Sitemap[number], "changeFrequency" | "priority" | "lastModified">;
|
function toAbsoluteUrl(pathname: string): string {
|
||||||
|
return new URL(pathname, getSiteUrl()).toString();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
function buildLocalizedEntries(
|
||||||
* One entry per locale for a path, each carrying hreflang alternates so search
|
|
||||||
* engines link the three language versions together.
|
|
||||||
*/
|
|
||||||
export function buildLocalizedEntries(
|
|
||||||
pathname: string,
|
pathname: string,
|
||||||
defaultLocale: AppLocale,
|
defaultLocale: "de" | "en" | "ar",
|
||||||
options?: EntryOptions,
|
options?: Pick<MetadataRoute.Sitemap[number], "changeFrequency" | "priority" | "lastModified">,
|
||||||
): MetadataRoute.Sitemap {
|
): MetadataRoute.Sitemap {
|
||||||
const languages = Object.fromEntries(
|
|
||||||
routing.locales.map((locale) => [locale, toAbsoluteUrl(getLocalizedPath(locale, pathname, defaultLocale))]),
|
|
||||||
) as Record<AppLocale, string>;
|
|
||||||
|
|
||||||
return routing.locales.map((locale) => ({
|
return routing.locales.map((locale) => ({
|
||||||
url: languages[locale],
|
url: toAbsoluteUrl(getLocalizedPath(locale, pathname, defaultLocale)),
|
||||||
lastModified: options?.lastModified,
|
lastModified: options?.lastModified,
|
||||||
changeFrequency: options?.changeFrequency,
|
changeFrequency: options?.changeFrequency,
|
||||||
priority: options?.priority,
|
priority: options?.priority,
|
||||||
alternates: {
|
|
||||||
languages: {
|
|
||||||
...languages,
|
|
||||||
"x-default": languages[defaultLocale],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||||
noStore();
|
noStore();
|
||||||
const [siteSettings, seo, maintenanceEnabled] = await Promise.all([
|
const siteSettings = await getSiteSettings();
|
||||||
getSiteSettings(),
|
|
||||||
getSeoSettings(),
|
|
||||||
getMaintenanceMode(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// While the site is hidden (maintenance) or indexing is off, publish an
|
|
||||||
// empty sitemap instead of advertising URLs that redirect or are noindex.
|
|
||||||
if (maintenanceEnabled || !seo.allowIndexing) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultLocale = siteSettings.defaultLocale;
|
|
||||||
|
|
||||||
let projects: Awaited<ReturnType<typeof getPublishedPortfolioProjects>> = [];
|
let projects: Awaited<ReturnType<typeof getPublishedPortfolioProjects>> = [];
|
||||||
let categories: Awaited<ReturnType<typeof getActivePortfolioCategories>> = [];
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
[projects, categories] = await Promise.all([
|
projects = await getPublishedPortfolioProjects();
|
||||||
getPublishedPortfolioProjects(),
|
|
||||||
getActivePortfolioCategories(),
|
|
||||||
]);
|
|
||||||
} catch {
|
} catch {
|
||||||
projects = [];
|
projects = [];
|
||||||
categories = [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only categories that actually have published work get a landing URL;
|
const categories = Array.from(
|
||||||
// an empty category page has nothing to index.
|
new Map(projects.map((project) => [project.category.slug, project.category])).values(),
|
||||||
const categoriesWithProjects = categories.filter((category) =>
|
|
||||||
projects.some((project) => project.category.slug === category.slug),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const latestProjectDate = projects.reduce<Date | undefined>((latest, project) => {
|
|
||||||
const date = project.publishedAt ?? undefined;
|
|
||||||
|
|
||||||
return date && (!latest || date > latest) ? date : latest;
|
|
||||||
}, undefined);
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
...buildLocalizedEntries("/", defaultLocale, {
|
...buildLocalizedEntries("/", siteSettings.defaultLocale, {
|
||||||
changeFrequency: "weekly",
|
changeFrequency: "weekly",
|
||||||
priority: 1,
|
priority: 1,
|
||||||
lastModified: latestProjectDate,
|
|
||||||
}),
|
}),
|
||||||
...buildLocalizedEntries("/about", defaultLocale, {
|
...buildLocalizedEntries("/about", siteSettings.defaultLocale, {
|
||||||
changeFrequency: "monthly",
|
changeFrequency: "monthly",
|
||||||
priority: 0.8,
|
priority: 0.8,
|
||||||
}),
|
}),
|
||||||
...buildLocalizedEntries("/portfolio", defaultLocale, {
|
...buildLocalizedEntries("/portfolio", siteSettings.defaultLocale, {
|
||||||
changeFrequency: "weekly",
|
changeFrequency: "weekly",
|
||||||
priority: 0.9,
|
priority: 0.9,
|
||||||
lastModified: latestProjectDate,
|
|
||||||
}),
|
}),
|
||||||
...categoriesWithProjects.flatMap((category) =>
|
...categories.flatMap((category) =>
|
||||||
buildLocalizedEntries(`/portfolio/${category.slug}`, defaultLocale, {
|
buildLocalizedEntries(`/portfolio/category/${category.slug}`, siteSettings.defaultLocale, {
|
||||||
changeFrequency: "weekly",
|
changeFrequency: "weekly",
|
||||||
priority: 0.8,
|
priority: 0.8,
|
||||||
lastModified: latestProjectDate,
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
...buildLocalizedEntries("/contact", defaultLocale, {
|
...buildLocalizedEntries("/contact", siteSettings.defaultLocale, {
|
||||||
changeFrequency: "monthly",
|
changeFrequency: "monthly",
|
||||||
priority: 0.7,
|
priority: 0.7,
|
||||||
}),
|
}),
|
||||||
...projects.flatMap((project) =>
|
...projects.flatMap((project) =>
|
||||||
buildLocalizedEntries(`/portfolio/${project.slug}`, defaultLocale, {
|
buildLocalizedEntries(`/portfolio/${project.slug}`, siteSettings.defaultLocale, {
|
||||||
lastModified: project.publishedAt ?? undefined,
|
lastModified: project.publishedAt ?? undefined,
|
||||||
changeFrequency: "monthly",
|
changeFrequency: "monthly",
|
||||||
priority: 0.8,
|
priority: 0.8,
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ export const dynamic = "force-dynamic";
|
|||||||
|
|
||||||
const CONTENT_TYPES: Record<string, string> = {
|
const CONTENT_TYPES: Record<string, string> = {
|
||||||
".ico": "image/x-icon",
|
".ico": "image/x-icon",
|
||||||
".gif": "image/gif",
|
|
||||||
".jpg": "image/jpeg",
|
".jpg": "image/jpeg",
|
||||||
".jpeg": "image/jpeg",
|
".jpeg": "image/jpeg",
|
||||||
".png": "image/png",
|
".png": "image/png",
|
||||||
@@ -31,30 +30,15 @@ export async function GET(_: Request, { params }: MediaFileRouteProps) {
|
|||||||
try {
|
try {
|
||||||
const absolutePath = resolveMediaUploadPath(publicPath);
|
const absolutePath = resolveMediaUploadPath(publicPath);
|
||||||
const fileBuffer = await readFile(absolutePath);
|
const fileBuffer = await readFile(absolutePath);
|
||||||
const extension = path.extname(absolutePath).toLowerCase();
|
const contentType = CONTENT_TYPES[path.extname(absolutePath).toLowerCase()] ?? "application/octet-stream";
|
||||||
const contentType = CONTENT_TYPES[extension];
|
|
||||||
|
|
||||||
if (!contentType) {
|
return new NextResponse(fileBuffer, {
|
||||||
return new NextResponse("Not Found", { status: 404 });
|
status: 200,
|
||||||
}
|
headers: {
|
||||||
|
"Content-Type": contentType,
|
||||||
const headers: Record<string, string> = {
|
"Cache-Control": "public, max-age=31536000, immutable",
|
||||||
"Content-Type": contentType,
|
},
|
||||||
"Cache-Control": "public, max-age=31536000, immutable",
|
});
|
||||||
"X-Content-Type-Options": "nosniff",
|
|
||||||
};
|
|
||||||
|
|
||||||
// SVG is an active document type: sandbox it so an uploaded file can never
|
|
||||||
// run script or reach our origin even if it is opened directly.
|
|
||||||
if (extension === ".svg") {
|
|
||||||
headers["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (extension === ".pdf") {
|
|
||||||
headers["Content-Disposition"] = "inline";
|
|
||||||
}
|
|
||||||
|
|
||||||
return new NextResponse(new Uint8Array(fileBuffer), { status: 200, headers });
|
|
||||||
} catch {
|
} catch {
|
||||||
return new NextResponse("Not Found", {
|
return new NextResponse("Not Found", {
|
||||||
status: 404,
|
status: 404,
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
LogOut,
|
LogOut,
|
||||||
Palette,
|
Palette,
|
||||||
PlusSquare,
|
PlusSquare,
|
||||||
Search,
|
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
SwatchBook,
|
SwatchBook,
|
||||||
Tags,
|
Tags,
|
||||||
@@ -41,7 +40,6 @@ type AdminDashboardCopy = {
|
|||||||
siteSettings: string;
|
siteSettings: string;
|
||||||
brandSettings?: string;
|
brandSettings?: string;
|
||||||
localizationSettings?: string;
|
localizationSettings?: string;
|
||||||
seoSettings?: string;
|
|
||||||
marquee?: string;
|
marquee?: string;
|
||||||
smtp?: string;
|
smtp?: string;
|
||||||
logout: string;
|
logout: string;
|
||||||
@@ -52,7 +50,7 @@ type AdminDashboardShellProps = {
|
|||||||
copy: AdminDashboardCopy;
|
copy: AdminDashboardCopy;
|
||||||
active: "overview" | "maintenance" | "ui-kit" | "portfolio" | "media" | "site-settings" | "smtp" | "marquee";
|
active: "overview" | "maintenance" | "ui-kit" | "portfolio" | "media" | "site-settings" | "smtp" | "marquee";
|
||||||
portfolioChild?: "overview" | "projects" | "new-project" | "categories";
|
portfolioChild?: "overview" | "projects" | "new-project" | "categories";
|
||||||
siteSettingsChild?: "brand" | "localization" | "seo";
|
siteSettingsChild?: "brand" | "localization";
|
||||||
flash?: FlashMessages;
|
flash?: FlashMessages;
|
||||||
logoutAction: () => Promise<void>;
|
logoutAction: () => Promise<void>;
|
||||||
headerTitle: string;
|
headerTitle: string;
|
||||||
@@ -102,8 +100,6 @@ export async function AdminDashboardShell({
|
|||||||
: active === "site-settings"
|
: active === "site-settings"
|
||||||
? siteSettingsChild === "localization"
|
? siteSettingsChild === "localization"
|
||||||
? Languages
|
? Languages
|
||||||
: siteSettingsChild === "seo"
|
|
||||||
? Search
|
|
||||||
: siteSettingsChild === "brand"
|
: siteSettingsChild === "brand"
|
||||||
? Palette
|
? Palette
|
||||||
: Globe2
|
: Globe2
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export function MarqueeSettingsForm({
|
|||||||
|
|
||||||
<div className="grid gap-4 xl:grid-cols-4">
|
<div className="grid gap-4 xl:grid-cols-4">
|
||||||
{rowMeta.map((row) => (
|
{rowMeta.map((row) => (
|
||||||
<AppCard key={`de-${row.key}`} level={2} padding="sm" contentClassName="space-y-2">
|
<AppCard key={`de-${row.key}`} level={2} layer="single" padding="sm" className="space-y-2">
|
||||||
<Label htmlFor={`${row.key}-de`} className="text-sm font-semibold text-foreground">{row.label}</Label>
|
<Label htmlFor={`${row.key}-de`} className="text-sm font-semibold text-foreground">{row.label}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id={`${row.key}-de`}
|
id={`${row.key}-de`}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { MediaKind } from "@/lib/db/enums";
|
|||||||
import { Check, ImageIcon, Search, Trash2 } from "lucide-react";
|
import { Check, ImageIcon, Search, Trash2 } from "lucide-react";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { AppCard } from "@/components/ui/app-card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
import type { MediaOption } from "@/lib/media";
|
import type { MediaOption } from "@/lib/media";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -87,45 +89,29 @@ export function MediaFieldPicker({
|
|||||||
}, [serializedValue]);
|
}, [serializedValue]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<AppCard level={2} layer="single" padding="sm" className="space-y-4">
|
||||||
<input ref={hiddenInputRef} type="hidden" name={inputName} value={serializedValue} />
|
<input ref={hiddenInputRef} type="hidden" name={inputName} value={serializedValue} />
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 rounded-nested border border-border/70 bg-background p-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<div className="space-y-1">
|
||||||
{selectedOption ? (
|
<Label className="text-sm font-semibold text-foreground">{title}</Label>
|
||||||
<>
|
<p className="text-sm text-muted-foreground">
|
||||||
<img
|
Media must be selected from the
|
||||||
src={selectedOption.url}
|
{" "}
|
||||||
alt={selectedOption.label}
|
Media Library
|
||||||
className="h-14 w-14 shrink-0 rounded-nested border border-border/60 object-cover"
|
.
|
||||||
/>
|
</p>
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate text-sm font-medium text-foreground">{selectedOption.label}</p>
|
|
||||||
<p className="truncate text-xs text-muted-foreground">{selectedOption.source}</p>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-3 text-muted-foreground">
|
|
||||||
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-nested border border-dashed border-border/70">
|
|
||||||
<ImageIcon className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<span className="text-sm">Kein Medium ausgewählt</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
<div className="flex shrink-0 gap-2">
|
<Button type="button" variant="outline" onClick={() => setOpen(true)}>
|
||||||
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(true)}>
|
|
||||||
<ImageIcon className="h-4 w-4" />
|
<ImageIcon className="h-4 w-4" />
|
||||||
{selectedOption ? "Ändern" : "Auswählen"}
|
Select from Media
|
||||||
</Button>
|
</Button>
|
||||||
{canClear ? (
|
{canClear ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
|
||||||
className="text-destructive hover:text-destructive"
|
className="text-destructive hover:text-destructive"
|
||||||
title={clearLabel}
|
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onChange({
|
onChange({
|
||||||
...value,
|
...value,
|
||||||
@@ -138,12 +124,32 @@ export function MediaFieldPicker({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
<span className="sr-only">{clearLabel}</span>
|
{clearLabel}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AppCard layer="single" padding="sm" className="rounded-nested border-border/70">
|
||||||
|
{selectedOption ? (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<img
|
||||||
|
src={selectedOption.url}
|
||||||
|
alt={selectedOption.label}
|
||||||
|
className="h-16 w-16 rounded-nested object-cover"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium text-foreground">{selectedOption.label}</p>
|
||||||
|
<p className="truncate text-xs text-muted-foreground">{selectedOption.source}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-nested border border-dashed border-border/70 px-4 py-6 text-sm text-muted-foreground">
|
||||||
|
No media selected.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AppCard>
|
||||||
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogContent className="max-w-4xl">
|
<DialogContent className="max-w-4xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
@@ -209,6 +215,6 @@ export function MediaFieldPicker({
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</AppCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -486,7 +486,7 @@ export function MediaLibraryManager({
|
|||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<MotionFade delay={0.18}>
|
<MotionFade delay={0.18}>
|
||||||
<AppCard>
|
<AppCard layer="single">
|
||||||
<CardContent className="p-6 text-sm text-muted-foreground">
|
<CardContent className="p-6 text-sm text-muted-foreground">
|
||||||
{copy.empty}
|
{copy.empty}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ function CategoryLocaleFields({
|
|||||||
const descriptionKey = `description${locale.key}` as const;
|
const descriptionKey = `description${locale.key}` as const;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppCard key={`${idPrefix}-${locale.key}`} level={2} padding="sm" className="rounded-nested" contentClassName="space-y-4">
|
<AppCard key={`${idPrefix}-${locale.key}`} level={2} layer="single" padding="sm" className="space-y-4 rounded-nested">
|
||||||
<p className="text-sm font-medium text-foreground">{locale.label}</p>
|
<p className="text-sm font-medium text-foreground">{locale.label}</p>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -168,7 +168,7 @@ function CategoryStatusFields({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_220px]">
|
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_220px]">
|
||||||
<AppCard level={2} padding="sm" className="rounded-nested">
|
<AppCard level={2} layer="single" padding="sm" className="rounded-nested">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
{isActive ? (
|
{isActive ? (
|
||||||
<ShieldCheck className="mt-0.5 h-4 w-4 text-status-success" />
|
<ShieldCheck className="mt-0.5 h-4 w-4 text-status-success" />
|
||||||
@@ -395,7 +395,7 @@ export function PortfolioCategoriesManager({
|
|||||||
<DialogDescription>{copy.modalDescription}</DialogDescription>
|
<DialogDescription>{copy.modalDescription}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<AppCard level={2} padding="sm" className="rounded-nested">
|
<AppCard level={2} layer="single" padding="sm" className="rounded-nested">
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-sm font-medium text-foreground">1. Basics</p>
|
<p className="text-sm font-medium text-foreground">1. Basics</p>
|
||||||
@@ -426,7 +426,7 @@ export function PortfolioCategoriesManager({
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AppCard level={3}>
|
<AppCard level={3} layer="single">
|
||||||
<CardContent className="space-y-4 p-6">
|
<CardContent className="space-y-4 p-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Layers3 className="h-5 w-5 text-brand-primary" />
|
<Layers3 className="h-5 w-5 text-brand-primary" />
|
||||||
|
|||||||
@@ -1,22 +1,13 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
import { ExternalLink, FolderKanban, Plus, Tags, CheckCircle2 } from "lucide-react";
|
||||||
|
|
||||||
import { CheckCircle2, ExternalLink, FolderKanban, ImageOff, Plus, Star, Tags } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import { StatsCard } from "@/components/dashboard/stats-card";
|
import { StatsCard } from "@/components/dashboard/stats-card";
|
||||||
|
import { MotionFade } from "@/components/motion-fade";
|
||||||
import { PortfolioProjectActions } from "@/components/admin/portfolio-project-actions";
|
import { PortfolioProjectActions } from "@/components/admin/portfolio-project-actions";
|
||||||
import { AppCard } from "@/components/ui/app-card";
|
import { AppCard } from "@/components/ui/app-card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card } from "@/components/ui/card";
|
import { CardContent } from "@/components/ui/card";
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import { getAdminAppPath } from "@/lib/admin-routing";
|
import { getAdminAppPath } from "@/lib/admin-routing";
|
||||||
import { getSiteSettings } from "@/lib/app-config";
|
import { getSiteSettings } from "@/lib/app-config";
|
||||||
import { getLocalizedPath } from "@/lib/locale";
|
import { getLocalizedPath } from "@/lib/locale";
|
||||||
@@ -33,34 +24,17 @@ const copy = {
|
|||||||
all: "Alle",
|
all: "Alle",
|
||||||
newProject: "Neues Projekt",
|
newProject: "Neues Projekt",
|
||||||
newCategory: "Neues Kategorie",
|
newCategory: "Neues Kategorie",
|
||||||
view: "Auf der Website ansehen",
|
openProject: "Ansehen",
|
||||||
untitled: "Unbenanntes Projekt",
|
untitled: "Unbenanntes Projekt",
|
||||||
empty: "Noch keine Projekte vorhanden.",
|
empty: "Noch keine Projekte vorhanden.",
|
||||||
emptyFiltered: "Keine Projekte in dieser Auswahl.",
|
|
||||||
emptyHint: "Lege dein erstes Projekt an, um es hier zu verwalten.",
|
|
||||||
colProject: "Projekt",
|
|
||||||
colCategory: "Kategorie",
|
|
||||||
colYear: "Jahr",
|
|
||||||
colStatus: "Status",
|
|
||||||
colMode: "Layout",
|
|
||||||
colActions: "Aktionen",
|
|
||||||
published: "Published",
|
|
||||||
draft: "Draft",
|
|
||||||
featured: "Featured",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function categoryLabel(name: PortfolioCategoryView["name"]) {
|
|
||||||
return name.de || name.en || name.ar;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function PortfolioProjectsOverview({
|
export async function PortfolioProjectsOverview({
|
||||||
categories,
|
categories,
|
||||||
projects,
|
projects,
|
||||||
selectedCategory,
|
selectedCategory,
|
||||||
}: PortfolioProjectsOverviewProps) {
|
}: PortfolioProjectsOverviewProps) {
|
||||||
const siteSettings = await getSiteSettings();
|
const siteSettings = await getSiteSettings();
|
||||||
const publishedCount = projects.filter((project) => project.isPublished).length;
|
|
||||||
const isFiltered = selectedCategory !== "";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -68,7 +42,11 @@ export async function PortfolioProjectsOverview({
|
|||||||
<div className="grid gap-4 md:grid-cols-3 xl:min-w-[620px]">
|
<div className="grid gap-4 md:grid-cols-3 xl:min-w-[620px]">
|
||||||
<StatsCard title="Projects" value={String(projects.length)} icon={FolderKanban} />
|
<StatsCard title="Projects" value={String(projects.length)} icon={FolderKanban} />
|
||||||
<StatsCard title="Categories" value={String(categories.length)} icon={Tags} />
|
<StatsCard title="Categories" value={String(categories.length)} icon={Tags} />
|
||||||
<StatsCard title="Published" value={String(publishedCount)} icon={CheckCircle2} />
|
<StatsCard
|
||||||
|
title="Published"
|
||||||
|
value={String(projects.filter((project) => project.isPublished).length)}
|
||||||
|
icon={CheckCircle2}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@@ -98,134 +76,59 @@ export async function PortfolioProjectsOverview({
|
|||||||
variant={selectedCategory === category.id ? "default" : "outline"}
|
variant={selectedCategory === category.id ? "default" : "outline"}
|
||||||
>
|
>
|
||||||
<Link href={`${getAdminAppPath("/portfolio")}?category=${category.id}`}>
|
<Link href={`${getAdminAppPath("/portfolio")}?category=${category.id}`}>
|
||||||
{categoryLabel(category.name)}
|
{category.name.de || category.name.en || category.name.ar}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{projects.length === 0 ? (
|
<div className="grid gap-4 xl:grid-cols-2">
|
||||||
<AppCard level={3} padding="lg">
|
{projects.map((project, index) => (
|
||||||
<div className="flex flex-col items-center gap-4 py-12 text-center">
|
<MotionFade key={project.id} delay={0.06 + index * 0.03}>
|
||||||
<div className="flex h-14 w-14 items-center justify-center rounded-pill border border-border/70 bg-surface-2 text-muted-foreground">
|
<AppCard interactive layer="single" className="h-full">
|
||||||
<FolderKanban className="h-6 w-6" />
|
<CardContent className="flex flex-col gap-4 p-5 lg:flex-row lg:items-center lg:justify-between">
|
||||||
</div>
|
<div className="space-y-2">
|
||||||
<div className="space-y-1">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<p className="text-base font-semibold text-foreground">
|
<p className="text-xl font-semibold text-foreground">
|
||||||
{isFiltered ? copy.emptyFiltered : copy.empty}
|
{getLocalizedValue(project.title, "de") || copy.untitled}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground">{copy.emptyHint}</p>
|
<Badge variant={project.isPublished ? "success" : "warning"}>
|
||||||
</div>
|
{project.isPublished ? "Published" : "Draft"}
|
||||||
{isFiltered ? (
|
</Badge>
|
||||||
<Button asChild variant="outline">
|
<Badge variant="outline">{project.viewMode}</Badge>
|
||||||
<Link href={getAdminAppPath("/portfolio")}>{copy.all}</Link>
|
</div>
|
||||||
</Button>
|
<div className="flex flex-wrap gap-2 text-sm text-muted-foreground">
|
||||||
) : (
|
<span>{project.category.name.de || project.category.name.en || project.category.name.ar}</span>
|
||||||
<Button asChild>
|
<span>{project.projectYear}</span>
|
||||||
<Link href={getAdminAppPath("/portfolio/projects/new")}>
|
</div>
|
||||||
<Plus className="h-4 w-4" />
|
</div>
|
||||||
{copy.newProject}
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</AppCard>
|
|
||||||
) : (
|
|
||||||
<Card className="overflow-hidden">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow className="hover:bg-transparent">
|
|
||||||
<TableHead className="w-[72px]">
|
|
||||||
<span className="sr-only">Cover</span>
|
|
||||||
</TableHead>
|
|
||||||
<TableHead>{copy.colProject}</TableHead>
|
|
||||||
<TableHead className="hidden md:table-cell">{copy.colCategory}</TableHead>
|
|
||||||
<TableHead className="hidden w-[80px] sm:table-cell">{copy.colYear}</TableHead>
|
|
||||||
<TableHead className="w-[130px]">{copy.colStatus}</TableHead>
|
|
||||||
<TableHead className="hidden w-[120px] lg:table-cell">{copy.colMode}</TableHead>
|
|
||||||
<TableHead className="w-[96px] text-right">{copy.colActions}</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{projects.map((project) => {
|
|
||||||
const title = getLocalizedValue(project.title, "de") || copy.untitled;
|
|
||||||
|
|
||||||
return (
|
<div className="flex flex-wrap gap-3">
|
||||||
<TableRow key={project.id}>
|
<Button asChild variant="outline">
|
||||||
<TableCell>
|
<Link
|
||||||
<div className="flex h-11 w-14 items-center justify-center overflow-hidden rounded-nested border border-border/70 bg-surface-2 text-muted-foreground">
|
href={getLocalizedPath("de", `/portfolio/${project.slug}`, siteSettings.defaultLocale)}
|
||||||
{project.coverImagePath ? (
|
target="_blank"
|
||||||
<img
|
rel="noreferrer"
|
||||||
src={project.coverImagePath}
|
>
|
||||||
alt={title}
|
<ExternalLink className="h-4 w-4" />
|
||||||
className="h-full w-full object-cover"
|
{copy.openProject}
|
||||||
/>
|
</Link>
|
||||||
) : (
|
</Button>
|
||||||
<ImageOff className="h-4 w-4" />
|
<PortfolioProjectActions projectId={project.id} />
|
||||||
)}
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
</TableCell>
|
</AppCard>
|
||||||
|
</MotionFade>
|
||||||
|
))}
|
||||||
|
|
||||||
<TableCell>
|
{projects.length === 0 ? (
|
||||||
<div className="flex min-w-0 flex-col">
|
<AppCard layer="single" className="xl:col-span-2">
|
||||||
<span className="inline-flex items-center gap-1.5 font-medium text-foreground">
|
<CardContent className="p-6 text-sm text-muted-foreground">
|
||||||
<span className="truncate">{title}</span>
|
{copy.empty}
|
||||||
{project.isFeatured ? (
|
</CardContent>
|
||||||
<Star
|
</AppCard>
|
||||||
className="h-3.5 w-3.5 shrink-0 text-brand-primary"
|
) : null}
|
||||||
fill="currentColor"
|
</div>
|
||||||
aria-label={copy.featured}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
<span className="truncate text-xs text-muted-foreground">/{project.slug}</span>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell className="hidden text-sm text-muted-foreground md:table-cell">
|
|
||||||
{categoryLabel(project.category.name)}
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell className="hidden text-sm text-muted-foreground sm:table-cell">
|
|
||||||
{project.projectYear}
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant={project.isPublished ? "success" : "warning"}>
|
|
||||||
{project.isPublished ? copy.published : copy.draft}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell className="hidden lg:table-cell">
|
|
||||||
<Badge variant="outline">{project.viewMode}</Badge>
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<div className="flex items-center justify-end gap-1.5">
|
|
||||||
<Button asChild variant="ghost" size="icon" title={copy.view}>
|
|
||||||
<Link
|
|
||||||
href={getLocalizedPath(
|
|
||||||
"de",
|
|
||||||
`/portfolio/${project.slug}`,
|
|
||||||
siteSettings.defaultLocale,
|
|
||||||
)}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
<ExternalLink className="h-4 w-4" />
|
|
||||||
<span className="sr-only">{copy.view}</span>
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<PortfolioProjectActions projectId={project.id} />
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,278 +0,0 @@
|
|||||||
import { ExternalLink, FileCode2, Map as MapIcon, Bot, CheckCircle2, AlertTriangle, XCircle } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
import { StatsCard } from "@/components/dashboard/stats-card";
|
|
||||||
import { AppCard } from "@/components/ui/app-card";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import type { SeoCheck } from "@/lib/seo-report";
|
|
||||||
import { summarizeSeoChecklist } from "@/lib/seo-report";
|
|
||||||
import type { SeoSettings } from "@/lib/seo-settings";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
type SeoSettingsFormProps = {
|
|
||||||
action: (formData: FormData) => Promise<void>;
|
|
||||||
settings: SeoSettings;
|
|
||||||
checks: SeoCheck[];
|
|
||||||
links: {
|
|
||||||
sitemap: string;
|
|
||||||
robots: string;
|
|
||||||
manifest: string;
|
|
||||||
};
|
|
||||||
sitemapEntryCount: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const localeKeywordFields = [
|
|
||||||
{ key: "de", name: "keywordsDe", label: "Keywords (Deutsch)" },
|
|
||||||
{ key: "en", name: "keywordsEn", label: "Keywords (English)" },
|
|
||||||
{ key: "ar", name: "keywordsAr", label: "Keywords (Arabic)" },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
function StatusIcon({ status }: { status: SeoCheck["status"] }) {
|
|
||||||
if (status === "ok") {
|
|
||||||
return <CheckCircle2 className="h-4 w-4 text-status-success" aria-label="OK" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === "warn") {
|
|
||||||
return <AlertTriangle className="h-4 w-4 text-status-warning" aria-label="Hinweis" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <XCircle className="h-4 w-4 text-destructive" aria-label="Fehler" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function FileLink({
|
|
||||||
href,
|
|
||||||
label,
|
|
||||||
description,
|
|
||||||
icon: Icon,
|
|
||||||
}: {
|
|
||||||
href: string;
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
icon: typeof MapIcon;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<AppCard level={2} padding="sm" contentClassName="flex items-center justify-between gap-3">
|
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
|
||||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-nested border border-border bg-muted/50 text-muted-foreground">
|
|
||||||
<Icon className="h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-sm font-medium text-foreground">{label}</p>
|
|
||||||
<p className="truncate text-xs text-muted-foreground">{description}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button asChild variant="outline" size="sm">
|
|
||||||
<Link href={href} target="_blank" rel="noreferrer">
|
|
||||||
Oeffnen
|
|
||||||
<ExternalLink className="ml-1.5 h-3.5 w-3.5" />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</AppCard>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SeoSettingsForm({ action, settings, checks, links, sitemapEntryCount }: SeoSettingsFormProps) {
|
|
||||||
const summary = summarizeSeoChecklist(checks);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
|
||||||
<StatsCard title="Bereit" value={String(summary.ok)} description="Checks bestanden" />
|
|
||||||
<StatsCard title="Hinweise" value={String(summary.warn)} description="Empfohlen zu pruefen" />
|
|
||||||
<StatsCard title="Fehler" value={String(summary.error)} description="Blockiert Sichtbarkeit" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
|
||||||
<form id="seo-settings-form" action={action} className="space-y-6">
|
|
||||||
<section className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-foreground">Sichtbarkeit</h2>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Steuert robots.txt, die Sitemap und das robots Meta Tag aller oeffentlichen Seiten.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<AppCard level={2} padding="sm" contentClassName="space-y-4">
|
|
||||||
<label className="flex items-start gap-3">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name="allowIndexing"
|
|
||||||
value="on"
|
|
||||||
defaultChecked={settings.allowIndexing}
|
|
||||||
className="mt-1 h-4 w-4 rounded border-border accent-primary"
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
<span className="block text-sm font-medium text-foreground">Indexierung erlauben</span>
|
|
||||||
<span className="block text-xs text-muted-foreground">
|
|
||||||
Aus = noindex auf allen Seiten, robots.txt sperrt alles, Sitemap wird leer. Der Wartungsmodus
|
|
||||||
sperrt zusaetzlich automatisch.
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</AppCard>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-foreground">Verifizierung & Social</h2>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Codes aus Google Search Console / Bing Webmaster und das X-Handle fuer Twitter Cards.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<AppCard level={2} padding="sm" contentClassName="grid gap-4 md:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="googleSiteVerification">Google Verification</Label>
|
|
||||||
<Input
|
|
||||||
id="googleSiteVerification"
|
|
||||||
name="googleSiteVerification"
|
|
||||||
defaultValue={settings.googleSiteVerification}
|
|
||||||
placeholder="google-site-verification Wert"
|
|
||||||
className="font-mono"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="bingSiteVerification">Bing Verification</Label>
|
|
||||||
<Input
|
|
||||||
id="bingSiteVerification"
|
|
||||||
name="bingSiteVerification"
|
|
||||||
defaultValue={settings.bingSiteVerification}
|
|
||||||
placeholder="msvalidate.01 Wert"
|
|
||||||
className="font-mono"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 md:col-span-2">
|
|
||||||
<Label htmlFor="twitterHandle">X / Twitter Handle</Label>
|
|
||||||
<Input
|
|
||||||
id="twitterHandle"
|
|
||||||
name="twitterHandle"
|
|
||||||
defaultValue={settings.twitterHandle}
|
|
||||||
placeholder="@handle"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</AppCard>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-foreground">Strukturierte Daten</h2>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
JSON-LD fuer Google: Wer steht hinter der Seite? Gilt fuer alle Seiten und jede Projekt-Ansicht.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<AppCard level={2} padding="sm" contentClassName="grid gap-4 md:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="structuredDataType">Typ</Label>
|
|
||||||
<select
|
|
||||||
id="structuredDataType"
|
|
||||||
name="structuredDataType"
|
|
||||||
defaultValue={settings.structuredDataType}
|
|
||||||
className="flex h-10 w-full rounded-nested border border-input bg-background px-3 py-2 text-sm"
|
|
||||||
>
|
|
||||||
<option value="Person">Person (Freelancer / Portfolio)</option>
|
|
||||||
<option value="Organization">Organization (Studio / Firma)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="structuredDataName">Name</Label>
|
|
||||||
<Input
|
|
||||||
id="structuredDataName"
|
|
||||||
name="structuredDataName"
|
|
||||||
defaultValue={settings.structuredDataName}
|
|
||||||
placeholder="Leer = Site Name"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 md:col-span-2">
|
|
||||||
<Label htmlFor="structuredDataJobTitle">Job Title / Slogan</Label>
|
|
||||||
<Input
|
|
||||||
id="structuredDataJobTitle"
|
|
||||||
name="structuredDataJobTitle"
|
|
||||||
defaultValue={settings.structuredDataJobTitle}
|
|
||||||
placeholder="z.B. Brand & Motion Designer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 md:col-span-2">
|
|
||||||
<Label htmlFor="sameAs">Social Profile (eine https-URL pro Zeile)</Label>
|
|
||||||
<Textarea
|
|
||||||
id="sameAs"
|
|
||||||
name="sameAs"
|
|
||||||
rows={4}
|
|
||||||
defaultValue={settings.sameAs.join("\n")}
|
|
||||||
placeholder={"https://www.behance.net/...\nhttps://www.linkedin.com/in/..."}
|
|
||||||
className="font-mono text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</AppCard>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-foreground">Keywords</h2>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Kommagetrennt, pro Sprache. Geringe Gewichtung bei Google, aber nuetzlich fuer Bing und Struktur.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<AppCard level={2} padding="sm" contentClassName="grid gap-4">
|
|
||||||
{localeKeywordFields.map((field) => (
|
|
||||||
<div key={field.key} className="space-y-2">
|
|
||||||
<Label htmlFor={field.name}>{field.label}</Label>
|
|
||||||
<Input
|
|
||||||
id={field.name}
|
|
||||||
name={field.name}
|
|
||||||
defaultValue={settings.locales[field.key].keywords}
|
|
||||||
dir={field.key === "ar" ? "rtl" : "ltr"}
|
|
||||||
placeholder="branding, motion design, berlin"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</AppCard>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button type="submit">Save SEO Settings</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<aside className="space-y-6">
|
|
||||||
<section className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-foreground">Dateien</h2>
|
|
||||||
<p className="text-sm text-muted-foreground">Werden live aus den Einstellungen generiert.</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<FileLink
|
|
||||||
href={links.sitemap}
|
|
||||||
label="sitemap.xml"
|
|
||||||
description={`${sitemapEntryCount} URLs, hreflang fuer DE/EN/AR`}
|
|
||||||
icon={MapIcon}
|
|
||||||
/>
|
|
||||||
<FileLink href={links.robots} label="robots.txt" description="Crawler-Regeln + Sitemap-Verweis" icon={Bot} />
|
|
||||||
<FileLink href={links.manifest} label="manifest.webmanifest" description="PWA / Icons" icon={FileCode2} />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-foreground">Checkliste</h2>
|
|
||||||
<p className="text-sm text-muted-foreground">Status der wichtigsten SEO-Bausteine.</p>
|
|
||||||
</div>
|
|
||||||
<AppCard level={2} padding="sm" contentClassName="divide-y divide-border/60">
|
|
||||||
{checks.map((check) => (
|
|
||||||
<div key={check.id} className={cn("flex items-start gap-3 py-2.5 first:pt-0 last:pb-0")}>
|
|
||||||
<span className="mt-0.5 shrink-0">
|
|
||||||
<StatusIcon status={check.status} />
|
|
||||||
</span>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-sm font-medium text-foreground">{check.label}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">{check.detail}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</AppCard>
|
|
||||||
</section>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -270,7 +270,7 @@ function SiteSettingsMediaRow({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AppCard level={2} padding="sm">
|
<AppCard level={2} layer="single" padding="sm">
|
||||||
<div className="grid gap-4 lg:grid-cols-[180px_minmax(0,1fr)]">
|
<div className="grid gap-4 lg:grid-cols-[180px_minmax(0,1fr)]">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-sm font-semibold text-foreground">{title}</p>
|
<p className="text-sm font-semibold text-foreground">{title}</p>
|
||||||
@@ -393,7 +393,7 @@ function LocalizedFieldsSection({
|
|||||||
|
|
||||||
<div className="grid gap-5 lg:grid-cols-3">
|
<div className="grid gap-5 lg:grid-cols-3">
|
||||||
{localeFields.map((locale) => (
|
{localeFields.map((locale) => (
|
||||||
<AppCard key={locale.key} level={2} padding="sm" contentClassName="space-y-4">
|
<AppCard key={locale.key} level={2} layer="single" padding="sm" className="space-y-4">
|
||||||
<p className="text-sm font-semibold text-foreground">{locale.label}</p>
|
<p className="text-sm font-semibold text-foreground">{locale.label}</p>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -558,7 +558,7 @@ export function SiteSettingsForm({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AppCard level={2} padding="sm" contentClassName="space-y-4">
|
<AppCard level={2} layer="single" padding="sm" className="space-y-4">
|
||||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_220px]">
|
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_220px]">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="primaryColor" className="sr-only">Primary Color</Label>
|
<Label htmlFor="primaryColor" className="sr-only">Primary Color</Label>
|
||||||
@@ -679,7 +679,7 @@ export function SiteSettingsForm({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AppCard level={2} padding="sm" contentClassName="space-y-4">
|
<AppCard level={2} layer="single" padding="sm" className="space-y-4">
|
||||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="defaultLocaleTrigger" className="sr-only">Default Locale</Label>
|
<Label htmlFor="defaultLocaleTrigger" className="sr-only">Default Locale</Label>
|
||||||
@@ -729,7 +729,7 @@ export function SiteSettingsForm({
|
|||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
{mode === "brand" ? (
|
{mode === "brand" ? (
|
||||||
<>
|
<>
|
||||||
<AppCard level={2} padding="sm" contentClassName="space-y-2">
|
<AppCard level={2} layer="single" padding="sm" className="space-y-2">
|
||||||
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Search Result</p>
|
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Search Result</p>
|
||||||
<div className="rounded-nested border border-border/70 bg-background p-3">
|
<div className="rounded-nested border border-border/70 bg-background p-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -750,7 +750,7 @@ export function SiteSettingsForm({
|
|||||||
</div>
|
</div>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
|
|
||||||
<AppCard level={2} padding="sm" contentClassName="space-y-2">
|
<AppCard level={2} layer="single" padding="sm" className="space-y-2">
|
||||||
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Brand Assets</p>
|
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Brand Assets</p>
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<AppCard level={1} layer="single" padding="sm">
|
<AppCard level={1} layer="single" padding="sm">
|
||||||
@@ -773,7 +773,7 @@ export function SiteSettingsForm({
|
|||||||
</div>
|
</div>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
|
|
||||||
<AppCard level={2} padding="sm" contentClassName="space-y-2">
|
<AppCard level={2} layer="single" padding="sm" className="space-y-2">
|
||||||
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Social Preview</p>
|
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Social Preview</p>
|
||||||
<div className="overflow-hidden rounded-nested border border-border/70 bg-background">
|
<div className="overflow-hidden rounded-nested border border-border/70 bg-background">
|
||||||
<div className="h-1.5" style={{ backgroundColor: settings.brand.primaryColor }} />
|
<div className="h-1.5" style={{ backgroundColor: settings.brand.primaryColor }} />
|
||||||
@@ -797,7 +797,7 @@ export function SiteSettingsForm({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<AppCard level={2} padding="sm" contentClassName="space-y-2">
|
<AppCard level={2} layer="single" padding="sm" className="space-y-2">
|
||||||
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Visitor Routing</p>
|
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Visitor Routing</p>
|
||||||
<div className="rounded-nested border border-border/70 bg-background p-3">
|
<div className="rounded-nested border border-border/70 bg-background p-3">
|
||||||
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||||
@@ -810,7 +810,7 @@ export function SiteSettingsForm({
|
|||||||
</div>
|
</div>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
|
|
||||||
<AppCard level={2} padding="sm" contentClassName="space-y-2">
|
<AppCard level={2} layer="single" padding="sm" className="space-y-2">
|
||||||
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Locale Summary</p>
|
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Locale Summary</p>
|
||||||
<div className="rounded-nested border border-border/70 bg-background">
|
<div className="rounded-nested border border-border/70 bg-background">
|
||||||
<Table>
|
<Table>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function SMTPSettingsForm({
|
|||||||
return (
|
return (
|
||||||
<form id="smtp-settings-form" action={action} className="space-y-6">
|
<form id="smtp-settings-form" action={action} className="space-y-6">
|
||||||
<div className="grid gap-6 xl:grid-cols-2">
|
<div className="grid gap-6 xl:grid-cols-2">
|
||||||
<AppCard>
|
<AppCard layer="single">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>SMTP Connection</CardTitle>
|
<CardTitle>SMTP Connection</CardTitle>
|
||||||
<CardDescription>Server und Login.</CardDescription>
|
<CardDescription>Server und Login.</CardDescription>
|
||||||
@@ -109,7 +109,7 @@ export function SMTPSettingsForm({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
|
|
||||||
<AppCard>
|
<AppCard layer="single">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Sender And Recipients</CardTitle>
|
<CardTitle>Sender And Recipients</CardTitle>
|
||||||
<CardDescription>Absender und Ziele.</CardDescription>
|
<CardDescription>Absender und Ziele.</CardDescription>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export function WorkspaceHero({
|
|||||||
aside?: ReactNode;
|
aside?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<AppCard level={3}>
|
<AppCard level={3} layer="single">
|
||||||
<CardContent className="flex flex-col gap-4 p-6 lg:flex-row lg:items-end lg:justify-between lg:p-8">
|
<CardContent className="flex flex-col gap-4 p-6 lg:flex-row lg:items-end lg:justify-between lg:p-8">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
@@ -40,7 +40,7 @@ export function WorkspaceSidebarPanel({
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<AppCard level={2}>
|
<AppCard level={2} layer="single">
|
||||||
<CardHeader className="pb-4">
|
<CardHeader className="pb-4">
|
||||||
<CardTitle className="text-base">{title}</CardTitle>
|
<CardTitle className="text-base">{title}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -100,7 +100,7 @@ export function WorkspaceLocaleCard({
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<AppCard level={2}>
|
<AppCard level={2} layer="single">
|
||||||
<CardHeader className="pb-4">
|
<CardHeader className="pb-4">
|
||||||
<CardTitle className="text-lg">{title}</CardTitle>
|
<CardTitle className="text-lg">{title}</CardTitle>
|
||||||
<CardDescription>{hint}</CardDescription>
|
<CardDescription>{hint}</CardDescription>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { StackedMarqueeSection, type MarqueeRow } from "@/components/layout/stacked-marquee-section";
|
import { StackedMarqueeSection, type MarqueeRow } from "@/components/layout/stacked-marquee-section";
|
||||||
|
import { AppCard } from "@/components/ui/app-card";
|
||||||
import { SectionHeading } from "./section-heading";
|
import { SectionHeading } from "./section-heading";
|
||||||
import type { MarqueeSectionCopy } from "./types";
|
import type { MarqueeSectionCopy } from "./types";
|
||||||
|
|
||||||
@@ -9,18 +10,18 @@ type MarqueeSectionProps = {
|
|||||||
|
|
||||||
export function MarqueeSection({ copy, rows }: MarqueeSectionProps) {
|
export function MarqueeSection({ copy, rows }: MarqueeSectionProps) {
|
||||||
return (
|
return (
|
||||||
<section className="space-y-8">
|
<section className="space-y-8 overflow-hidden">
|
||||||
<SectionHeading
|
<SectionHeading
|
||||||
eyebrow={copy.eyebrow}
|
eyebrow={copy.eyebrow}
|
||||||
title={copy.title}
|
title={copy.title}
|
||||||
description={copy.description}
|
description={copy.description}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Full-bleed band: breaks out of the centered container to span the
|
<AppCard className="overflow-hidden">
|
||||||
entire site width instead of sitting inside a card. */}
|
<div className="rounded-nested border border-border/70 bg-background py-6">
|
||||||
<div className="relative left-1/2 w-screen -translate-x-1/2 overflow-hidden border-y border-border/60 bg-background/40 py-8">
|
<StackedMarqueeSection rows={rows} className="py-0" />
|
||||||
<StackedMarqueeSection rows={rows} className="py-0" />
|
</div>
|
||||||
</div>
|
</AppCard>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { ShieldCheck } from "lucide-react";
|
|
||||||
import { useLocale } from "next-intl";
|
|
||||||
|
|
||||||
import { buildAdminUrl } from "@/lib/admin-routing";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
export function FloatingAdminButton() {
|
|
||||||
const locale = useLocale();
|
|
||||||
const isRtl = locale === "ar";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
href={buildAdminUrl("/")}
|
|
||||||
aria-label="Admin"
|
|
||||||
className={cn(
|
|
||||||
"fixed bottom-5 z-50 flex h-11 w-11 items-center justify-center rounded-full border border-border/70 bg-background/90 text-foreground/70 shadow-lg backdrop-blur-chrome transition-all hover:scale-105 hover:bg-accent hover:text-foreground hover:shadow-xl",
|
|
||||||
isRtl ? "left-5" : "right-5",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ShieldCheck className="h-5 w-5" />
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
|
|
||||||
import { Plus, ShieldCheck } from "lucide-react";
|
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
|
||||||
import { useState, type ReactNode } from "react";
|
|
||||||
|
|
||||||
import { LocaleToggle } from "@/components/layout/locale-toggle";
|
|
||||||
import { SoundToggle } from "@/components/sound-toggle";
|
|
||||||
import { ThemeToggle } from "@/components/theme-toggle";
|
|
||||||
import { buildAdminUrl } from "@/lib/admin-routing";
|
|
||||||
import type { AppLocale } from "@/lib/locale";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
type FloatingControlsProps = {
|
|
||||||
locale: string;
|
|
||||||
defaultLocale: AppLocale;
|
|
||||||
isSuperAdmin?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const pill =
|
|
||||||
"flex h-11 w-11 items-center justify-center rounded-full border border-border/60 bg-background/85 text-foreground/80 backdrop-blur-chrome transition-colors hover:bg-accent hover:text-foreground";
|
|
||||||
|
|
||||||
/** Radius (px) of the arc the items fan out along. */
|
|
||||||
const RADIUS = 92;
|
|
||||||
|
|
||||||
export function FloatingControls({
|
|
||||||
locale,
|
|
||||||
defaultLocale,
|
|
||||||
isSuperAdmin = false,
|
|
||||||
}: FloatingControlsProps) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const t = useTranslations("navigation");
|
|
||||||
const activeLocale = useLocale();
|
|
||||||
const isRtl = activeLocale === "ar";
|
|
||||||
const reducedMotion = useReducedMotion();
|
|
||||||
|
|
||||||
const controls: ReactNode[] = [
|
|
||||||
<SoundToggle
|
|
||||||
key="sound"
|
|
||||||
ariaLabel={t("soundMute")}
|
|
||||||
mutedAriaLabel={t("soundUnmute")}
|
|
||||||
variant="ghost"
|
|
||||||
className={cn(pill, "border-transparent bg-transparent")}
|
|
||||||
/>,
|
|
||||||
<ThemeToggle
|
|
||||||
key="theme"
|
|
||||||
ariaLabel={t("themeToggle")}
|
|
||||||
variant="ghost"
|
|
||||||
className={cn(pill, "border-transparent bg-transparent")}
|
|
||||||
/>,
|
|
||||||
<LocaleToggle
|
|
||||||
key="locale"
|
|
||||||
locale={locale}
|
|
||||||
defaultLocale={defaultLocale}
|
|
||||||
className={cn(pill, "border-transparent bg-transparent p-0")}
|
|
||||||
/>,
|
|
||||||
];
|
|
||||||
|
|
||||||
if (isSuperAdmin) {
|
|
||||||
controls.push(
|
|
||||||
<a key="admin" href={buildAdminUrl("/")} aria-label="Open admin dashboard" className={pill}>
|
|
||||||
<ShieldCheck className="h-[1.125rem] w-[1.125rem]" />
|
|
||||||
</a>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const count = controls.length;
|
|
||||||
// Fan across a quarter arc from "up" (90°) to "sideways" (180°), mirrored for
|
|
||||||
// RTL so the items always spread away from the corner into the page.
|
|
||||||
const startAngle = 94;
|
|
||||||
const endAngle = 176;
|
|
||||||
const positionFor = (index: number) => {
|
|
||||||
const angle = count === 1 ? 135 : startAngle + ((endAngle - startAngle) * index) / (count - 1);
|
|
||||||
const radians = (angle * Math.PI) / 180;
|
|
||||||
return {
|
|
||||||
x: (isRtl ? -1 : 1) * Math.cos(radians) * RADIUS,
|
|
||||||
y: -Math.sin(radians) * RADIUS,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="pointer-events-none fixed bottom-0 end-0 z-50 p-4"
|
|
||||||
style={{ paddingBottom: "calc(1.1rem + env(safe-area-inset-bottom, 0px))" }}
|
|
||||||
>
|
|
||||||
<div className="pointer-events-auto relative h-12 w-12">
|
|
||||||
<AnimatePresence>
|
|
||||||
{open ? (
|
|
||||||
<>
|
|
||||||
{/* click-away scrim */}
|
|
||||||
<motion.button
|
|
||||||
key="scrim"
|
|
||||||
type="button"
|
|
||||||
aria-hidden
|
|
||||||
tabIndex={-1}
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
className="fixed inset-0 -z-10 cursor-default bg-transparent"
|
|
||||||
/>
|
|
||||||
{controls.map((control, index) => {
|
|
||||||
const { x, y } = positionFor(index);
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
key={index}
|
|
||||||
className="absolute bottom-0 end-0 grid h-12 w-12 place-items-center"
|
|
||||||
initial={reducedMotion ? { opacity: 0 } : { opacity: 0, x: 0, y: 0, scale: 0.4 }}
|
|
||||||
animate={reducedMotion ? { opacity: 1 } : { opacity: 1, x, y, scale: 1 }}
|
|
||||||
exit={reducedMotion ? { opacity: 0 } : { opacity: 0, x: 0, y: 0, scale: 0.4 }}
|
|
||||||
transition={
|
|
||||||
reducedMotion
|
|
||||||
? { duration: 0 }
|
|
||||||
: { type: "spring", stiffness: 460, damping: 26, delay: index * 0.035 }
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{control}
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
{/* trigger */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setOpen((value) => !value)}
|
|
||||||
aria-expanded={open}
|
|
||||||
aria-label={open ? t("closeMenu") : t("openMenu")}
|
|
||||||
className={cn(
|
|
||||||
pill,
|
|
||||||
"relative h-12 w-12",
|
|
||||||
open && "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<motion.span
|
|
||||||
animate={{ rotate: open ? 135 : 0 }}
|
|
||||||
transition={reducedMotion ? { duration: 0 } : { type: "spring", stiffness: 400, damping: 22 }}
|
|
||||||
className="flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<Plus className="h-5 w-5" />
|
|
||||||
</motion.span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,33 +1,13 @@
|
|||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
type HeroMotionBackdropProps = {
|
type HeroMotionBackdropProps = {
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
export function HeroMotionBackdrop({}: HeroMotionBackdropProps) {
|
||||||
* Layered ambient backdrop rendered behind every hero (home + page).
|
|
||||||
* Pure CSS so it needs no client JS: a faded grid for structure, a soft
|
|
||||||
* brand glow, slowly drifting brand orbs, a grain overlay for texture, and
|
|
||||||
* a bottom fade that blends the hero into the page content. The orb drift is
|
|
||||||
* disabled under `prefers-reduced-motion` (see globals.css).
|
|
||||||
*/
|
|
||||||
export function HeroMotionBackdrop({ compact = false }: HeroMotionBackdropProps) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="hero-sheet pointer-events-none absolute inset-0 overflow-hidden">
|
||||||
aria-hidden="true"
|
<div className="hero-sheet-base" />
|
||||||
className={cn(
|
<div className="hero-sheet-veil" />
|
||||||
"hero-backdrop pointer-events-none absolute inset-0 z-0 overflow-hidden",
|
<div className="hero-sheet-fade" />
|
||||||
compact && "is-compact",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="hero-backdrop-base" />
|
|
||||||
<div className="hero-backdrop-grid" />
|
|
||||||
<div className="hero-backdrop-glow" />
|
|
||||||
<span className="hero-orb hero-orb-1" />
|
|
||||||
<span className="hero-orb hero-orb-2" />
|
|
||||||
<span className="hero-orb hero-orb-3" />
|
|
||||||
<div className="hero-noise hero-backdrop-noise" />
|
|
||||||
<div className="hero-backdrop-floor" />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
|
|
||||||
import { LayoutRouterContext } from "next/dist/shared/lib/app-router-context.shared-runtime";
|
|
||||||
import { usePathname } from "next/navigation";
|
|
||||||
import { useContext, useState, type ReactNode } from "react";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pins the layout-router context captured when this instance mounted. Each
|
|
||||||
* keyed page gets its own FrozenRouter, so the *outgoing* page keeps rendering
|
|
||||||
* its old route data while it animates out.
|
|
||||||
*/
|
|
||||||
function FrozenRouter({ children }: { children: ReactNode }) {
|
|
||||||
const context = useContext(LayoutRouterContext);
|
|
||||||
const [frozen] = useState(context);
|
|
||||||
|
|
||||||
if (!frozen) {
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <LayoutRouterContext.Provider value={frozen}>{children}</LayoutRouterContext.Provider>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One unified page transition on every navigation. Lives in the (persistent)
|
|
||||||
* site layout — NOT in a `template.tsx`, which Next re-creates on each
|
|
||||||
* navigation and would reset AnimatePresence so nothing animates. The old page
|
|
||||||
* animates out (rise + blur), then the new page animates in from below.
|
|
||||||
*/
|
|
||||||
export function PageTransition({ children }: { children: ReactNode }) {
|
|
||||||
const reducedMotion = useReducedMotion();
|
|
||||||
const pathname = usePathname();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AnimatePresence mode="wait">
|
|
||||||
<motion.div
|
|
||||||
key={pathname}
|
|
||||||
initial={reducedMotion ? false : { opacity: 0, y: 20, filter: "blur(10px)" }}
|
|
||||||
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
|
|
||||||
exit={reducedMotion ? { opacity: 0 } : { opacity: 0, y: -20, filter: "blur(10px)" }}
|
|
||||||
transition={reducedMotion ? { duration: 0 } : { duration: 0.45, ease: [0.22, 1, 0.36, 1] }}
|
|
||||||
>
|
|
||||||
<FrozenRouter>{children}</FrozenRouter>
|
|
||||||
</motion.div>
|
|
||||||
</AnimatePresence>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,189 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { motion, useReducedMotion } from "framer-motion";
|
|
||||||
import Image from "next/image";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { usePathname } from "next/navigation";
|
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
|
||||||
import { useTheme } from "next-themes";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
import { FloatingControls } from "@/components/layout/floating-controls";
|
|
||||||
import { Dock, DockIcon } from "@/components/ui/dock";
|
|
||||||
import { getLocalizedPath, stripLocalePrefix, type AppLocale } from "@/lib/locale";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
type SiteDockProps = {
|
|
||||||
defaultLocale: AppLocale;
|
|
||||||
/**
|
|
||||||
* Server-computed via `isSuperAdmin()` in the parent layout. This client
|
|
||||||
* component only decides whether to *render* the Admin shortcut — it performs
|
|
||||||
* no auth check and grants no access; the real guard lives server-side on
|
|
||||||
* each admin page/action.
|
|
||||||
*/
|
|
||||||
isSuperAdmin?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type NavKey = "about" | "portfolio" | "products" | "contact";
|
|
||||||
|
|
||||||
type NavItem = {
|
|
||||||
key: NavKey;
|
|
||||||
path: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
// "Home" is intentionally omitted — the logo (first dock icon) is the home link.
|
|
||||||
const navItems: NavItem[] = [
|
|
||||||
{ key: "about", path: "/about" },
|
|
||||||
{ key: "portfolio", path: "/portfolio" },
|
|
||||||
{ key: "products", path: "/products", disabled: true },
|
|
||||||
{ key: "contact", path: "/contact" },
|
|
||||||
];
|
|
||||||
|
|
||||||
function isNavItemActive(currentPath: string, itemPath: string) {
|
|
||||||
if (itemPath === "/") {
|
|
||||||
return currentPath === "/";
|
|
||||||
}
|
|
||||||
return currentPath === itemPath || currentPath.startsWith(`${itemPath}/`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SiteDock({ defaultLocale, isSuperAdmin = false }: SiteDockProps) {
|
|
||||||
const locale = useLocale();
|
|
||||||
const pathname = usePathname();
|
|
||||||
const t = useTranslations("navigation");
|
|
||||||
const reducedMotion = useReducedMotion();
|
|
||||||
const [bouncing, setBouncing] = useState<string | null>(null);
|
|
||||||
const { theme, resolvedTheme } = useTheme();
|
|
||||||
const activeTheme = theme === "system" ? resolvedTheme : theme;
|
|
||||||
|
|
||||||
const currentPath = stripLocalePrefix(pathname);
|
|
||||||
const homeHref = getLocalizedPath(locale, "/", defaultLocale);
|
|
||||||
|
|
||||||
// macOS-style launch hop on click.
|
|
||||||
const bounceProps = (id: string) =>
|
|
||||||
reducedMotion
|
|
||||||
? {}
|
|
||||||
: {
|
|
||||||
onClick: () => setBouncing(id),
|
|
||||||
animate: bouncing === id ? { y: [0, -5, 0, -2, 0] } : { y: 0 },
|
|
||||||
transition: { duration: 0.35, ease: [0.22, 1, 0.36, 1] as const },
|
|
||||||
onAnimationComplete: () => setBouncing((cur) => (cur === id ? null : cur)),
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* ===== Bottom dock — primary navigation ===== */}
|
|
||||||
<div
|
|
||||||
className="pointer-events-none fixed inset-x-0 bottom-0 z-40 flex justify-center px-4"
|
|
||||||
style={{ paddingBottom: "calc(0.9rem + env(safe-area-inset-bottom, 0px))" }}
|
|
||||||
>
|
|
||||||
<Dock
|
|
||||||
direction="bottom"
|
|
||||||
iconSize={50}
|
|
||||||
iconMagnification={66}
|
|
||||||
iconDistance={140}
|
|
||||||
className="pointer-events-auto h-[66px] gap-3 border-border/60 bg-background/70"
|
|
||||||
>
|
|
||||||
{/* ── Logo (fixed src → no refresh flash) ── */}
|
|
||||||
<DockIcon className="group relative overflow-visible">
|
|
||||||
<Link
|
|
||||||
href={homeHref}
|
|
||||||
aria-label="mohfarawati — Home"
|
|
||||||
className="flex h-full w-full items-center justify-center"
|
|
||||||
>
|
|
||||||
<motion.span
|
|
||||||
className="flex h-full w-full items-center justify-center overflow-hidden rounded-[22%] bg-white ring-1 ring-black/10"
|
|
||||||
{...bounceProps("logo")}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src="/logos/light-primary.svg"
|
|
||||||
alt="mohfarawati"
|
|
||||||
width={104}
|
|
||||||
height={104}
|
|
||||||
sizes="104px"
|
|
||||||
priority
|
|
||||||
className="h-full w-full object-contain"
|
|
||||||
/>
|
|
||||||
</motion.span>
|
|
||||||
</Link>
|
|
||||||
<DockTip label="mohfarawati" />
|
|
||||||
</DockIcon>
|
|
||||||
|
|
||||||
<div className="mx-0.5 h-9 w-px self-center bg-border/70" aria-hidden />
|
|
||||||
|
|
||||||
{/* ── Pages (icon art carries its own squircle shape) ── */}
|
|
||||||
{navItems.map(({ key, path, disabled }) => {
|
|
||||||
const itemPath = path || "/";
|
|
||||||
const isActive = isNavItemActive(currentPath, itemPath);
|
|
||||||
const label = t(key);
|
|
||||||
const variant = activeTheme === "dark" ? "dark" : "light";
|
|
||||||
const src = `/dock/${key}-${variant}.png`;
|
|
||||||
|
|
||||||
const icon = (
|
|
||||||
<motion.span
|
|
||||||
className="flex h-full w-full items-center justify-center overflow-hidden rounded-[22%]"
|
|
||||||
{...(disabled ? {} : bounceProps(key))}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src={src}
|
|
||||||
alt={label}
|
|
||||||
width={104}
|
|
||||||
height={104}
|
|
||||||
sizes="104px"
|
|
||||||
className={cn("h-[110%] w-[110%] object-cover", disabled && "opacity-55")}
|
|
||||||
/>
|
|
||||||
</motion.span>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DockIcon key={key} className="group relative overflow-visible">
|
|
||||||
{disabled ? (
|
|
||||||
<span
|
|
||||||
aria-disabled="true"
|
|
||||||
className="flex h-full w-full cursor-not-allowed items-center justify-center"
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<Link
|
|
||||||
href={getLocalizedPath(locale, itemPath, defaultLocale)}
|
|
||||||
aria-label={label}
|
|
||||||
aria-current={isActive ? "page" : undefined}
|
|
||||||
className="flex h-full w-full items-center justify-center"
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* macOS running indicator */}
|
|
||||||
{isActive ? (
|
|
||||||
<span
|
|
||||||
aria-hidden
|
|
||||||
className="pointer-events-none absolute -bottom-[7px] left-1/2 h-[5px] w-[5px] -translate-x-1/2 rounded-full bg-foreground/70"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<DockTip label={label} soon={disabled ? t("soon") : undefined} />
|
|
||||||
</DockIcon>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Dock>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ===== Corner controls — theme / language / sound / admin (fan out) ===== */}
|
|
||||||
<FloatingControls locale={locale} defaultLocale={defaultLocale} isSuperAdmin={isSuperAdmin} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DockTip({ label, soon }: { label: string; soon?: string }) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
role="tooltip"
|
|
||||||
className="pointer-events-none absolute bottom-[calc(100%+0.85rem)] left-1/2 -translate-x-1/2 translate-y-1 whitespace-nowrap rounded-lg border border-border/60 bg-background/90 px-3 py-1.5 text-sm font-semibold text-foreground opacity-0 backdrop-blur-chrome transition-all duration-150 group-hover:translate-y-0 group-hover:opacity-100"
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
{soon ? <span className="ms-1.5 text-xs tracking-wide text-primary">{soon}</span> : null}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,30 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
import { ArrowDown } from "lucide-react";
|
import { ArrowDown } from "lucide-react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
import { Container } from "@/components/layout/container";
|
import { Container } from "@/components/layout/container";
|
||||||
import { HeroMotionBackdrop } from "@/components/layout/hero-motion-backdrop";
|
import { HeroMotionBackdrop } from "@/components/layout/hero-motion-backdrop";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const heroItemVariants = {
|
||||||
|
hidden: { opacity: 0, y: 16 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: { duration: 0.54, ease: [0.22, 1, 0.36, 1] as [number, number, number, number] },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const heroContainerVariants = {
|
||||||
|
hidden: {},
|
||||||
|
visible: {
|
||||||
|
transition: { staggerChildren: 0.13, delayChildren: 0.08 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
type HeroTone = "default" | "accent" | "soft";
|
type HeroTone = "default" | "accent" | "soft";
|
||||||
type HeroLineAlign = "start" | "center" | "end";
|
type HeroLineAlign = "start" | "center" | "end";
|
||||||
|
|
||||||
@@ -109,14 +128,6 @@ export function HeroShell({
|
|||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
{variant === "home" ? (
|
|
||||||
<HeroScrollLink
|
|
||||||
href="#home-content"
|
|
||||||
label="Scroll to content"
|
|
||||||
className="hero-scroll-anchor hidden sm:inline-flex"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -125,14 +136,27 @@ export function HeroContentMotion({
|
|||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
}: HeroContentMotionProps) {
|
}: HeroContentMotionProps) {
|
||||||
return <div className={cn("hero-content", className)}>{children}</div>;
|
return (
|
||||||
|
<motion.div
|
||||||
|
className={className}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
variants={heroContainerVariants}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HeroMotionItem({
|
export function HeroMotionItem({
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
}: HeroContentMotionProps) {
|
}: HeroContentMotionProps) {
|
||||||
return <div className={cn("hero-rise", className)}>{children}</div>;
|
return (
|
||||||
|
<motion.div className={className} variants={heroItemVariants}>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HeroTitle({
|
export function HeroTitle({
|
||||||
@@ -143,7 +167,7 @@ export function HeroTitle({
|
|||||||
}: HeroTitleProps) {
|
}: HeroTitleProps) {
|
||||||
const isArabic = locale === "ar";
|
const isArabic = locale === "ar";
|
||||||
const titleClassName = cn(
|
const titleClassName = cn(
|
||||||
"hero-title hero-rise text-balance font-semibold text-[hsl(var(--hero-ink))]",
|
"text-balance font-semibold text-[hsl(var(--hero-ink))]",
|
||||||
variant === "home"
|
variant === "home"
|
||||||
? isArabic
|
? isArabic
|
||||||
? "mt-6 flex w-full max-w-[26rem] flex-col gap-y-1 px-4 text-[clamp(2.85rem,11vw,5.9rem)] leading-[1.06] tracking-[-0.03em] sm:max-w-[30rem] sm:px-0 md:max-w-[36rem] lg:max-w-[32rem]"
|
? "mt-6 flex w-full max-w-[26rem] flex-col gap-y-1 px-4 text-[clamp(2.85rem,11vw,5.9rem)] leading-[1.06] tracking-[-0.03em] sm:max-w-[30rem] sm:px-0 md:max-w-[36rem] lg:max-w-[32rem]"
|
||||||
@@ -153,7 +177,7 @@ export function HeroTitle({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<h1 className={titleClassName}>
|
<motion.h1 className={titleClassName} variants={heroItemVariants}>
|
||||||
{lines.map((line, index) => (
|
{lines.map((line, index) => (
|
||||||
<span
|
<span
|
||||||
key={`${index}-${line.text}`}
|
key={`${index}-${line.text}`}
|
||||||
@@ -178,7 +202,7 @@ export function HeroTitle({
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</h1>
|
</motion.h1>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import { serializeJsonLd } from "@/lib/metadata";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders a JSON-LD `<script>` block. Server component only — the payload is
|
|
||||||
* serialized with `<` escaped so it can never break out of the script tag.
|
|
||||||
*/
|
|
||||||
export function JsonLd({ data }: { data: Record<string, unknown> }) {
|
|
||||||
return (
|
|
||||||
<script
|
|
||||||
type="application/ld+json"
|
|
||||||
dangerouslySetInnerHTML={{ __html: serializeJsonLd(data) }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -30,7 +30,7 @@ export function PortfolioCategoryFilter({
|
|||||||
{categories.map((category) => (
|
{categories.map((category) => (
|
||||||
<CategoryLink
|
<CategoryLink
|
||||||
key={category.id}
|
key={category.id}
|
||||||
href={getLocalizedPath(locale, `/portfolio/${category.slug}`, defaultLocale)}
|
href={getLocalizedPath(locale, `/portfolio/category/${category.slug}`, defaultLocale)}
|
||||||
label={getLocalizedValue(category.name, locale)}
|
label={getLocalizedValue(category.name, locale)}
|
||||||
active={activeCategorySlug === category.slug}
|
active={activeCategorySlug === category.slug}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import {
|
|||||||
Tag,
|
Tag,
|
||||||
UserRound,
|
UserRound,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import { MotionFade } from "@/components/motion-fade";
|
import { MotionFade } from "@/components/motion-fade";
|
||||||
import { PortfolioCover } from "@/components/site/portfolio-cover";
|
|
||||||
import { AppCard } from "@/components/ui/app-card";
|
import { AppCard } from "@/components/ui/app-card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { CardContent } from "@/components/ui/card";
|
import { CardContent } from "@/components/ui/card";
|
||||||
@@ -16,6 +16,7 @@ import { getLocalizedPath, type AppLocale } from "@/lib/locale";
|
|||||||
import {
|
import {
|
||||||
getLocalizedValue,
|
getLocalizedValue,
|
||||||
resolvePortfolioProjectViewMode,
|
resolvePortfolioProjectViewMode,
|
||||||
|
type PortfolioAssetView,
|
||||||
type PortfolioProjectView,
|
type PortfolioProjectView,
|
||||||
type PortfolioSectionView,
|
type PortfolioSectionView,
|
||||||
} from "@/lib/portfolio";
|
} from "@/lib/portfolio";
|
||||||
@@ -27,76 +28,27 @@ type PortfolioProjectDetailProps = {
|
|||||||
t: (key: "back" | "preview" | "openLink" | "gallery" | "download") => string;
|
t: (key: "back" | "preview" | "openLink" | "gallery" | "download") => string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ProjectImage = {
|
function PortfolioImage({
|
||||||
key: string;
|
src,
|
||||||
src: string | null;
|
alt,
|
||||||
label: string;
|
className,
|
||||||
};
|
width,
|
||||||
|
height,
|
||||||
/**
|
|
||||||
* All image sources for a project, in reading order: cover, gallery-section
|
|
||||||
* images, then gallery assets. `src` may be null — PortfolioCover then renders
|
|
||||||
* the branded placeholder, so layouts look intentional even without artwork.
|
|
||||||
*/
|
|
||||||
function collectImages(item: PortfolioProjectView, locale: AppLocale): ProjectImage[] {
|
|
||||||
const images: ProjectImage[] = [];
|
|
||||||
const coverTitle = getLocalizedValue(item.title, locale);
|
|
||||||
|
|
||||||
images.push({ key: "cover", src: item.coverImagePath ?? null, label: coverTitle });
|
|
||||||
|
|
||||||
for (const section of item.sections) {
|
|
||||||
if (section.type === "GALLERY") {
|
|
||||||
images.push({
|
|
||||||
key: `section-${section.id}`,
|
|
||||||
src: section.imagePath ?? null,
|
|
||||||
label: getLocalizedValue(section.title, locale),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const asset of item.assets) {
|
|
||||||
if (asset.kind === "IMAGE") {
|
|
||||||
images.push({
|
|
||||||
key: `asset-${asset.id}`,
|
|
||||||
src: asset.filePath,
|
|
||||||
label: getLocalizedValue(asset.alt, locale),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return images;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Text-bearing sections (everything that is not a gallery image). */
|
|
||||||
function textSections(item: PortfolioProjectView): PortfolioSectionView[] {
|
|
||||||
return item.sections.filter((section) => section.type !== "GALLERY");
|
|
||||||
}
|
|
||||||
|
|
||||||
function MediaFrame({
|
|
||||||
image,
|
|
||||||
aspect = "aspect-[16/10]",
|
|
||||||
chrome = false,
|
|
||||||
priority = false,
|
|
||||||
}: {
|
}: {
|
||||||
image: ProjectImage;
|
src: string;
|
||||||
aspect?: string;
|
alt: string;
|
||||||
chrome?: boolean;
|
className: string;
|
||||||
priority?: boolean;
|
width: number;
|
||||||
|
height: number;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<figure className="overflow-hidden rounded-surface border border-border bg-surface-2 shadow-card">
|
<Image
|
||||||
{chrome ? (
|
src={src}
|
||||||
<div className="flex items-center gap-2 border-b border-border bg-surface-3 px-4 py-3">
|
alt={alt}
|
||||||
<span className="h-2.5 w-2.5 rounded-full bg-border-strong" />
|
width={width}
|
||||||
<span className="h-2.5 w-2.5 rounded-full bg-border-strong" />
|
height={height}
|
||||||
<span className="h-2.5 w-2.5 rounded-full bg-border-strong" />
|
className={className}
|
||||||
<span className="ms-3 h-5 flex-1 rounded-md bg-surface-1" />
|
/>
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div className={`group relative ${aspect}`}>
|
|
||||||
<PortfolioCover src={image.src} title={image.label} priority={priority} />
|
|
||||||
</div>
|
|
||||||
</figure>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,10 +60,22 @@ function ProjectMeta({
|
|||||||
locale: AppLocale;
|
locale: AppLocale;
|
||||||
}) {
|
}) {
|
||||||
const metadata = [
|
const metadata = [
|
||||||
{ icon: Tag, label: getLocalizedValue(item.category.name, locale) },
|
{
|
||||||
{ icon: CalendarDays, label: String(item.projectYear) },
|
icon: Tag,
|
||||||
{ icon: FolderKanban, label: getLocalizedValue(item.serviceLabel, locale) },
|
label: getLocalizedValue(item.category.name, locale),
|
||||||
{ icon: UserRound, label: item.clientName },
|
},
|
||||||
|
{
|
||||||
|
icon: CalendarDays,
|
||||||
|
label: String(item.projectYear),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: FolderKanban,
|
||||||
|
label: getLocalizedValue(item.serviceLabel, locale),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: UserRound,
|
||||||
|
label: item.clientName,
|
||||||
|
},
|
||||||
].filter((entry) => entry.label);
|
].filter((entry) => entry.label);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -120,111 +84,19 @@ function ProjectMeta({
|
|||||||
const Icon = meta.icon;
|
const Icon = meta.icon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<AppCard key={`${meta.label}-${Icon.name}`} level={2}>
|
||||||
key={`${meta.label}-${Icon.name}`}
|
<CardContent className="flex items-center gap-2 p-3">
|
||||||
className="inline-flex items-center gap-2 rounded-pill border border-border/70 bg-surface-2 px-3.5 py-1.5"
|
<Icon className="h-4 w-4 text-brand-primary" />
|
||||||
>
|
{meta.label}
|
||||||
<Icon className="h-4 w-4 text-brand-primary" />
|
</CardContent>
|
||||||
{meta.label}
|
</AppCard>
|
||||||
</span>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BackLink({
|
function SectionBlock({
|
||||||
locale,
|
|
||||||
defaultLocale,
|
|
||||||
t,
|
|
||||||
}: {
|
|
||||||
locale: AppLocale;
|
|
||||||
defaultLocale: AppLocale;
|
|
||||||
t: PortfolioProjectDetailProps["t"];
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Button asChild variant="ghost" className="h-auto px-0 py-0 text-sm text-muted-foreground">
|
|
||||||
<Link href={getLocalizedPath(locale, "/portfolio", defaultLocale)}>{t("back")}</Link>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PreviewButton({
|
|
||||||
item,
|
|
||||||
t,
|
|
||||||
full = false,
|
|
||||||
}: {
|
|
||||||
item: PortfolioProjectView;
|
|
||||||
t: PortfolioProjectDetailProps["t"];
|
|
||||||
full?: boolean;
|
|
||||||
}) {
|
|
||||||
if (!item.previewUrl) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button asChild className={full ? "w-full justify-center" : undefined}>
|
|
||||||
<Link href={item.previewUrl} target="_blank" rel="noreferrer">
|
|
||||||
{t("preview")}
|
|
||||||
<ArrowUpRight className="h-4 w-4" />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProjectIntro({ item, locale }: { item: PortfolioProjectView; locale: AppLocale }) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p className="text-overline uppercase tracking-[0.18em] text-brand-primary">
|
|
||||||
{getLocalizedValue(item.category.name, locale)} · {item.projectYear}
|
|
||||||
</p>
|
|
||||||
<h1 className="text-h1 text-foreground">{getLocalizedValue(item.title, locale)}</h1>
|
|
||||||
<p className="max-w-2xl text-body-lg text-muted-foreground">
|
|
||||||
{getLocalizedValue(item.summary, locale)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function InfoPanel({
|
|
||||||
item,
|
|
||||||
locale,
|
|
||||||
t,
|
|
||||||
}: {
|
|
||||||
item: PortfolioProjectView;
|
|
||||||
locale: AppLocale;
|
|
||||||
t: PortfolioProjectDetailProps["t"];
|
|
||||||
}) {
|
|
||||||
const rows = [
|
|
||||||
{ label: "Client", value: item.clientName },
|
|
||||||
{ label: "Year", value: String(item.projectYear) },
|
|
||||||
{ label: "Service", value: getLocalizedValue(item.serviceLabel, locale) },
|
|
||||||
{ label: "Category", value: getLocalizedValue(item.category.name, locale) },
|
|
||||||
].filter((row) => row.value);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AppCard level={2} className="lg:sticky lg:top-24">
|
|
||||||
<CardContent className="space-y-5 p-6">
|
|
||||||
<dl className="space-y-0">
|
|
||||||
{rows.map((row, index) => (
|
|
||||||
<div
|
|
||||||
key={row.label}
|
|
||||||
className={`flex items-center justify-between gap-4 py-2.5 text-sm ${
|
|
||||||
index < rows.length - 1 ? "border-b border-dashed border-border" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<dt className="text-muted-foreground">{row.label}</dt>
|
|
||||||
<dd className="font-semibold text-foreground">{row.value}</dd>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</dl>
|
|
||||||
<PreviewButton item={item} t={t} full />
|
|
||||||
</CardContent>
|
|
||||||
</AppCard>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TextSection({
|
|
||||||
section,
|
section,
|
||||||
locale,
|
locale,
|
||||||
t,
|
t,
|
||||||
@@ -236,204 +108,326 @@ function TextSection({
|
|||||||
const title = getLocalizedValue(section.title, locale);
|
const title = getLocalizedValue(section.title, locale);
|
||||||
const body = getLocalizedValue(section.body, locale);
|
const body = getLocalizedValue(section.body, locale);
|
||||||
|
|
||||||
|
if (section.type === "GALLERY") {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-xl font-semibold text-foreground">{title}</h3>
|
||||||
|
{section.imagePath ? (
|
||||||
|
<PortfolioImage
|
||||||
|
src={section.imagePath}
|
||||||
|
alt={title}
|
||||||
|
width={1400}
|
||||||
|
height={880}
|
||||||
|
className="h-72 w-full rounded-surface object-cover"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (section.type === "LINK") {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-xl font-semibold text-foreground">{title}</h3>
|
||||||
|
{body ? <p className="whitespace-pre-line text-sm leading-7 text-muted-foreground">{body}</p> : null}
|
||||||
|
{section.linkUrl ? (
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href={section.linkUrl} target="_blank" rel="noreferrer">
|
||||||
|
{t("openLink")}
|
||||||
|
<ArrowUpRight className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-4">
|
||||||
<h3 className="text-h3 text-foreground">{title}</h3>
|
<h3 className="text-xl font-semibold text-foreground">{title}</h3>
|
||||||
{body ? (
|
<p className="whitespace-pre-line text-sm leading-7 text-muted-foreground">{body}</p>
|
||||||
<p className="whitespace-pre-line text-body leading-7 text-muted-foreground">{body}</p>
|
|
||||||
) : null}
|
|
||||||
{section.type === "LINK" && section.linkUrl ? (
|
|
||||||
<Button asChild variant="outline">
|
|
||||||
<Link href={section.linkUrl} target="_blank" rel="noreferrer">
|
|
||||||
{t("openLink")}
|
|
||||||
<ArrowUpRight className="h-4 w-4" />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
function AssetGallery({
|
||||||
WEB (viewMode: CASE_STUDY)
|
assets,
|
||||||
Two columns: stacked full screenshots + sticky info panel.
|
locale,
|
||||||
============================================================ */
|
t,
|
||||||
function WebTemplate({ item, locale, defaultLocale, t }: PortfolioProjectDetailProps) {
|
}: {
|
||||||
const images = collectImages(item, locale);
|
assets: PortfolioAssetView[];
|
||||||
const texts = textSections(item);
|
locale: AppLocale;
|
||||||
|
t: PortfolioProjectDetailProps["t"];
|
||||||
|
}) {
|
||||||
|
if (assets.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MotionFade delay={0.12}>
|
||||||
|
<AppCard>
|
||||||
|
<CardContent className="p-6 lg:p-8">
|
||||||
|
<div className="flex items-end justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm uppercase tracking-[0.18em] text-muted-foreground">Assets</p>
|
||||||
|
<h2 className="mt-2 text-2xl font-semibold text-foreground">{t("gallery")}</h2>
|
||||||
|
</div>
|
||||||
|
<BadgeCount count={assets.length} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
||||||
|
{assets.map((asset) => (
|
||||||
|
<div key={asset.id} className="overflow-hidden rounded-surface border border-border bg-card">
|
||||||
|
{asset.kind === "IMAGE" ? (
|
||||||
|
<PortfolioImage
|
||||||
|
src={asset.filePath}
|
||||||
|
alt={getLocalizedValue(asset.alt, locale)}
|
||||||
|
width={1200}
|
||||||
|
height={760}
|
||||||
|
className="h-72 w-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex min-h-72 items-center justify-center bg-muted/30 p-6 text-center">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm font-medium text-foreground">
|
||||||
|
{getLocalizedValue(asset.alt, locale)}
|
||||||
|
</p>
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href={asset.filePath} target="_blank" rel="noreferrer">
|
||||||
|
{t("download")}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</AppCard>
|
||||||
|
</MotionFade>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BadgeCount({ count }: { count: number }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-pill border border-border/70 bg-background px-4 py-2 text-sm text-muted-foreground">
|
||||||
|
{count} {count === 1 ? "file" : "files"}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectHeader({
|
||||||
|
item,
|
||||||
|
locale,
|
||||||
|
defaultLocale,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
item: PortfolioProjectView;
|
||||||
|
locale: AppLocale;
|
||||||
|
defaultLocale: AppLocale;
|
||||||
|
t: PortfolioProjectDetailProps["t"];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MotionFade>
|
||||||
|
<AppCard level={3}>
|
||||||
|
<CardContent className="p-6 lg:p-10">
|
||||||
|
<Button asChild variant="ghost" className="h-auto px-0 py-0 text-sm">
|
||||||
|
<Link href={getLocalizedPath(locale, "/portfolio", defaultLocale)}>{t("back")}</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<ProjectMeta item={item} locale={locale} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{item.previewUrl ? (
|
||||||
|
<div className="mt-8">
|
||||||
|
<Button asChild>
|
||||||
|
<Link href={item.previewUrl} target="_blank" rel="noreferrer">
|
||||||
|
{t("preview")}
|
||||||
|
<ArrowUpRight className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</AppCard>
|
||||||
|
</MotionFade>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GridTemplate({
|
||||||
|
item,
|
||||||
|
locale,
|
||||||
|
defaultLocale,
|
||||||
|
t,
|
||||||
|
}: PortfolioProjectDetailProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<ProjectHeader item={item} locale={locale} defaultLocale={defaultLocale} t={t} />
|
||||||
|
|
||||||
|
{item.coverImagePath ? (
|
||||||
|
<MotionFade delay={0.04}>
|
||||||
|
<AppCard>
|
||||||
|
<CardContent className="p-3 lg:p-4">
|
||||||
|
<PortfolioImage
|
||||||
|
src={item.coverImagePath}
|
||||||
|
alt={getLocalizedValue(item.title, locale)}
|
||||||
|
width={1800}
|
||||||
|
height={1000}
|
||||||
|
className="h-auto w-full rounded-surface object-cover"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</AppCard>
|
||||||
|
</MotionFade>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
{item.sections.map((section, index) => (
|
||||||
|
<MotionFade key={section.id} delay={0.06 * (index + 1)}>
|
||||||
|
<AppCard>
|
||||||
|
<CardContent className="p-5 lg:p-6">
|
||||||
|
<SectionBlock section={section} locale={locale} t={t} />
|
||||||
|
</CardContent>
|
||||||
|
</AppCard>
|
||||||
|
</MotionFade>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AssetGallery assets={item.assets} locale={locale} t={t} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StoryTemplate({
|
||||||
|
item,
|
||||||
|
locale,
|
||||||
|
defaultLocale,
|
||||||
|
t,
|
||||||
|
}: PortfolioProjectDetailProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<ProjectHeader item={item} locale={locale} defaultLocale={defaultLocale} t={t} />
|
||||||
|
|
||||||
|
{item.coverImagePath ? (
|
||||||
|
<MotionFade delay={0.04}>
|
||||||
|
<div className="overflow-hidden rounded-[32px] border border-border/70 bg-card p-3 shadow-card">
|
||||||
|
<PortfolioImage
|
||||||
|
src={item.coverImagePath}
|
||||||
|
alt={getLocalizedValue(item.title, locale)}
|
||||||
|
width={1800}
|
||||||
|
height={1100}
|
||||||
|
className="h-[440px] w-full rounded-[28px] object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</MotionFade>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{item.sections.map((section, index) => (
|
||||||
|
<MotionFade key={section.id} delay={0.06 * (index + 1)}>
|
||||||
|
<div className="grid gap-4 lg:grid-cols-[120px_minmax(0,1fr)]">
|
||||||
|
<div className="pt-4">
|
||||||
|
<div className="inline-flex rounded-pill border border-border/70 bg-background px-4 py-2 text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||||
|
{String(index + 1).padStart(2, "0")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AppCard className="overflow-hidden">
|
||||||
|
<CardContent className="p-6 lg:p-8">
|
||||||
|
<SectionBlock section={section} locale={locale} t={t} />
|
||||||
|
</CardContent>
|
||||||
|
</AppCard>
|
||||||
|
</div>
|
||||||
|
</MotionFade>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AssetGallery assets={item.assets} locale={locale} t={t} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CaseStudyTemplate({
|
||||||
|
item,
|
||||||
|
locale,
|
||||||
|
defaultLocale,
|
||||||
|
t,
|
||||||
|
}: PortfolioProjectDetailProps) {
|
||||||
|
const [challenge, solution, outcome, ...restSections] = item.sections;
|
||||||
|
const leadSections = [challenge, solution, outcome].filter(
|
||||||
|
(section): section is PortfolioSectionView => Boolean(section),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<MotionFade>
|
<ProjectHeader item={item} locale={locale} defaultLocale={defaultLocale} t={t} />
|
||||||
<div className="space-y-6">
|
|
||||||
<BackLink locale={locale} defaultLocale={defaultLocale} t={t} />
|
|
||||||
<ProjectIntro item={item} locale={locale} />
|
|
||||||
</div>
|
|
||||||
</MotionFade>
|
|
||||||
|
|
||||||
<div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_340px] lg:items-start">
|
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_420px]">
|
||||||
<div className="order-2 space-y-6 lg:order-1">
|
<div className="space-y-6">
|
||||||
{images.map((image, index) => (
|
{item.coverImagePath ? (
|
||||||
<MotionFade key={image.key} delay={0.04 * index}>
|
<MotionFade delay={0.04}>
|
||||||
<MediaFrame image={image} chrome aspect="aspect-[16/11]" priority={index === 0} />
|
<AppCard className="overflow-hidden">
|
||||||
|
<CardContent className="p-3">
|
||||||
|
<PortfolioImage
|
||||||
|
src={item.coverImagePath}
|
||||||
|
alt={getLocalizedValue(item.title, locale)}
|
||||||
|
width={1800}
|
||||||
|
height={1100}
|
||||||
|
className="h-auto w-full rounded-surface object-cover"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</AppCard>
|
||||||
|
</MotionFade>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{leadSections.map((section, index) => (
|
||||||
|
<MotionFade key={section.id} delay={0.06 * (index + 1)}>
|
||||||
|
<AppCard level={index === 1 ? 3 : 1}>
|
||||||
|
<CardContent className="space-y-4 p-6 lg:p-8">
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||||
|
{index === 0 ? "Challenge" : index === 1 ? "Solution" : "Outcome"}
|
||||||
|
</p>
|
||||||
|
<SectionBlock section={section} locale={locale} t={t} />
|
||||||
|
</CardContent>
|
||||||
|
</AppCard>
|
||||||
</MotionFade>
|
</MotionFade>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{texts.length > 0 ? (
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
{texts.map((section) => (
|
|
||||||
<AppCard key={section.id}>
|
|
||||||
<CardContent className="p-5 lg:p-6">
|
|
||||||
<TextSection section={section} locale={locale} t={t} />
|
|
||||||
</CardContent>
|
|
||||||
</AppCard>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside className="order-1 lg:order-2">
|
|
||||||
<InfoPanel item={item} locale={locale} t={t} />
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================
|
|
||||||
PRINT (viewMode: GRID)
|
|
||||||
Gallery: mixed-size grid of images (logos, posters, ...).
|
|
||||||
============================================================ */
|
|
||||||
function PrintTemplate({ item, locale, defaultLocale, t }: PortfolioProjectDetailProps) {
|
|
||||||
const images = collectImages(item, locale);
|
|
||||||
const texts = textSections(item);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-10">
|
|
||||||
<MotionFade>
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<BackLink locale={locale} defaultLocale={defaultLocale} t={t} />
|
<MotionFade delay={0.08}>
|
||||||
<ProjectIntro item={item} locale={locale} />
|
<AppCard level={2} className="sticky top-24">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<CardContent className="space-y-5 p-6">
|
||||||
<ProjectMeta item={item} locale={locale} />
|
<div>
|
||||||
<PreviewButton item={item} t={t} />
|
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">Case Study Snapshot</p>
|
||||||
</div>
|
<h2 className="mt-2 text-2xl font-semibold text-foreground">
|
||||||
|
{getLocalizedValue(item.title, locale)}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm leading-7 text-muted-foreground">
|
||||||
|
{getLocalizedValue(item.summary, locale)}
|
||||||
|
</p>
|
||||||
|
<ProjectMeta item={item} locale={locale} />
|
||||||
|
</CardContent>
|
||||||
|
</AppCard>
|
||||||
|
</MotionFade>
|
||||||
</div>
|
</div>
|
||||||
</MotionFade>
|
</div>
|
||||||
|
|
||||||
<MotionFade delay={0.06}>
|
{restSections.length > 0 ? (
|
||||||
<div className="grid auto-rows-[minmax(0,1fr)] grid-cols-2 gap-4 md:grid-cols-3">
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
{images.map((image, index) => {
|
{restSections.map((section, index) => (
|
||||||
// First image spans a large feature tile; the rest alternate size.
|
<MotionFade key={section.id} delay={0.1 + index * 0.04}>
|
||||||
const span =
|
|
||||||
index === 0
|
|
||||||
? "col-span-2 row-span-2"
|
|
||||||
: index % 4 === 0
|
|
||||||
? "col-span-2"
|
|
||||||
: "";
|
|
||||||
const aspect = index === 0 ? "aspect-square" : "aspect-[4/3]";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={image.key} className={span}>
|
|
||||||
<MediaFrame image={image} aspect={index === 0 ? "aspect-square" : aspect} priority={index === 0} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</MotionFade>
|
|
||||||
|
|
||||||
{texts.length > 0 ? (
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
{texts.map((section) => (
|
|
||||||
<MotionFade key={section.id} delay={0.04}>
|
|
||||||
<AppCard>
|
<AppCard>
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-5 lg:p-6">
|
||||||
<TextSection section={section} locale={locale} t={t} />
|
<SectionBlock section={section} locale={locale} t={t} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
</MotionFade>
|
</MotionFade>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================
|
<AssetGallery assets={item.assets} locale={locale} t={t} />
|
||||||
EDITORIAL (viewMode: STORY)
|
|
||||||
Full-bleed hero + alternating text / media sections.
|
|
||||||
============================================================ */
|
|
||||||
function EditorialTemplate({ item, locale, defaultLocale, t }: PortfolioProjectDetailProps) {
|
|
||||||
const cover = collectImages(item, locale)[0]!;
|
|
||||||
const sections = item.sections;
|
|
||||||
const galleryImages = collectImages(item, locale).slice(1);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<MotionFade>
|
|
||||||
<BackLink locale={locale} defaultLocale={defaultLocale} t={t} />
|
|
||||||
</MotionFade>
|
|
||||||
|
|
||||||
<MotionFade delay={0.04}>
|
|
||||||
<div className="relative flex min-h-[420px] items-end overflow-hidden rounded-surface border border-border">
|
|
||||||
<div className="absolute inset-0">
|
|
||||||
<PortfolioCover src={cover.src} title={cover.label} priority sizes="100vw" />
|
|
||||||
</div>
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-background/90 via-background/40 to-transparent" />
|
|
||||||
<div className="relative z-10 max-w-3xl space-y-4 p-8 lg:p-12">
|
|
||||||
<p className="text-overline uppercase tracking-[0.18em] text-brand-primary">
|
|
||||||
{getLocalizedValue(item.category.name, locale)} · {item.projectYear}
|
|
||||||
</p>
|
|
||||||
<h1 className="text-display text-foreground">{getLocalizedValue(item.title, locale)}</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</MotionFade>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
<ProjectMeta item={item} locale={locale} />
|
|
||||||
<PreviewButton item={item} t={t} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<MotionFade delay={0.06}>
|
|
||||||
<p className="max-w-3xl py-6 text-h3 font-medium leading-snug text-foreground/90">
|
|
||||||
{getLocalizedValue(item.summary, locale)}
|
|
||||||
</p>
|
|
||||||
</MotionFade>
|
|
||||||
|
|
||||||
<div className="space-y-16 py-4">
|
|
||||||
{sections.map((section, index) => {
|
|
||||||
if (section.type === "GALLERY") {
|
|
||||||
return (
|
|
||||||
<MotionFade key={section.id} delay={0.04}>
|
|
||||||
<MediaFrame
|
|
||||||
image={{
|
|
||||||
key: section.id,
|
|
||||||
src: section.imagePath ?? null,
|
|
||||||
label: getLocalizedValue(section.title, locale),
|
|
||||||
}}
|
|
||||||
aspect="aspect-[16/8]"
|
|
||||||
/>
|
|
||||||
</MotionFade>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MotionFade key={section.id} delay={0.04}>
|
|
||||||
<div
|
|
||||||
className={`grid items-center gap-8 lg:grid-cols-2 ${
|
|
||||||
index % 2 === 1 ? "lg:[&>*:first-child]:order-2" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<TextSection section={section} locale={locale} t={t} />
|
|
||||||
<MediaFrame
|
|
||||||
image={galleryImages[index % Math.max(galleryImages.length, 1)] ?? cover}
|
|
||||||
aspect="aspect-[4/3]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</MotionFade>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -447,12 +441,12 @@ export function PortfolioProjectDetail({
|
|||||||
const viewMode = resolvePortfolioProjectViewMode(item.viewMode);
|
const viewMode = resolvePortfolioProjectViewMode(item.viewMode);
|
||||||
|
|
||||||
if (viewMode === "STORY") {
|
if (viewMode === "STORY") {
|
||||||
return <EditorialTemplate item={item} locale={locale} defaultLocale={defaultLocale} t={t} />;
|
return <StoryTemplate item={item} locale={locale} defaultLocale={defaultLocale} t={t} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (viewMode === "CASE_STUDY") {
|
if (viewMode === "CASE_STUDY") {
|
||||||
return <WebTemplate item={item} locale={locale} defaultLocale={defaultLocale} t={t} />;
|
return <CaseStudyTemplate item={item} locale={locale} defaultLocale={defaultLocale} t={t} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <PrintTemplate item={item} locale={locale} defaultLocale={defaultLocale} t={t} />;
|
return <GridTemplate item={item} locale={locale} defaultLocale={defaultLocale} t={t} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,21 +87,10 @@ export interface AppCardProps
|
|||||||
VariantProps<typeof appCardVariants>,
|
VariantProps<typeof appCardVariants>,
|
||||||
VariantProps<typeof appCardInnerVariants> {
|
VariantProps<typeof appCardInnerVariants> {
|
||||||
layer?: "double" | "single";
|
layer?: "double" | "single";
|
||||||
/**
|
|
||||||
* Classes applied to the element that directly wraps `children` (the inner
|
|
||||||
* shell in `double` layer, the single shell in `single` layer). Use it for
|
|
||||||
* content utilities such as `space-y-*` / `grid` / `flex` so they keep
|
|
||||||
* working after switching a card to the layered (double) look, where
|
|
||||||
* `className` lands on the outer shell instead.
|
|
||||||
*/
|
|
||||||
contentClassName?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const AppCard = React.forwardRef<HTMLDivElement, AppCardProps>(
|
const AppCard = React.forwardRef<HTMLDivElement, AppCardProps>(
|
||||||
(
|
({ className, level, padding, interactive, layer = "double", children, ...props }, ref) => {
|
||||||
{ className, contentClassName, level, padding, interactive, layer = "double", children, ...props },
|
|
||||||
ref,
|
|
||||||
) => {
|
|
||||||
if (layer === "single") {
|
if (layer === "single") {
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
@@ -113,7 +102,6 @@ const AppCard = React.forwardRef<HTMLDivElement, AppCardProps>(
|
|||||||
interactive,
|
interactive,
|
||||||
}),
|
}),
|
||||||
className,
|
className,
|
||||||
contentClassName,
|
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -141,7 +129,6 @@ const AppCard = React.forwardRef<HTMLDivElement, AppCardProps>(
|
|||||||
padding,
|
padding,
|
||||||
interactive,
|
interactive,
|
||||||
}),
|
}),
|
||||||
contentClassName,
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,152 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useRef, type PropsWithChildren } from "react";
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority";
|
|
||||||
import {
|
|
||||||
motion,
|
|
||||||
useMotionValue,
|
|
||||||
useSpring,
|
|
||||||
useTransform,
|
|
||||||
type MotionValue,
|
|
||||||
type MotionProps,
|
|
||||||
} from "framer-motion";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
export interface DockProps extends VariantProps<typeof dockVariants> {
|
|
||||||
className?: string;
|
|
||||||
iconSize?: number;
|
|
||||||
iconMagnification?: number;
|
|
||||||
disableMagnification?: boolean;
|
|
||||||
iconDistance?: number;
|
|
||||||
direction?: "top" | "middle" | "bottom";
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_SIZE = 40;
|
|
||||||
const DEFAULT_MAGNIFICATION = 60;
|
|
||||||
const DEFAULT_DISTANCE = 140;
|
|
||||||
const DEFAULT_DISABLEMAGNIFICATION = false;
|
|
||||||
|
|
||||||
const dockVariants = cva(
|
|
||||||
"supports-backdrop-blur:bg-white/10 supports-backdrop-blur:dark:bg-black/10 mx-auto flex w-max items-center justify-center gap-2 rounded-2xl border p-2 backdrop-blur-md",
|
|
||||||
);
|
|
||||||
|
|
||||||
const Dock = React.forwardRef<HTMLDivElement, DockProps>(
|
|
||||||
(
|
|
||||||
{
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
iconSize = DEFAULT_SIZE,
|
|
||||||
iconMagnification = DEFAULT_MAGNIFICATION,
|
|
||||||
disableMagnification = DEFAULT_DISABLEMAGNIFICATION,
|
|
||||||
iconDistance = DEFAULT_DISTANCE,
|
|
||||||
direction = "bottom",
|
|
||||||
...props
|
|
||||||
},
|
|
||||||
ref,
|
|
||||||
) => {
|
|
||||||
const mouseX = useMotionValue(Infinity);
|
|
||||||
|
|
||||||
const renderChildren = () => {
|
|
||||||
return React.Children.map(children, (child) => {
|
|
||||||
if (React.isValidElement<DockIconProps>(child) && child.type === DockIcon) {
|
|
||||||
return React.cloneElement(child, {
|
|
||||||
...child.props,
|
|
||||||
mouseX: mouseX,
|
|
||||||
size: iconSize,
|
|
||||||
magnification: iconMagnification,
|
|
||||||
disableMagnification: disableMagnification,
|
|
||||||
distance: iconDistance,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return child;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
ref={ref}
|
|
||||||
onMouseMove={(e) => mouseX.set(e.pageX)}
|
|
||||||
onMouseLeave={() => mouseX.set(Infinity)}
|
|
||||||
{...props}
|
|
||||||
className={cn(dockVariants({ className }), {
|
|
||||||
"items-start": direction === "top",
|
|
||||||
"items-center": direction === "middle",
|
|
||||||
"items-end": direction === "bottom",
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{renderChildren()}
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
Dock.displayName = "Dock";
|
|
||||||
|
|
||||||
export interface DockIconProps
|
|
||||||
extends Omit<MotionProps & React.HTMLAttributes<HTMLDivElement>, "children"> {
|
|
||||||
size?: number;
|
|
||||||
magnification?: number;
|
|
||||||
disableMagnification?: boolean;
|
|
||||||
distance?: number;
|
|
||||||
mouseX?: MotionValue<number>;
|
|
||||||
className?: string;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
props?: PropsWithChildren;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DockIcon = ({
|
|
||||||
size = DEFAULT_SIZE,
|
|
||||||
magnification = DEFAULT_MAGNIFICATION,
|
|
||||||
disableMagnification,
|
|
||||||
distance = DEFAULT_DISTANCE,
|
|
||||||
mouseX,
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: DockIconProps) => {
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
const padding = 0;
|
|
||||||
const defaultMouseX = useMotionValue(Infinity);
|
|
||||||
|
|
||||||
const distanceCalc = useTransform(mouseX ?? defaultMouseX, (val: number) => {
|
|
||||||
const bounds = ref.current?.getBoundingClientRect() ?? { x: 0, width: 0 };
|
|
||||||
return val - bounds.x - bounds.width / 2;
|
|
||||||
});
|
|
||||||
|
|
||||||
const targetSize = disableMagnification ? size : magnification;
|
|
||||||
|
|
||||||
const sizeTransform = useTransform(
|
|
||||||
distanceCalc,
|
|
||||||
[-distance, 0, distance],
|
|
||||||
[size, targetSize, size],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Snappy, near-instant tracking with a touch of smoothing — closer to how the
|
|
||||||
// real macOS dock follows the cursor than a soft/bouncy spring.
|
|
||||||
const scaleSize = useSpring(sizeTransform, {
|
|
||||||
mass: 0.1,
|
|
||||||
stiffness: 280,
|
|
||||||
damping: 22,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
ref={ref}
|
|
||||||
style={{ width: scaleSize, height: scaleSize, padding }}
|
|
||||||
className={cn(
|
|
||||||
"flex aspect-square cursor-pointer items-center justify-center rounded-full",
|
|
||||||
disableMagnification && "hover:bg-muted-foreground transition-colors",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
DockIcon.displayName = "DockIcon";
|
|
||||||
|
|
||||||
export { Dock, DockIcon, dockVariants };
|
|
||||||
@@ -98,28 +98,22 @@ function TabsTrigger({
|
|||||||
|
|
||||||
type TabsContentProps = React.HTMLAttributes<HTMLDivElement> & {
|
type TabsContentProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||||
value: string;
|
value: string;
|
||||||
// Keep the panel mounted while inactive (hidden via `hidden`) so its form
|
|
||||||
// fields still submit. Use for tabbed forms that share one submit button.
|
|
||||||
forceMount?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function TabsContent({
|
function TabsContent({
|
||||||
className,
|
className,
|
||||||
value,
|
value,
|
||||||
children,
|
children,
|
||||||
forceMount = false,
|
|
||||||
...props
|
...props
|
||||||
}: TabsContentProps) {
|
}: TabsContentProps) {
|
||||||
const { value: activeValue } = useTabsContext();
|
const { value: activeValue } = useTabsContext();
|
||||||
const isActive = activeValue === value;
|
|
||||||
|
|
||||||
if (!isActive && !forceMount) {
|
if (activeValue !== value) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
hidden={!isActive}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"mt-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0",
|
"mt-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0",
|
||||||
className,
|
className,
|
||||||
|
|||||||
@@ -12,9 +12,8 @@ services:
|
|||||||
NEXT_TELEMETRY_DISABLED: "1"
|
NEXT_TELEMETRY_DISABLED: "1"
|
||||||
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-https://mohfarawati.de}
|
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-https://mohfarawati.de}
|
||||||
NEXT_PUBLIC_ADMIN_URL: ${NEXT_PUBLIC_ADMIN_URL:-https://root.mohfarawati.de}
|
NEXT_PUBLIC_ADMIN_URL: ${NEXT_PUBLIC_ADMIN_URL:-https://root.mohfarawati.de}
|
||||||
ADMIN_HOST: ${ADMIN_HOST:-root.mohfarawati.de}
|
|
||||||
SITE_RUNTIME_ORIGIN: ${SITE_RUNTIME_ORIGIN:-http://127.0.0.1:3000}
|
SITE_RUNTIME_ORIGIN: ${SITE_RUNTIME_ORIGIN:-http://127.0.0.1:3000}
|
||||||
DATABASE_URL: postgresql://postgres:postgres@db:5432/moh_sass
|
DATABASE_URL: postgresql://postgres:postgres@db:5432/moh_sass?schema=public
|
||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD must be set in .env}
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD must be set in .env}
|
||||||
ADMIN_AUTH_SECRET: ${ADMIN_AUTH_SECRET:?ADMIN_AUTH_SECRET must be set in .env (use a long random string)}
|
ADMIN_AUTH_SECRET: ${ADMIN_AUTH_SECRET:?ADMIN_AUTH_SECRET must be set in .env (use a long random string)}
|
||||||
ADMIN_BASIC_AUTH_USER: ${ADMIN_BASIC_AUTH_USER:?ADMIN_BASIC_AUTH_USER must be set in .env}
|
ADMIN_BASIC_AUTH_USER: ${ADMIN_BASIC_AUTH_USER:?ADMIN_BASIC_AUTH_USER must be set in .env}
|
||||||
@@ -28,25 +27,11 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
start_period: 20s
|
start_period: 20s
|
||||||
labels:
|
ports:
|
||||||
- traefik.enable=true
|
- "${PORT:-3014}:3000"
|
||||||
- traefik.docker.network=${TRAEFIK_NETWORK:-proxy}
|
|
||||||
# HTTPS router — public site + www + admin subdomain all hit this one app;
|
|
||||||
# the app's middleware routes root.mohfarawati.de to the admin internally.
|
|
||||||
- "traefik.http.routers.sass.rule=Host(`mohfarawati.de`) || Host(`www.mohfarawati.de`) || Host(`root.mohfarawati.de`)"
|
|
||||||
- traefik.http.routers.sass.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}
|
|
||||||
- traefik.http.routers.sass.tls=true
|
|
||||||
- traefik.http.routers.sass.tls.certresolver=${TRAEFIK_CERTRESOLVER:-cf}
|
|
||||||
- traefik.http.services.sass.loadbalancer.server.port=3000
|
|
||||||
# HTTP router → redirect to HTTPS
|
|
||||||
- "traefik.http.routers.sass-http.rule=Host(`mohfarawati.de`) || Host(`www.mohfarawati.de`) || Host(`root.mohfarawati.de`)"
|
|
||||||
- traefik.http.routers.sass-http.entrypoints=web
|
|
||||||
- traefik.http.routers.sass-http.middlewares=sass-redirect
|
|
||||||
- traefik.http.middlewares.sass-redirect.redirectscheme.scheme=https
|
|
||||||
volumes:
|
volumes:
|
||||||
- media_uploads:/app/public/uploads/media
|
- media_uploads:/app/public/uploads/media
|
||||||
networks:
|
networks:
|
||||||
- proxy
|
|
||||||
- appnet
|
- appnet
|
||||||
|
|
||||||
db:
|
db:
|
||||||
@@ -72,8 +57,3 @@ volumes:
|
|||||||
|
|
||||||
networks:
|
networks:
|
||||||
appnet:
|
appnet:
|
||||||
# Shared external Traefik network (same one the other projects use).
|
|
||||||
# Override the name per server with TRAEFIK_NETWORK in .env.
|
|
||||||
proxy:
|
|
||||||
external: true
|
|
||||||
name: ${TRAEFIK_NETWORK:-proxy}
|
|
||||||
|
|||||||
@@ -181,8 +181,8 @@ i18n/routing.ts
|
|||||||
Admin routing
|
Admin routing
|
||||||
lib/admin-routing.ts
|
lib/admin-routing.ts
|
||||||
|
|
||||||
Database access (Drizzle)
|
Prisma access
|
||||||
lib/db/index.ts, lib/db/schema.ts
|
lib/prisma.ts
|
||||||
|
|
||||||
Application configuration
|
Application configuration
|
||||||
lib/app-config.ts
|
lib/app-config.ts
|
||||||
@@ -191,13 +191,7 @@ Portfolio logic
|
|||||||
lib/portfolio.ts
|
lib/portfolio.ts
|
||||||
|
|
||||||
Media handling
|
Media handling
|
||||||
lib/media.ts (DB), lib/media-storage.ts (filesystem + content validation), lib/media-service.ts
|
lib/media.ts
|
||||||
|
|
||||||
SEO / metadata
|
|
||||||
lib/metadata.ts, lib/seo-settings.ts, lib/seo-report.ts, app/robots.ts, app/sitemap.ts (docs/SEO.md)
|
|
||||||
|
|
||||||
Admin session token (shared by middleware and server auth)
|
|
||||||
lib/admin-session-token.ts
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -14,11 +14,8 @@
|
|||||||
- Contact form with:
|
- Contact form with:
|
||||||
- validation
|
- validation
|
||||||
- email delivery
|
- email delivery
|
||||||
- Success page after contact submission (noindex)
|
- Success page after contact submission
|
||||||
- Maintenance redirect flow (bypass requires a *signed* admin session cookie)
|
- Maintenance redirect flow
|
||||||
- SEO: localized metadata with canonical + hreflang, OG/Twitter cards (project
|
|
||||||
cover as share image), JSON-LD (WebSite + Person/Organization, CreativeWork per
|
|
||||||
project), dynamic `robots.txt` and hreflang `sitemap.xml` — see `docs/SEO.md`
|
|
||||||
|
|
||||||
### Admin
|
### Admin
|
||||||
|
|
||||||
@@ -27,12 +24,8 @@
|
|||||||
- Portfolio category management
|
- Portfolio category management
|
||||||
- Portfolio project creation and editing
|
- Portfolio project creation and editing
|
||||||
- Section and asset management inside each project
|
- Section and asset management inside each project
|
||||||
- Media library with usage bindings (uploads are magic-byte checked, SVGs are
|
- Media library with usage bindings
|
||||||
sanitized and served sandboxed)
|
- Site settings management
|
||||||
- Site settings management (Brand, Localization, SEO)
|
|
||||||
- SEO page: indexing switch, Search Console/Bing verification, X handle,
|
|
||||||
structured-data identity, per-locale keywords, readiness checklist and links
|
|
||||||
to sitemap/robots/manifest
|
|
||||||
- SMTP settings and test email
|
- SMTP settings and test email
|
||||||
- Marquee settings
|
- Marquee settings
|
||||||
- Maintenance toggle
|
- Maintenance toggle
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
# SEO
|
|
||||||
|
|
||||||
How search visibility works in this project and where each piece is controlled.
|
|
||||||
Everything is data-driven from the admin; no code change is needed to adjust
|
|
||||||
titles, descriptions, indexing, verification, or structured data.
|
|
||||||
|
|
||||||
## Admin: Settings → SEO (`/site-settings/seo`)
|
|
||||||
|
|
||||||
Canonical page: `app/_admin/site-settings/seo/page.tsx` (mirrored under
|
|
||||||
`app/admin-internal/` and `app/root/`). Form: `components/admin/seo-settings-form.tsx`.
|
|
||||||
Action: `saveSeoSettingsAction` in `app/_admin/site-settings/actions.ts`.
|
|
||||||
|
|
||||||
Stored as one JSON blob in `app_config` under key `seo_settings`
|
|
||||||
(`lib/seo-settings.ts` parses/normalizes; `lib/app-config.ts` exposes
|
|
||||||
`getSeoSettings` / `updateSeoSettings`).
|
|
||||||
|
|
||||||
| Field | Effect |
|
|
||||||
|---|---|
|
|
||||||
| Indexierung erlauben | Off → `noindex,nofollow` meta on every page, `robots.txt` disallows `/`, `sitemap.xml` becomes empty. Maintenance mode forces the same automatically. |
|
|
||||||
| Google / Bing Verification | `<meta name="google-site-verification">` and `<meta name="msvalidate.01">` on all pages. Tokens are restricted to `[A-Za-z0-9_-]`. |
|
|
||||||
| X / Twitter Handle | `twitter:site` + `twitter:creator`. |
|
|
||||||
| Strukturierte Daten | Type (`Person` / `Organization`), name, job title/slogan, `sameAs` profile URLs → JSON-LD publisher on every public page. |
|
|
||||||
| Keywords (per locale) | `<meta name="keywords">` per language. |
|
|
||||||
|
|
||||||
The page also shows a **checklist** (`lib/seo-report.ts`) — indexing state, public
|
|
||||||
URL, meta description length per locale, OG image, favicon, verification,
|
|
||||||
structured data, published projects, sitemap URL count — and **open buttons** for
|
|
||||||
`/sitemap.xml`, `/robots.txt`, `/manifest.webmanifest`.
|
|
||||||
|
|
||||||
Titles, descriptions and the title template per locale live under
|
|
||||||
**Settings → Localization**; logos, favicon and the default OG image under
|
|
||||||
**Settings → Brand**.
|
|
||||||
|
|
||||||
## Generated files
|
|
||||||
|
|
||||||
- `app/robots.ts` → `/robots.txt`. Indexable: allow `/`, disallow admin
|
|
||||||
(`/admin-internal`, `/root`), `/api/`, `/success`, `/coming-soon` (+ locale
|
|
||||||
variants), plus the sitemap URL. Not indexable (setting off or maintenance):
|
|
||||||
disallow everything.
|
|
||||||
- `app/sitemap.ts` → `/sitemap.xml`. One entry per locale for home, about,
|
|
||||||
portfolio, contact, every category that has published projects, and every
|
|
||||||
published project — each with `xhtml:link hreflang` alternates and `x-default`.
|
|
||||||
Empty while maintenance mode is on or indexing is disabled.
|
|
||||||
- `app/manifest.ts` → `/manifest.webmanifest` (icons from Brand settings).
|
|
||||||
|
|
||||||
## Per-page metadata (`lib/metadata.ts`)
|
|
||||||
|
|
||||||
- `buildAppMetadata()` — root layout: `metadataBase`, robots, verification,
|
|
||||||
keywords, icons, manifest, OG (`og:locale` as `de_DE`/`en_US`/`ar_AR` +
|
|
||||||
`alternateLocale`), Twitter.
|
|
||||||
- `buildLocalizedMetadata({...})` — every public page: templated title,
|
|
||||||
description (≤300 chars), canonical + hreflang alternates, robots, OG, Twitter.
|
|
||||||
Options: `image` (page-specific share image), `noIndex`, `type: "article"`,
|
|
||||||
`publishedTime`.
|
|
||||||
- Portfolio project pages pass the project **cover** as OG image and `article`
|
|
||||||
type. This is independent of the project's view mode (`GRID` / `STORY` /
|
|
||||||
`CASE_STUDY`), so new view modes inherit full SEO automatically.
|
|
||||||
- `/success` and `/coming-soon` are `noindex`.
|
|
||||||
|
|
||||||
## Structured data (JSON-LD)
|
|
||||||
|
|
||||||
Rendered via `components/seo/json-ld.tsx` (server component; `<` is escaped).
|
|
||||||
|
|
||||||
- Site layout: `WebSite` + publisher (`Person` or `Organization`) graph linked by
|
|
||||||
`@id` (`buildSiteJsonLd`).
|
|
||||||
- Project page: `CreativeWork` with url, headline, description, image, genre
|
|
||||||
(category), keywords (service label, year), `datePublished`, author `@id`,
|
|
||||||
client as `sourceOrganization` (`buildProjectJsonLd`).
|
|
||||||
|
|
||||||
## Slugs
|
|
||||||
|
|
||||||
Categories and projects share `/portfolio/[slug]`; categories win at resolve
|
|
||||||
time. The admin therefore rejects a project slug that equals an existing
|
|
||||||
category slug and vice versa (`app/_admin/portfolio/actions.ts`).
|
|
||||||
|
|
||||||
## Operational checklist before launch
|
|
||||||
|
|
||||||
1. `NEXT_PUBLIC_SITE_URL` must be the public `https://` origin (canonical base).
|
|
||||||
2. Settings → Localization: site name + 50–160 char description in DE/EN/AR.
|
|
||||||
3. Settings → Brand: default OG image (1200×630) + favicon.
|
|
||||||
4. Settings → SEO: indexing on, verification codes, Person/Organization data.
|
|
||||||
5. Maintenance mode off. Verify `/robots.txt` and `/sitemap.xml` from the SEO page.
|
|
||||||
6. Submit the sitemap in Google Search Console / Bing Webmaster.
|
|
||||||
@@ -1,22 +1,13 @@
|
|||||||
import { existsSync } from "node:fs";
|
import type { Config } from "drizzle-kit";
|
||||||
|
|
||||||
import { defineConfig } from "drizzle-kit";
|
const rawConnectionString =
|
||||||
|
process.env.DATABASE_URL ?? "postgresql://postgres:postgres@localhost:5432/moh_sass";
|
||||||
|
|
||||||
// drizzle-kit (unlike the Next.js app) does not load .env automatically, so
|
export default {
|
||||||
// DATABASE_URL would be undefined and fall back to the wrong host. Load .env
|
|
||||||
// for local dev. In production the env is already provided (docker-compose)
|
|
||||||
// and no .env file exists, so this is skipped.
|
|
||||||
if (!process.env.DATABASE_URL && existsSync(".env")) {
|
|
||||||
process.loadEnvFile(".env");
|
|
||||||
}
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
schema: "./lib/db/schema.ts",
|
schema: "./lib/db/schema.ts",
|
||||||
out: "./lib/db/migrations",
|
out: "./lib/db/migrations",
|
||||||
dialect: "postgresql",
|
dialect: "postgresql",
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
url:
|
url: rawConnectionString.split("?")[0],
|
||||||
process.env.DATABASE_URL ??
|
|
||||||
"postgresql://postgres:postgres@localhost:5432/moh_sass",
|
|
||||||
},
|
},
|
||||||
});
|
} satisfies Config;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const config = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ignores: ["prisma/seed.js", "scripts/legacy-prisma-seed.cjs"],
|
ignores: ["prisma/seed.js"],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,14 @@
|
|||||||
import { createHash, timingSafeEqual } from "crypto";
|
import { createHash, createHmac, timingSafeEqual } from "crypto";
|
||||||
|
import { and, eq, like, lt } from "drizzle-orm";
|
||||||
import { cookies, headers } from "next/headers";
|
import { cookies, headers } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
import { and, eq, like, lt } from "drizzle-orm";
|
|
||||||
|
|
||||||
import { db } from "./db";
|
import { db } from "./db";
|
||||||
import { appConfig } from "./db/schema";
|
import { appConfig } from "./db/schema";
|
||||||
import { getAdminAppPath } from "./admin-routing";
|
import { getAdminAppPath } from "./admin-routing";
|
||||||
import {
|
|
||||||
ADMIN_SESSION_COOKIE as SHARED_ADMIN_SESSION_COOKIE,
|
|
||||||
buildAdminSessionToken,
|
|
||||||
verifyAdminSessionToken,
|
|
||||||
} from "./admin-session-token";
|
|
||||||
|
|
||||||
export const ADMIN_SESSION_COOKIE = SHARED_ADMIN_SESSION_COOKIE;
|
export const ADMIN_SESSION_COOKIE = "moh_admin_session";
|
||||||
|
const ADMIN_SESSION_VALUE = "superadmin";
|
||||||
const MAX_FAILED_ATTEMPTS = 5;
|
const MAX_FAILED_ATTEMPTS = 5;
|
||||||
const LOCKOUT_SECONDS = 15 * 60;
|
const LOCKOUT_SECONDS = 15 * 60;
|
||||||
const ADMIN_LOCKOUT_KEY_PREFIX = "admin_lockout";
|
const ADMIN_LOCKOUT_KEY_PREFIX = "admin_lockout";
|
||||||
@@ -95,12 +90,34 @@ function getAdminCookieDomain(): string | undefined {
|
|||||||
return hostname ? `.${hostname}` : undefined;
|
return hostname ? `.${hostname}` : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function signValue(value: string): string {
|
||||||
|
return createHmac("sha256", getSecret()).update(value).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
function buildToken(): string {
|
function buildToken(): string {
|
||||||
return buildAdminSessionToken();
|
return `${ADMIN_SESSION_VALUE}.${signValue(ADMIN_SESSION_VALUE)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function verifyToken(token: string): boolean {
|
function verifyToken(token: string): boolean {
|
||||||
return verifyAdminSessionToken(token);
|
const parts = token.split(".");
|
||||||
|
if (parts.length !== 2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [value, signature] = parts;
|
||||||
|
if (value !== ADMIN_SESSION_VALUE) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expected = signValue(value);
|
||||||
|
const left = Buffer.from(signature);
|
||||||
|
const right = Buffer.from(expected);
|
||||||
|
|
||||||
|
if (left.length !== right.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return timingSafeEqual(left, right);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getClientIp(): Promise<string> {
|
async function getClientIp(): Promise<string> {
|
||||||
@@ -124,7 +141,9 @@ async function cleanupExpiredLockouts(): Promise<void> {
|
|||||||
const cutoff = new Date(Date.now() - LOCKOUT_SECONDS * 2 * 1000);
|
const cutoff = new Date(Date.now() - LOCKOUT_SECONDS * 2 * 1000);
|
||||||
await db
|
await db
|
||||||
.delete(appConfig)
|
.delete(appConfig)
|
||||||
.where(and(like(appConfig.key, `${ADMIN_LOCKOUT_KEY_PREFIX}:%`), lt(appConfig.updatedAt, cutoff)));
|
.where(
|
||||||
|
and(like(appConfig.key, `${ADMIN_LOCKOUT_KEY_PREFIX}:%`), lt(appConfig.updatedAt, cutoff)),
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
// Non-critical — ignore cleanup errors.
|
// Non-critical — ignore cleanup errors.
|
||||||
}
|
}
|
||||||
@@ -199,12 +218,12 @@ export async function getAdminLockState(): Promise<{ locked: boolean; remainingS
|
|||||||
try {
|
try {
|
||||||
const ip = await getClientIp();
|
const ip = await getClientIp();
|
||||||
const key = getLockoutKey(ip);
|
const key = getLockoutKey(ip);
|
||||||
const [config] = await db
|
const rows = await db
|
||||||
.select({ value: appConfig.value })
|
.select({ value: appConfig.value })
|
||||||
.from(appConfig)
|
.from(appConfig)
|
||||||
.where(eq(appConfig.key, key))
|
.where(eq(appConfig.key, key))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
const state = parseFailState(config?.value);
|
const state = parseFailState(rows[0]?.value);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
if (state.lockUntil > now) {
|
if (state.lockUntil > now) {
|
||||||
@@ -228,26 +247,24 @@ export async function registerFailedAdminAttempt(): Promise<{ locked: boolean; r
|
|||||||
|
|
||||||
await cleanupExpiredLockouts();
|
await cleanupExpiredLockouts();
|
||||||
|
|
||||||
const [config] = await db
|
const rows = await db
|
||||||
.select({ value: appConfig.value })
|
.select({ value: appConfig.value })
|
||||||
.from(appConfig)
|
.from(appConfig)
|
||||||
.where(eq(appConfig.key, key))
|
.where(eq(appConfig.key, key))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
const current = parseFailState(config?.value);
|
const current = parseFailState(rows[0]?.value);
|
||||||
// If a previous lockout has expired, reset the counter.
|
// If a previous lockout has expired, reset the counter.
|
||||||
const baseAttempts = current.lockUntil > 0 && current.lockUntil < now ? 0 : current.attempts;
|
const baseAttempts = current.lockUntil > 0 && current.lockUntil < now ? 0 : current.attempts;
|
||||||
const attempts = baseAttempts + 1;
|
const attempts = baseAttempts + 1;
|
||||||
const locked = attempts >= MAX_FAILED_ATTEMPTS;
|
const locked = attempts >= MAX_FAILED_ATTEMPTS;
|
||||||
const lockUntil = locked ? now + LOCKOUT_SECONDS * 1000 : 0;
|
const lockUntil = locked ? now + LOCKOUT_SECONDS * 1000 : 0;
|
||||||
|
const value = JSON.stringify({ attempts, lockUntil });
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.insert(appConfig)
|
.insert(appConfig)
|
||||||
.values({ key, value: JSON.stringify({ attempts, lockUntil }) })
|
.values({ key, value })
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({ target: appConfig.key, set: { value, updatedAt: new Date() } });
|
||||||
target: appConfig.key,
|
|
||||||
set: { value: JSON.stringify({ attempts, lockUntil }) },
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
locked,
|
locked,
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
Mail,
|
Mail,
|
||||||
Palette,
|
Palette,
|
||||||
PlusSquare,
|
PlusSquare,
|
||||||
Search,
|
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
SwatchBook,
|
SwatchBook,
|
||||||
Tags,
|
Tags,
|
||||||
@@ -25,7 +24,6 @@ type AdminNavigationCopy = {
|
|||||||
siteSettings: string;
|
siteSettings: string;
|
||||||
brandSettings?: string;
|
brandSettings?: string;
|
||||||
localizationSettings?: string;
|
localizationSettings?: string;
|
||||||
seoSettings?: string;
|
|
||||||
marquee?: string;
|
marquee?: string;
|
||||||
smtp?: string;
|
smtp?: string;
|
||||||
};
|
};
|
||||||
@@ -43,7 +41,7 @@ export function getAdminNavigation(
|
|||||||
copy: AdminNavigationCopy,
|
copy: AdminNavigationCopy,
|
||||||
active: "overview" | "maintenance" | "ui-kit" | "portfolio" | "media" | "site-settings" | "smtp" | "marquee",
|
active: "overview" | "maintenance" | "ui-kit" | "portfolio" | "media" | "site-settings" | "smtp" | "marquee",
|
||||||
portfolioChild?: "overview" | "projects" | "new-project" | "categories",
|
portfolioChild?: "overview" | "projects" | "new-project" | "categories",
|
||||||
siteSettingsChild?: "brand" | "localization" | "seo",
|
siteSettingsChild?: "brand" | "localization",
|
||||||
): AdminNavItem[] {
|
): AdminNavItem[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -89,12 +87,6 @@ export function getAdminNavigation(
|
|||||||
icon: Languages,
|
icon: Languages,
|
||||||
active: siteSettingsChild === "localization",
|
active: siteSettingsChild === "localization",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: copy.seoSettings ?? "SEO",
|
|
||||||
href: getAdminAppPath("/site-settings/seo"),
|
|
||||||
icon: Search,
|
|
||||||
active: siteSettingsChild === "seo",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
import { createHmac, timingSafeEqual } from "crypto";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pure helpers for the admin session cookie token. Kept free of `next/headers`
|
|
||||||
* and the database so the middleware (`proxy.ts`) can verify a session without
|
|
||||||
* pulling the server-only auth module into the edge/middleware bundle.
|
|
||||||
*/
|
|
||||||
export const ADMIN_SESSION_COOKIE = "moh_admin_session";
|
|
||||||
export const ADMIN_SESSION_VALUE = "superadmin";
|
|
||||||
|
|
||||||
function getSecret(): string {
|
|
||||||
return process.env.ADMIN_AUTH_SECRET ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function signValue(value: string): string {
|
|
||||||
return createHmac("sha256", getSecret()).update(value).digest("hex");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildAdminSessionToken(): string {
|
|
||||||
return `${ADMIN_SESSION_VALUE}.${signValue(ADMIN_SESSION_VALUE)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function verifyAdminSessionToken(token: string | undefined | null): boolean {
|
|
||||||
if (!token || !getSecret()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parts = token.split(".");
|
|
||||||
if (parts.length !== 2) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [value, signature] = parts;
|
|
||||||
if (value !== ADMIN_SESSION_VALUE) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const expected = signValue(value);
|
|
||||||
const left = Buffer.from(signature);
|
|
||||||
const right = Buffer.from(expected);
|
|
||||||
|
|
||||||
if (left.length !== right.length) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return timingSafeEqual(left, right);
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { and, eq, inArray } from "drizzle-orm";
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
|
|
||||||
import { db } from "./db";
|
import { db } from "./db";
|
||||||
import { appConfig, mediaAsset, mediaUsage } from "./db/schema";
|
import { appConfig, mediaUsage } from "./db/schema";
|
||||||
export const MAINTENANCE_MODE_KEY = "maintenance_mode";
|
export const MAINTENANCE_MODE_KEY = "maintenance_mode";
|
||||||
export {
|
export {
|
||||||
SITE_NAME_KEY,
|
SITE_NAME_KEY,
|
||||||
@@ -67,42 +67,37 @@ import {
|
|||||||
syncMarqueeSettingsToGermanSource,
|
syncMarqueeSettingsToGermanSource,
|
||||||
type MarqueeSettings,
|
type MarqueeSettings,
|
||||||
} from "./marquee-settings";
|
} from "./marquee-settings";
|
||||||
import { SEO_SETTINGS_KEY, buildDefaultSeoSettings, parseSeoSettingsValue, type SeoSettings } from "./seo-settings";
|
|
||||||
export {
|
|
||||||
SEO_SETTINGS_KEY,
|
|
||||||
buildDefaultSeoSettings,
|
|
||||||
parseSeoSettingsValue,
|
|
||||||
type SeoSettings,
|
|
||||||
} from "./seo-settings";
|
|
||||||
|
|
||||||
// Small helpers over the app_config key/value table (Drizzle).
|
async function getAppConfigValue(key: string): Promise<string | undefined> {
|
||||||
async function readConfigValue(key: string): Promise<string | undefined> {
|
const rows = await db
|
||||||
const [row] = await db
|
|
||||||
.select({ value: appConfig.value })
|
.select({ value: appConfig.value })
|
||||||
.from(appConfig)
|
.from(appConfig)
|
||||||
.where(eq(appConfig.key, key))
|
.where(eq(appConfig.key, key))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
return row?.value;
|
return rows[0]?.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function upsertConfig(key: string, value: string): Promise<void> {
|
async function upsertAppConfigValue(key: string, value: string): Promise<void> {
|
||||||
await db
|
await db
|
||||||
.insert(appConfig)
|
.insert(appConfig)
|
||||||
.values({ key, value })
|
.values({ key, value })
|
||||||
.onConflictDoUpdate({ target: appConfig.key, set: { value } });
|
.onConflictDoUpdate({
|
||||||
|
target: appConfig.key,
|
||||||
|
set: { value, updatedAt: new Date() },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMaintenanceMode(): Promise<boolean> {
|
export async function getMaintenanceMode(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
return (await readConfigValue(MAINTENANCE_MODE_KEY)) === "true";
|
return (await getAppConfigValue(MAINTENANCE_MODE_KEY)) === "true";
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setMaintenanceMode(enabled: boolean): Promise<void> {
|
export async function setMaintenanceMode(enabled: boolean): Promise<void> {
|
||||||
await upsertConfig(MAINTENANCE_MODE_KEY, enabled ? "true" : "false");
|
await upsertAppConfigValue(MAINTENANCE_MODE_KEY, enabled ? "true" : "false");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSiteSettings(): Promise<SiteSettings> {
|
export async function getSiteSettings(): Promise<SiteSettings> {
|
||||||
@@ -122,12 +117,12 @@ export async function getSiteSettings(): Promise<SiteSettings> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateSiteSettings(settings: SiteSettings): Promise<void> {
|
export async function updateSiteSettings(settings: SiteSettings): Promise<void> {
|
||||||
await upsertConfig(SITE_SETTINGS_KEY, JSON.stringify(settings));
|
await upsertAppConfigValue(SITE_SETTINGS_KEY, JSON.stringify(settings));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMailSettings(): Promise<MailSettings> {
|
export async function getMailSettings(): Promise<MailSettings> {
|
||||||
try {
|
try {
|
||||||
return parseMailSettingsValue(await readConfigValue(MAIL_SETTINGS_KEY));
|
return parseMailSettingsValue(await getAppConfigValue(MAIL_SETTINGS_KEY));
|
||||||
} catch {
|
} catch {
|
||||||
return buildDefaultMailSettings();
|
return buildDefaultMailSettings();
|
||||||
}
|
}
|
||||||
@@ -140,12 +135,12 @@ export async function getMailSettingsFormValues(): Promise<MailSettingsFormValue
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateMailSettings(settings: MailSettings): Promise<void> {
|
export async function updateMailSettings(settings: MailSettings): Promise<void> {
|
||||||
await upsertConfig(MAIL_SETTINGS_KEY, JSON.stringify(settings));
|
await upsertAppConfigValue(MAIL_SETTINGS_KEY, JSON.stringify(settings));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMarqueeSettings(): Promise<MarqueeSettings> {
|
export async function getMarqueeSettings(): Promise<MarqueeSettings> {
|
||||||
try {
|
try {
|
||||||
return parseMarqueeSettingsValue(await readConfigValue(MARQUEE_SETTINGS_KEY));
|
return parseMarqueeSettingsValue(await getAppConfigValue(MARQUEE_SETTINGS_KEY));
|
||||||
} catch {
|
} catch {
|
||||||
return buildDefaultMarqueeSettings();
|
return buildDefaultMarqueeSettings();
|
||||||
}
|
}
|
||||||
@@ -154,64 +149,68 @@ export async function getMarqueeSettings(): Promise<MarqueeSettings> {
|
|||||||
export async function updateMarqueeSettings(settings: MarqueeSettings): Promise<void> {
|
export async function updateMarqueeSettings(settings: MarqueeSettings): Promise<void> {
|
||||||
const normalizedSettings = syncMarqueeSettingsToGermanSource(settings);
|
const normalizedSettings = syncMarqueeSettingsToGermanSource(settings);
|
||||||
|
|
||||||
await upsertConfig(MARQUEE_SETTINGS_KEY, JSON.stringify(normalizedSettings));
|
await upsertAppConfigValue(MARQUEE_SETTINGS_KEY, JSON.stringify(normalizedSettings));
|
||||||
}
|
|
||||||
|
|
||||||
export async function getSeoSettings(): Promise<SeoSettings> {
|
|
||||||
try {
|
|
||||||
return parseSeoSettingsValue(await readConfigValue(SEO_SETTINGS_KEY));
|
|
||||||
} catch {
|
|
||||||
return buildDefaultSeoSettings();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateSeoSettings(settings: SeoSettings): Promise<void> {
|
|
||||||
await upsertConfig(SEO_SETTINGS_KEY, JSON.stringify(settings));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSiteSettingsMediaBindings(): Promise<SiteSettingsMediaBindings> {
|
export async function getSiteSettingsMediaBindings(): Promise<SiteSettingsMediaBindings> {
|
||||||
try {
|
try {
|
||||||
const usages = await db
|
const usages = await db.query.mediaUsage.findMany({
|
||||||
.select({
|
where: and(
|
||||||
fieldKey: mediaUsage.fieldKey,
|
eq(mediaUsage.entityType, SITE_SETTINGS_ENTITY_TYPE),
|
||||||
updatedAt: mediaUsage.updatedAt,
|
eq(mediaUsage.entityId, SITE_SETTINGS_ENTITY_ID),
|
||||||
assetId: mediaAsset.id,
|
),
|
||||||
assetUrl: mediaAsset.url,
|
columns: {
|
||||||
})
|
fieldKey: true,
|
||||||
.from(mediaUsage)
|
updatedAt: true,
|
||||||
.innerJoin(mediaAsset, eq(mediaAsset.id, mediaUsage.assetId))
|
},
|
||||||
.where(
|
with: {
|
||||||
and(
|
asset: {
|
||||||
eq(mediaUsage.entityType, SITE_SETTINGS_ENTITY_TYPE),
|
columns: {
|
||||||
eq(mediaUsage.entityId, SITE_SETTINGS_ENTITY_ID),
|
id: true,
|
||||||
),
|
url: true,
|
||||||
);
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return usages.reduce<SiteSettingsMediaBindings>((result, usage) => {
|
return usages.reduce<SiteSettingsMediaBindings>(
|
||||||
const binding = {
|
(result, usage) => {
|
||||||
assetId: usage.assetId,
|
if (usage.fieldKey === SITE_SETTINGS_LOGO_LIGHT_FIELD_KEY) {
|
||||||
url: usage.assetUrl,
|
result.siteLogoLight = {
|
||||||
version: usage.updatedAt.toISOString(),
|
assetId: usage.asset.id,
|
||||||
};
|
url: usage.asset.url,
|
||||||
|
version: usage.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (usage.fieldKey === SITE_SETTINGS_LOGO_LIGHT_FIELD_KEY) {
|
if (usage.fieldKey === SITE_SETTINGS_LOGO_DARK_FIELD_KEY) {
|
||||||
result.siteLogoLight = binding;
|
result.siteLogoDark = {
|
||||||
}
|
assetId: usage.asset.id,
|
||||||
|
url: usage.asset.url,
|
||||||
|
version: usage.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (usage.fieldKey === SITE_SETTINGS_LOGO_DARK_FIELD_KEY) {
|
if (usage.fieldKey === SITE_SETTINGS_FAVICON_FIELD_KEY) {
|
||||||
result.siteLogoDark = binding;
|
result.favicon = {
|
||||||
}
|
assetId: usage.asset.id,
|
||||||
|
url: usage.asset.url,
|
||||||
|
version: usage.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (usage.fieldKey === SITE_SETTINGS_FAVICON_FIELD_KEY) {
|
if (usage.fieldKey === SITE_SETTINGS_DEFAULT_OG_IMAGE_FIELD_KEY) {
|
||||||
result.favicon = binding;
|
result.defaultOgImage = {
|
||||||
}
|
assetId: usage.asset.id,
|
||||||
|
url: usage.asset.url,
|
||||||
|
version: usage.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (usage.fieldKey === SITE_SETTINGS_DEFAULT_OG_IMAGE_FIELD_KEY) {
|
return result;
|
||||||
result.defaultOgImage = binding;
|
},
|
||||||
}
|
getDefaultSiteSettingsMediaBindings(),
|
||||||
|
);
|
||||||
return result;
|
|
||||||
}, getDefaultSiteSettingsMediaBindings());
|
|
||||||
} catch {
|
} catch {
|
||||||
return getDefaultSiteSettingsMediaBindings();
|
return getDefaultSiteSettingsMediaBindings();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,56 @@
|
|||||||
import {
|
// Shared enum values + types. NO server/ORM imports here — this file is safe to
|
||||||
mediaKind,
|
// import from client components (replaces the old `@prisma/client` enum imports).
|
||||||
mediaSource,
|
//
|
||||||
mediaUsageType,
|
// Defined as `const object + union type` (the same shape Prisma generated) rather
|
||||||
portfolioAssetKind,
|
// than a TS `enum`, so bare string literals like "IMAGE" stay assignable and
|
||||||
portfolioProjectViewMode,
|
// `z.nativeEnum(...)` keeps working.
|
||||||
portfolioSectionType,
|
|
||||||
} from "./schema";
|
|
||||||
|
|
||||||
/**
|
export const PortfolioSectionType = {
|
||||||
* Prisma-compatible enum objects + types, derived from the Drizzle pgEnums, so
|
RICH_TEXT: "RICH_TEXT",
|
||||||
* existing consumers can keep writing `MediaKind.IMAGE` (value) and `: MediaKind`
|
GALLERY: "GALLERY",
|
||||||
* (type) — only the import path changes from `@prisma/client` to `@/lib/db/enums`.
|
STATS: "STATS",
|
||||||
*/
|
DELIVERABLES: "DELIVERABLES",
|
||||||
function asEnum<T extends string>(values: readonly T[]): { [K in T]: K } {
|
LINK: "LINK",
|
||||||
return Object.fromEntries(values.map((v) => [v, v])) as { [K in T]: K };
|
} as const;
|
||||||
|
export type PortfolioSectionType = (typeof PortfolioSectionType)[keyof typeof PortfolioSectionType];
|
||||||
|
|
||||||
|
export const PortfolioAssetKind = {
|
||||||
|
IMAGE: "IMAGE",
|
||||||
|
DOCUMENT: "DOCUMENT",
|
||||||
|
} as const;
|
||||||
|
export type PortfolioAssetKind = (typeof PortfolioAssetKind)[keyof typeof PortfolioAssetKind];
|
||||||
|
|
||||||
|
export const PortfolioProjectViewMode = {
|
||||||
|
GRID: "GRID",
|
||||||
|
STORY: "STORY",
|
||||||
|
CASE_STUDY: "CASE_STUDY",
|
||||||
|
} as const;
|
||||||
|
export type PortfolioProjectViewMode =
|
||||||
|
(typeof PortfolioProjectViewMode)[keyof typeof PortfolioProjectViewMode];
|
||||||
|
|
||||||
|
export const MediaSource = {
|
||||||
|
UPLOAD: "UPLOAD",
|
||||||
|
EXTERNAL: "EXTERNAL",
|
||||||
|
} as const;
|
||||||
|
export type MediaSource = (typeof MediaSource)[keyof typeof MediaSource];
|
||||||
|
|
||||||
|
export const MediaKind = {
|
||||||
|
IMAGE: "IMAGE",
|
||||||
|
DOCUMENT: "DOCUMENT",
|
||||||
|
} as const;
|
||||||
|
export type MediaKind = (typeof MediaKind)[keyof typeof MediaKind];
|
||||||
|
|
||||||
|
export const MediaUsageType = {
|
||||||
|
PORTFOLIO_COVER: "PORTFOLIO_COVER",
|
||||||
|
PORTFOLIO_SECTION: "PORTFOLIO_SECTION",
|
||||||
|
PORTFOLIO_ASSET: "PORTFOLIO_ASSET",
|
||||||
|
GENERIC: "GENERIC",
|
||||||
|
} as const;
|
||||||
|
export type MediaUsageType = (typeof MediaUsageType)[keyof typeof MediaUsageType];
|
||||||
|
|
||||||
|
// Helper: enum-object -> tuple of its string values, for Drizzle pgEnum(...).
|
||||||
|
// Preserves the literal union (not widened to `string`) so pgEnum columns infer
|
||||||
|
// as the exact union type.
|
||||||
|
export function enumValues<T extends Record<string, string>>(e: T): [T[keyof T], ...T[keyof T][]] {
|
||||||
|
return Object.values(e) as [T[keyof T], ...T[keyof T][]];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MediaKind = asEnum(mediaKind.enumValues);
|
|
||||||
export type MediaKind = (typeof mediaKind.enumValues)[number];
|
|
||||||
|
|
||||||
export const MediaSource = asEnum(mediaSource.enumValues);
|
|
||||||
export type MediaSource = (typeof mediaSource.enumValues)[number];
|
|
||||||
|
|
||||||
export const MediaUsageType = asEnum(mediaUsageType.enumValues);
|
|
||||||
export type MediaUsageType = (typeof mediaUsageType.enumValues)[number];
|
|
||||||
|
|
||||||
export const PortfolioAssetKind = asEnum(portfolioAssetKind.enumValues);
|
|
||||||
export type PortfolioAssetKind = (typeof portfolioAssetKind.enumValues)[number];
|
|
||||||
|
|
||||||
export const PortfolioProjectViewMode = asEnum(portfolioProjectViewMode.enumValues);
|
|
||||||
export type PortfolioProjectViewMode = (typeof portfolioProjectViewMode.enumValues)[number];
|
|
||||||
|
|
||||||
export const PortfolioSectionType = asEnum(portfolioSectionType.enumValues);
|
|
||||||
export type PortfolioSectionType = (typeof portfolioSectionType.enumValues)[number];
|
|
||||||
|
|||||||
@@ -3,26 +3,21 @@ import postgres from "postgres";
|
|||||||
|
|
||||||
import * as schema from "./schema";
|
import * as schema from "./schema";
|
||||||
|
|
||||||
/**
|
const rawConnectionString =
|
||||||
* The Drizzle database client (postgres.js driver), matching the house standard
|
process.env.DATABASE_URL ?? "postgresql://postgres:postgres@localhost:5432/moh_sass";
|
||||||
* used by the other projects. Replaces the old Prisma client (`lib/prisma.ts`).
|
|
||||||
* A single connection is reused across hot reloads in dev.
|
// Prisma allowed a `?schema=public` query param that postgres.js does not
|
||||||
*/
|
// understand — strip any unknown query string; `public` is the default schema.
|
||||||
// Strip any query string (e.g. a leftover Prisma `?schema=public`) — postgres.js
|
const connectionString = rawConnectionString.split("?")[0];
|
||||||
// forwards unknown params to the server as startup options and Postgres rejects
|
|
||||||
// them ("unrecognized configuration parameter"). `public` is the default schema.
|
|
||||||
const connectionString = (
|
|
||||||
process.env.DATABASE_URL ?? "postgresql://postgres:postgres@localhost:5432/moh_sass"
|
|
||||||
).split("?")[0];
|
|
||||||
|
|
||||||
const globalForDb = globalThis as unknown as {
|
const globalForDb = globalThis as unknown as {
|
||||||
dbClient: ReturnType<typeof postgres> | undefined;
|
pgClient: ReturnType<typeof postgres> | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const client = globalForDb.dbClient ?? postgres(connectionString);
|
const client = globalForDb.pgClient ?? postgres(connectionString, { max: 10 });
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== "production") {
|
if (process.env.NODE_ENV !== "production") {
|
||||||
globalForDb.dbClient = client;
|
globalForDb.pgClient = client;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const db = drizzle(client, { schema });
|
export const db = drizzle(client, { schema });
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
CREATE TYPE "public"."MediaKind" AS ENUM('IMAGE', 'DOCUMENT');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."MediaSource" AS ENUM('UPLOAD', 'EXTERNAL');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."MediaUsageType" AS ENUM('PORTFOLIO_COVER', 'PORTFOLIO_SECTION', 'PORTFOLIO_ASSET', 'GENERIC');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."PortfolioAssetKind" AS ENUM('IMAGE', 'DOCUMENT');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."PortfolioProjectViewMode" AS ENUM('GRID', 'STORY', 'CASE_STUDY');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."PortfolioSectionType" AS ENUM('RICH_TEXT', 'GALLERY', 'STATS', 'DELIVERABLES', 'LINK');--> statement-breakpoint
|
||||||
|
CREATE TABLE "AppConfig" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"key" text NOT NULL,
|
||||||
|
"value" text NOT NULL,
|
||||||
|
"createdAt" timestamp (3) DEFAULT now() NOT NULL,
|
||||||
|
"updatedAt" timestamp (3) DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "AppConfig_key_unique" UNIQUE("key")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "Category" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"slug" text NOT NULL,
|
||||||
|
"nameAr" text NOT NULL,
|
||||||
|
"nameEn" text NOT NULL,
|
||||||
|
"nameDe" text NOT NULL,
|
||||||
|
"descriptionAr" text NOT NULL,
|
||||||
|
"descriptionEn" text NOT NULL,
|
||||||
|
"descriptionDe" text NOT NULL,
|
||||||
|
"sortOrder" integer DEFAULT 0 NOT NULL,
|
||||||
|
"isActive" boolean DEFAULT true NOT NULL,
|
||||||
|
"createdAt" timestamp (3) DEFAULT now() NOT NULL,
|
||||||
|
"updatedAt" timestamp (3) DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "Category_slug_unique" UNIQUE("slug")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "MediaAsset" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"source" "MediaSource" NOT NULL,
|
||||||
|
"kind" "MediaKind" NOT NULL,
|
||||||
|
"url" text NOT NULL,
|
||||||
|
"fileName" text NOT NULL,
|
||||||
|
"label" text NOT NULL,
|
||||||
|
"altText" text,
|
||||||
|
"mimeType" text,
|
||||||
|
"size" integer,
|
||||||
|
"createdAt" timestamp (3) DEFAULT now() NOT NULL,
|
||||||
|
"updatedAt" timestamp (3) DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "MediaUsage" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"assetId" text NOT NULL,
|
||||||
|
"usageType" "MediaUsageType" NOT NULL,
|
||||||
|
"entityType" text NOT NULL,
|
||||||
|
"entityId" text NOT NULL,
|
||||||
|
"fieldKey" text NOT NULL,
|
||||||
|
"createdAt" timestamp (3) DEFAULT now() NOT NULL,
|
||||||
|
"updatedAt" timestamp (3) DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "PortfolioAsset" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"projectId" text NOT NULL,
|
||||||
|
"kind" "PortfolioAssetKind" NOT NULL,
|
||||||
|
"filePath" text NOT NULL,
|
||||||
|
"altAr" text NOT NULL,
|
||||||
|
"altEn" text NOT NULL,
|
||||||
|
"altDe" text NOT NULL,
|
||||||
|
"sortOrder" integer DEFAULT 0 NOT NULL,
|
||||||
|
"createdAt" timestamp (3) DEFAULT now() NOT NULL,
|
||||||
|
"updatedAt" timestamp (3) DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "PortfolioProject" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"categoryId" text NOT NULL,
|
||||||
|
"slug" text NOT NULL,
|
||||||
|
"viewMode" "PortfolioProjectViewMode" DEFAULT 'GRID' NOT NULL,
|
||||||
|
"titleAr" text NOT NULL,
|
||||||
|
"titleEn" text NOT NULL,
|
||||||
|
"titleDe" text NOT NULL,
|
||||||
|
"summaryAr" text NOT NULL,
|
||||||
|
"summaryEn" text NOT NULL,
|
||||||
|
"summaryDe" text NOT NULL,
|
||||||
|
"clientName" text NOT NULL,
|
||||||
|
"projectYear" integer NOT NULL,
|
||||||
|
"serviceLabelAr" text NOT NULL,
|
||||||
|
"serviceLabelEn" text NOT NULL,
|
||||||
|
"serviceLabelDe" text NOT NULL,
|
||||||
|
"previewUrl" text,
|
||||||
|
"coverImagePath" text,
|
||||||
|
"isFeatured" boolean DEFAULT false NOT NULL,
|
||||||
|
"isPublished" boolean DEFAULT false NOT NULL,
|
||||||
|
"publishedAt" timestamp (3),
|
||||||
|
"sortOrder" integer DEFAULT 0 NOT NULL,
|
||||||
|
"createdAt" timestamp (3) DEFAULT now() NOT NULL,
|
||||||
|
"updatedAt" timestamp (3) DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "PortfolioProject_slug_unique" UNIQUE("slug")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "PortfolioSection" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"projectId" text NOT NULL,
|
||||||
|
"type" "PortfolioSectionType" NOT NULL,
|
||||||
|
"titleAr" text NOT NULL,
|
||||||
|
"titleEn" text NOT NULL,
|
||||||
|
"titleDe" text NOT NULL,
|
||||||
|
"bodyAr" text NOT NULL,
|
||||||
|
"bodyEn" text NOT NULL,
|
||||||
|
"bodyDe" text NOT NULL,
|
||||||
|
"imagePath" text,
|
||||||
|
"linkUrl" text,
|
||||||
|
"sortOrder" integer DEFAULT 0 NOT NULL,
|
||||||
|
"createdAt" timestamp (3) DEFAULT now() NOT NULL,
|
||||||
|
"updatedAt" timestamp (3) DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "MediaUsage" ADD CONSTRAINT "MediaUsage_assetId_MediaAsset_id_fk" FOREIGN KEY ("assetId") REFERENCES "public"."MediaAsset"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "PortfolioAsset" ADD CONSTRAINT "PortfolioAsset_projectId_PortfolioProject_id_fk" FOREIGN KEY ("projectId") REFERENCES "public"."PortfolioProject"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "PortfolioProject" ADD CONSTRAINT "PortfolioProject_categoryId_Category_id_fk" FOREIGN KEY ("categoryId") REFERENCES "public"."Category"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "PortfolioSection" ADD CONSTRAINT "PortfolioSection_projectId_PortfolioProject_id_fk" FOREIGN KEY ("projectId") REFERENCES "public"."PortfolioProject"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "MediaAsset_kind_createdAt_idx" ON "MediaAsset" USING btree ("kind","createdAt");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "MediaUsage_usageType_entityType_entityId_fieldKey_key" ON "MediaUsage" USING btree ("usageType","entityType","entityId","fieldKey");--> statement-breakpoint
|
||||||
|
CREATE INDEX "MediaUsage_assetId_idx" ON "MediaUsage" USING btree ("assetId");--> statement-breakpoint
|
||||||
|
CREATE INDEX "MediaUsage_entityType_entityId_idx" ON "MediaUsage" USING btree ("entityType","entityId");--> statement-breakpoint
|
||||||
|
CREATE INDEX "PortfolioAsset_projectId_sortOrder_idx" ON "PortfolioAsset" USING btree ("projectId","sortOrder");--> statement-breakpoint
|
||||||
|
CREATE INDEX "PortfolioProject_categoryId_isPublished_sortOrder_idx" ON "PortfolioProject" USING btree ("categoryId","isPublished","sortOrder");--> statement-breakpoint
|
||||||
|
CREATE INDEX "PortfolioProject_isPublished_sortOrder_idx" ON "PortfolioProject" USING btree ("isPublished","sortOrder");--> statement-breakpoint
|
||||||
|
CREATE INDEX "PortfolioSection_projectId_sortOrder_idx" ON "PortfolioSection" USING btree ("projectId","sortOrder");
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
CREATE TYPE "public"."media_kind" AS ENUM('IMAGE', 'DOCUMENT');--> statement-breakpoint
|
|
||||||
CREATE TYPE "public"."media_source" AS ENUM('UPLOAD', 'EXTERNAL');--> statement-breakpoint
|
|
||||||
CREATE TYPE "public"."media_usage_type" AS ENUM('PORTFOLIO_COVER', 'PORTFOLIO_SECTION', 'PORTFOLIO_ASSET', 'GENERIC');--> statement-breakpoint
|
|
||||||
CREATE TYPE "public"."portfolio_asset_kind" AS ENUM('IMAGE', 'DOCUMENT');--> statement-breakpoint
|
|
||||||
CREATE TYPE "public"."portfolio_project_view_mode" AS ENUM('GRID', 'STORY', 'CASE_STUDY');--> statement-breakpoint
|
|
||||||
CREATE TYPE "public"."portfolio_section_type" AS ENUM('RICH_TEXT', 'GALLERY', 'STATS', 'DELIVERABLES', 'LINK');--> statement-breakpoint
|
|
||||||
CREATE TABLE "app_config" (
|
|
||||||
"id" text PRIMARY KEY NOT NULL,
|
|
||||||
"key" text NOT NULL,
|
|
||||||
"value" text NOT NULL,
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
CONSTRAINT "app_config_key_unique" UNIQUE("key")
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "category" (
|
|
||||||
"id" text PRIMARY KEY NOT NULL,
|
|
||||||
"slug" text NOT NULL,
|
|
||||||
"name_ar" text NOT NULL,
|
|
||||||
"name_en" text NOT NULL,
|
|
||||||
"name_de" text NOT NULL,
|
|
||||||
"description_ar" text NOT NULL,
|
|
||||||
"description_en" text NOT NULL,
|
|
||||||
"description_de" text NOT NULL,
|
|
||||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
|
||||||
"is_active" boolean DEFAULT true NOT NULL,
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
CONSTRAINT "category_slug_unique" UNIQUE("slug")
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "media_asset" (
|
|
||||||
"id" text PRIMARY KEY NOT NULL,
|
|
||||||
"source" "media_source" NOT NULL,
|
|
||||||
"kind" "media_kind" NOT NULL,
|
|
||||||
"url" text NOT NULL,
|
|
||||||
"file_name" text NOT NULL,
|
|
||||||
"label" text NOT NULL,
|
|
||||||
"alt_text" text,
|
|
||||||
"mime_type" text,
|
|
||||||
"size" integer,
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "media_usage" (
|
|
||||||
"id" text PRIMARY KEY NOT NULL,
|
|
||||||
"asset_id" text NOT NULL,
|
|
||||||
"usage_type" "media_usage_type" NOT NULL,
|
|
||||||
"entity_type" text NOT NULL,
|
|
||||||
"entity_id" text NOT NULL,
|
|
||||||
"field_key" text NOT NULL,
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "portfolio_asset" (
|
|
||||||
"id" text PRIMARY KEY NOT NULL,
|
|
||||||
"project_id" text NOT NULL,
|
|
||||||
"kind" "portfolio_asset_kind" NOT NULL,
|
|
||||||
"file_path" text NOT NULL,
|
|
||||||
"alt_ar" text NOT NULL,
|
|
||||||
"alt_en" text NOT NULL,
|
|
||||||
"alt_de" text NOT NULL,
|
|
||||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "portfolio_project" (
|
|
||||||
"id" text PRIMARY KEY NOT NULL,
|
|
||||||
"category_id" text NOT NULL,
|
|
||||||
"slug" text NOT NULL,
|
|
||||||
"view_mode" "portfolio_project_view_mode" DEFAULT 'GRID' NOT NULL,
|
|
||||||
"title_ar" text NOT NULL,
|
|
||||||
"title_en" text NOT NULL,
|
|
||||||
"title_de" text NOT NULL,
|
|
||||||
"summary_ar" text NOT NULL,
|
|
||||||
"summary_en" text NOT NULL,
|
|
||||||
"summary_de" text NOT NULL,
|
|
||||||
"client_name" text NOT NULL,
|
|
||||||
"project_year" integer NOT NULL,
|
|
||||||
"service_label_ar" text NOT NULL,
|
|
||||||
"service_label_en" text NOT NULL,
|
|
||||||
"service_label_de" text NOT NULL,
|
|
||||||
"preview_url" text,
|
|
||||||
"cover_image_path" text,
|
|
||||||
"is_featured" boolean DEFAULT false NOT NULL,
|
|
||||||
"is_published" boolean DEFAULT false NOT NULL,
|
|
||||||
"published_at" timestamp with time zone,
|
|
||||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
CONSTRAINT "portfolio_project_slug_unique" UNIQUE("slug")
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "portfolio_section" (
|
|
||||||
"id" text PRIMARY KEY NOT NULL,
|
|
||||||
"project_id" text NOT NULL,
|
|
||||||
"type" "portfolio_section_type" NOT NULL,
|
|
||||||
"title_ar" text NOT NULL,
|
|
||||||
"title_en" text NOT NULL,
|
|
||||||
"title_de" text NOT NULL,
|
|
||||||
"body_ar" text NOT NULL,
|
|
||||||
"body_en" text NOT NULL,
|
|
||||||
"body_de" text NOT NULL,
|
|
||||||
"image_path" text,
|
|
||||||
"link_url" text,
|
|
||||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE "media_usage" ADD CONSTRAINT "media_usage_asset_id_media_asset_id_fk" FOREIGN KEY ("asset_id") REFERENCES "public"."media_asset"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "portfolio_asset" ADD CONSTRAINT "portfolio_asset_project_id_portfolio_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."portfolio_project"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "portfolio_project" ADD CONSTRAINT "portfolio_project_category_id_category_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."category"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "portfolio_section" ADD CONSTRAINT "portfolio_section_project_id_portfolio_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."portfolio_project"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
CREATE INDEX "media_asset_kind_created_idx" ON "media_asset" USING btree ("kind","created_at");--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX "media_usage_unique_slot" ON "media_usage" USING btree ("usage_type","entity_type","entity_id","field_key");--> statement-breakpoint
|
|
||||||
CREATE INDEX "media_usage_asset_idx" ON "media_usage" USING btree ("asset_id");--> statement-breakpoint
|
|
||||||
CREATE INDEX "media_usage_entity_idx" ON "media_usage" USING btree ("entity_type","entity_id");--> statement-breakpoint
|
|
||||||
CREATE INDEX "portfolio_asset_project_sort_idx" ON "portfolio_asset" USING btree ("project_id","sort_order");--> statement-breakpoint
|
|
||||||
CREATE INDEX "portfolio_project_category_published_sort_idx" ON "portfolio_project" USING btree ("category_id","is_published","sort_order");--> statement-breakpoint
|
|
||||||
CREATE INDEX "portfolio_project_published_sort_idx" ON "portfolio_project" USING btree ("is_published","sort_order");--> statement-breakpoint
|
|
||||||
CREATE INDEX "portfolio_section_project_sort_idx" ON "portfolio_section" USING btree ("project_id","sort_order");
|
|
||||||
@@ -5,8 +5,8 @@
|
|||||||
{
|
{
|
||||||
"idx": 0,
|
"idx": 0,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1786049545718,
|
"when": 1786146995564,
|
||||||
"tag": "0000_fixed_venom",
|
"tag": "0000_absurd_rawhide_kid",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
import { relations } from "drizzle-orm";
|
import { relations } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
boolean,
|
boolean,
|
||||||
@@ -10,198 +11,186 @@ import {
|
|||||||
uniqueIndex,
|
uniqueIndex,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
/**
|
import {
|
||||||
* Drizzle schema — the single source of truth for the database, replacing the
|
MediaKind,
|
||||||
* old Prisma schema (see docs). The database is Postgres; migrations are
|
MediaSource,
|
||||||
* generated with `drizzle-kit generate`. IDs are app-generated opaque strings
|
MediaUsageType,
|
||||||
* (was Prisma `cuid()`), timestamps default in the DB and bump on update.
|
PortfolioAssetKind,
|
||||||
*/
|
PortfolioProjectViewMode,
|
||||||
|
PortfolioSectionType,
|
||||||
|
enumValues,
|
||||||
|
} from "./enums";
|
||||||
|
|
||||||
// `crypto.randomUUID()` is a global in Node 20+ and browsers (no node: import),
|
// Postgres enum types — names match the ones Prisma created, so no DB migration
|
||||||
// so the schema stays safe to pull into a client bundle via lib/db/enums.
|
// is needed for the ORM swap.
|
||||||
const id = () =>
|
export const portfolioSectionTypeEnum = pgEnum("PortfolioSectionType", enumValues(PortfolioSectionType));
|
||||||
text("id")
|
export const portfolioAssetKindEnum = pgEnum("PortfolioAssetKind", enumValues(PortfolioAssetKind));
|
||||||
.primaryKey()
|
export const portfolioProjectViewModeEnum = pgEnum("PortfolioProjectViewMode", enumValues(PortfolioProjectViewMode));
|
||||||
.$defaultFn(() => crypto.randomUUID());
|
export const mediaSourceEnum = pgEnum("MediaSource", enumValues(MediaSource));
|
||||||
|
export const mediaKindEnum = pgEnum("MediaKind", enumValues(MediaKind));
|
||||||
|
export const mediaUsageTypeEnum = pgEnum("MediaUsageType", enumValues(MediaUsageType));
|
||||||
|
|
||||||
const createdAt = timestamp("created_at", { withTimezone: true }).notNull().defaultNow();
|
// Shared column builders (Prisma parity): cuid ids, precision-3 timestamps.
|
||||||
const updatedAt = timestamp("updated_at", { withTimezone: true })
|
const id = () => text("id").primaryKey().$defaultFn(() => createId());
|
||||||
.notNull()
|
const createdAt = () => timestamp("createdAt", { precision: 3, mode: "date" }).defaultNow().notNull();
|
||||||
.defaultNow()
|
const updatedAt = () =>
|
||||||
.$onUpdate(() => new Date());
|
timestamp("updatedAt", { precision: 3, mode: "date" })
|
||||||
|
.defaultNow()
|
||||||
|
.notNull()
|
||||||
|
.$onUpdate(() => new Date());
|
||||||
|
|
||||||
// --- Enums ------------------------------------------------------------------
|
export const appConfig = pgTable("AppConfig", {
|
||||||
|
|
||||||
export const portfolioSectionType = pgEnum("portfolio_section_type", [
|
|
||||||
"RICH_TEXT",
|
|
||||||
"GALLERY",
|
|
||||||
"STATS",
|
|
||||||
"DELIVERABLES",
|
|
||||||
"LINK",
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const portfolioAssetKind = pgEnum("portfolio_asset_kind", ["IMAGE", "DOCUMENT"]);
|
|
||||||
|
|
||||||
export const portfolioProjectViewMode = pgEnum("portfolio_project_view_mode", [
|
|
||||||
"GRID",
|
|
||||||
"STORY",
|
|
||||||
"CASE_STUDY",
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const mediaSource = pgEnum("media_source", ["UPLOAD", "EXTERNAL"]);
|
|
||||||
|
|
||||||
export const mediaKind = pgEnum("media_kind", ["IMAGE", "DOCUMENT"]);
|
|
||||||
|
|
||||||
export const mediaUsageType = pgEnum("media_usage_type", [
|
|
||||||
"PORTFOLIO_COVER",
|
|
||||||
"PORTFOLIO_SECTION",
|
|
||||||
"PORTFOLIO_ASSET",
|
|
||||||
"GENERIC",
|
|
||||||
]);
|
|
||||||
|
|
||||||
// --- Tables -----------------------------------------------------------------
|
|
||||||
|
|
||||||
export const appConfig = pgTable("app_config", {
|
|
||||||
id: id(),
|
id: id(),
|
||||||
key: text("key").notNull().unique(),
|
key: text("key").notNull().unique(),
|
||||||
value: text("value").notNull(),
|
value: text("value").notNull(),
|
||||||
createdAt,
|
createdAt: createdAt(),
|
||||||
updatedAt,
|
updatedAt: updatedAt(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const category = pgTable("category", {
|
export const category = pgTable(
|
||||||
id: id(),
|
"Category",
|
||||||
slug: text("slug").notNull().unique(),
|
|
||||||
nameAr: text("name_ar").notNull(),
|
|
||||||
nameEn: text("name_en").notNull(),
|
|
||||||
nameDe: text("name_de").notNull(),
|
|
||||||
descriptionAr: text("description_ar").notNull(),
|
|
||||||
descriptionEn: text("description_en").notNull(),
|
|
||||||
descriptionDe: text("description_de").notNull(),
|
|
||||||
sortOrder: integer("sort_order").notNull().default(0),
|
|
||||||
isActive: boolean("is_active").notNull().default(true),
|
|
||||||
createdAt,
|
|
||||||
updatedAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const portfolioProject = pgTable(
|
|
||||||
"portfolio_project",
|
|
||||||
{
|
{
|
||||||
id: id(),
|
id: id(),
|
||||||
categoryId: text("category_id")
|
slug: text("slug").notNull().unique(),
|
||||||
|
nameAr: text("nameAr").notNull(),
|
||||||
|
nameEn: text("nameEn").notNull(),
|
||||||
|
nameDe: text("nameDe").notNull(),
|
||||||
|
descriptionAr: text("descriptionAr").notNull(),
|
||||||
|
descriptionEn: text("descriptionEn").notNull(),
|
||||||
|
descriptionDe: text("descriptionDe").notNull(),
|
||||||
|
sortOrder: integer("sortOrder").notNull().default(0),
|
||||||
|
isActive: boolean("isActive").notNull().default(true),
|
||||||
|
createdAt: createdAt(),
|
||||||
|
updatedAt: updatedAt(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const portfolioProject = pgTable(
|
||||||
|
"PortfolioProject",
|
||||||
|
{
|
||||||
|
id: id(),
|
||||||
|
categoryId: text("categoryId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => category.id, { onDelete: "restrict" }),
|
.references(() => category.id, { onDelete: "restrict" }),
|
||||||
slug: text("slug").notNull().unique(),
|
slug: text("slug").notNull().unique(),
|
||||||
viewMode: portfolioProjectViewMode("view_mode").notNull().default("GRID"),
|
viewMode: portfolioProjectViewModeEnum("viewMode").notNull().default("GRID"),
|
||||||
titleAr: text("title_ar").notNull(),
|
titleAr: text("titleAr").notNull(),
|
||||||
titleEn: text("title_en").notNull(),
|
titleEn: text("titleEn").notNull(),
|
||||||
titleDe: text("title_de").notNull(),
|
titleDe: text("titleDe").notNull(),
|
||||||
summaryAr: text("summary_ar").notNull(),
|
summaryAr: text("summaryAr").notNull(),
|
||||||
summaryEn: text("summary_en").notNull(),
|
summaryEn: text("summaryEn").notNull(),
|
||||||
summaryDe: text("summary_de").notNull(),
|
summaryDe: text("summaryDe").notNull(),
|
||||||
clientName: text("client_name").notNull(),
|
clientName: text("clientName").notNull(),
|
||||||
projectYear: integer("project_year").notNull(),
|
projectYear: integer("projectYear").notNull(),
|
||||||
serviceLabelAr: text("service_label_ar").notNull(),
|
serviceLabelAr: text("serviceLabelAr").notNull(),
|
||||||
serviceLabelEn: text("service_label_en").notNull(),
|
serviceLabelEn: text("serviceLabelEn").notNull(),
|
||||||
serviceLabelDe: text("service_label_de").notNull(),
|
serviceLabelDe: text("serviceLabelDe").notNull(),
|
||||||
previewUrl: text("preview_url"),
|
previewUrl: text("previewUrl"),
|
||||||
coverImagePath: text("cover_image_path"),
|
coverImagePath: text("coverImagePath"),
|
||||||
isFeatured: boolean("is_featured").notNull().default(false),
|
isFeatured: boolean("isFeatured").notNull().default(false),
|
||||||
isPublished: boolean("is_published").notNull().default(false),
|
isPublished: boolean("isPublished").notNull().default(false),
|
||||||
publishedAt: timestamp("published_at", { withTimezone: true }),
|
publishedAt: timestamp("publishedAt", { precision: 3, mode: "date" }),
|
||||||
sortOrder: integer("sort_order").notNull().default(0),
|
sortOrder: integer("sortOrder").notNull().default(0),
|
||||||
createdAt,
|
createdAt: createdAt(),
|
||||||
updatedAt,
|
updatedAt: updatedAt(),
|
||||||
},
|
},
|
||||||
(t) => [
|
(table) => [
|
||||||
index("portfolio_project_category_published_sort_idx").on(t.categoryId, t.isPublished, t.sortOrder),
|
index("PortfolioProject_categoryId_isPublished_sortOrder_idx").on(
|
||||||
index("portfolio_project_published_sort_idx").on(t.isPublished, t.sortOrder),
|
table.categoryId,
|
||||||
|
table.isPublished,
|
||||||
|
table.sortOrder,
|
||||||
|
),
|
||||||
|
index("PortfolioProject_isPublished_sortOrder_idx").on(table.isPublished, table.sortOrder),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const portfolioSection = pgTable(
|
export const portfolioSection = pgTable(
|
||||||
"portfolio_section",
|
"PortfolioSection",
|
||||||
{
|
{
|
||||||
id: id(),
|
id: id(),
|
||||||
projectId: text("project_id")
|
projectId: text("projectId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => portfolioProject.id, { onDelete: "cascade" }),
|
.references(() => portfolioProject.id, { onDelete: "cascade" }),
|
||||||
type: portfolioSectionType("type").notNull(),
|
type: portfolioSectionTypeEnum("type").notNull(),
|
||||||
titleAr: text("title_ar").notNull(),
|
titleAr: text("titleAr").notNull(),
|
||||||
titleEn: text("title_en").notNull(),
|
titleEn: text("titleEn").notNull(),
|
||||||
titleDe: text("title_de").notNull(),
|
titleDe: text("titleDe").notNull(),
|
||||||
bodyAr: text("body_ar").notNull(),
|
bodyAr: text("bodyAr").notNull(),
|
||||||
bodyEn: text("body_en").notNull(),
|
bodyEn: text("bodyEn").notNull(),
|
||||||
bodyDe: text("body_de").notNull(),
|
bodyDe: text("bodyDe").notNull(),
|
||||||
imagePath: text("image_path"),
|
imagePath: text("imagePath"),
|
||||||
linkUrl: text("link_url"),
|
linkUrl: text("linkUrl"),
|
||||||
sortOrder: integer("sort_order").notNull().default(0),
|
sortOrder: integer("sortOrder").notNull().default(0),
|
||||||
createdAt,
|
createdAt: createdAt(),
|
||||||
updatedAt,
|
updatedAt: updatedAt(),
|
||||||
},
|
},
|
||||||
(t) => [index("portfolio_section_project_sort_idx").on(t.projectId, t.sortOrder)],
|
(table) => [index("PortfolioSection_projectId_sortOrder_idx").on(table.projectId, table.sortOrder)],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const portfolioAsset = pgTable(
|
export const portfolioAsset = pgTable(
|
||||||
"portfolio_asset",
|
"PortfolioAsset",
|
||||||
{
|
{
|
||||||
id: id(),
|
id: id(),
|
||||||
projectId: text("project_id")
|
projectId: text("projectId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => portfolioProject.id, { onDelete: "cascade" }),
|
.references(() => portfolioProject.id, { onDelete: "cascade" }),
|
||||||
kind: portfolioAssetKind("kind").notNull(),
|
kind: portfolioAssetKindEnum("kind").notNull(),
|
||||||
filePath: text("file_path").notNull(),
|
filePath: text("filePath").notNull(),
|
||||||
altAr: text("alt_ar").notNull(),
|
altAr: text("altAr").notNull(),
|
||||||
altEn: text("alt_en").notNull(),
|
altEn: text("altEn").notNull(),
|
||||||
altDe: text("alt_de").notNull(),
|
altDe: text("altDe").notNull(),
|
||||||
sortOrder: integer("sort_order").notNull().default(0),
|
sortOrder: integer("sortOrder").notNull().default(0),
|
||||||
createdAt,
|
createdAt: createdAt(),
|
||||||
updatedAt,
|
updatedAt: updatedAt(),
|
||||||
},
|
},
|
||||||
(t) => [index("portfolio_asset_project_sort_idx").on(t.projectId, t.sortOrder)],
|
(table) => [index("PortfolioAsset_projectId_sortOrder_idx").on(table.projectId, table.sortOrder)],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const mediaAsset = pgTable(
|
export const mediaAsset = pgTable(
|
||||||
"media_asset",
|
"MediaAsset",
|
||||||
{
|
{
|
||||||
id: id(),
|
id: id(),
|
||||||
source: mediaSource("source").notNull(),
|
source: mediaSourceEnum("source").notNull(),
|
||||||
kind: mediaKind("kind").notNull(),
|
kind: mediaKindEnum("kind").notNull(),
|
||||||
url: text("url").notNull(),
|
url: text("url").notNull(),
|
||||||
fileName: text("file_name").notNull(),
|
fileName: text("fileName").notNull(),
|
||||||
label: text("label").notNull(),
|
label: text("label").notNull(),
|
||||||
altText: text("alt_text"),
|
altText: text("altText"),
|
||||||
mimeType: text("mime_type"),
|
mimeType: text("mimeType"),
|
||||||
size: integer("size"),
|
size: integer("size"),
|
||||||
createdAt,
|
createdAt: createdAt(),
|
||||||
updatedAt,
|
updatedAt: updatedAt(),
|
||||||
},
|
},
|
||||||
(t) => [index("media_asset_kind_created_idx").on(t.kind, t.createdAt)],
|
(table) => [index("MediaAsset_kind_createdAt_idx").on(table.kind, table.createdAt)],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const mediaUsage = pgTable(
|
export const mediaUsage = pgTable(
|
||||||
"media_usage",
|
"MediaUsage",
|
||||||
{
|
{
|
||||||
id: id(),
|
id: id(),
|
||||||
assetId: text("asset_id")
|
assetId: text("assetId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => mediaAsset.id, { onDelete: "cascade" }),
|
.references(() => mediaAsset.id, { onDelete: "cascade" }),
|
||||||
usageType: mediaUsageType("usage_type").notNull(),
|
usageType: mediaUsageTypeEnum("usageType").notNull(),
|
||||||
entityType: text("entity_type").notNull(),
|
entityType: text("entityType").notNull(),
|
||||||
entityId: text("entity_id").notNull(),
|
entityId: text("entityId").notNull(),
|
||||||
fieldKey: text("field_key").notNull(),
|
fieldKey: text("fieldKey").notNull(),
|
||||||
createdAt,
|
createdAt: createdAt(),
|
||||||
updatedAt,
|
updatedAt: updatedAt(),
|
||||||
},
|
},
|
||||||
(t) => [
|
(table) => [
|
||||||
uniqueIndex("media_usage_unique_slot").on(t.usageType, t.entityType, t.entityId, t.fieldKey),
|
uniqueIndex("MediaUsage_usageType_entityType_entityId_fieldKey_key").on(
|
||||||
index("media_usage_asset_idx").on(t.assetId),
|
table.usageType,
|
||||||
index("media_usage_entity_idx").on(t.entityType, t.entityId),
|
table.entityType,
|
||||||
|
table.entityId,
|
||||||
|
table.fieldKey,
|
||||||
|
),
|
||||||
|
index("MediaUsage_assetId_idx").on(table.assetId),
|
||||||
|
index("MediaUsage_entityType_entityId_idx").on(table.entityType, table.entityId),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- Relations (for the relational query API: db.query.*.findMany({ with })) --
|
// Relations (enable db.query.* `with:` includes).
|
||||||
|
|
||||||
export const categoryRelations = relations(category, ({ many }) => ({
|
export const categoryRelations = relations(category, ({ many }) => ({
|
||||||
projects: many(portfolioProject),
|
projects: many(portfolioProject),
|
||||||
}));
|
}));
|
||||||
@@ -239,3 +228,21 @@ export const mediaUsageRelations = relations(mediaUsage, ({ one }) => ({
|
|||||||
references: [mediaAsset.id],
|
references: [mediaAsset.id],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Inferred row types (replace the old `@prisma/client` model type imports).
|
||||||
|
export type AppConfig = typeof appConfig.$inferSelect;
|
||||||
|
export type Category = typeof category.$inferSelect;
|
||||||
|
export type PortfolioProject = typeof portfolioProject.$inferSelect;
|
||||||
|
export type PortfolioSection = typeof portfolioSection.$inferSelect;
|
||||||
|
export type PortfolioAsset = typeof portfolioAsset.$inferSelect;
|
||||||
|
export type MediaAsset = typeof mediaAsset.$inferSelect;
|
||||||
|
export type MediaUsage = typeof mediaUsage.$inferSelect;
|
||||||
|
|
||||||
|
export {
|
||||||
|
MediaKind,
|
||||||
|
MediaSource,
|
||||||
|
MediaUsageType,
|
||||||
|
PortfolioAssetKind,
|
||||||
|
PortfolioProjectViewMode,
|
||||||
|
PortfolioSectionType,
|
||||||
|
} from "./enums";
|
||||||
|
|||||||
@@ -0,0 +1,565 @@
|
|||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { drizzle } from "drizzle-orm/postgres-js";
|
||||||
|
import postgres from "postgres";
|
||||||
|
|
||||||
|
import * as schema from "./schema";
|
||||||
|
import {
|
||||||
|
appConfig,
|
||||||
|
category,
|
||||||
|
mediaAsset,
|
||||||
|
mediaUsage,
|
||||||
|
portfolioAsset,
|
||||||
|
portfolioProject,
|
||||||
|
portfolioSection,
|
||||||
|
} from "./schema";
|
||||||
|
|
||||||
|
const connectionString = (
|
||||||
|
process.env.DATABASE_URL ?? "postgresql://postgres:postgres@localhost:5432/moh_sass"
|
||||||
|
).split("?")[0];
|
||||||
|
|
||||||
|
const client = postgres(connectionString, { max: 1 });
|
||||||
|
const db = drizzle(client, { schema });
|
||||||
|
|
||||||
|
type MediaAssetInput = {
|
||||||
|
source: "UPLOAD" | "EXTERNAL";
|
||||||
|
kind: "IMAGE" | "DOCUMENT";
|
||||||
|
url: string;
|
||||||
|
fileName: string;
|
||||||
|
label: string;
|
||||||
|
altText: string | null;
|
||||||
|
mimeType: string | null;
|
||||||
|
size: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function upsertMediaAsset(input: MediaAssetInput) {
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(mediaAsset)
|
||||||
|
.where(and(eq(mediaAsset.label, input.label), eq(mediaAsset.url, input.url)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
const [updated] = await db
|
||||||
|
.update(mediaAsset)
|
||||||
|
.set({ ...input, updatedAt: new Date() })
|
||||||
|
.where(eq(mediaAsset.id, existing.id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [created] = await db.insert(mediaAsset).values(input).returning();
|
||||||
|
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertAppConfig(key: string, value: string) {
|
||||||
|
await db
|
||||||
|
.insert(appConfig)
|
||||||
|
.values({ key, value })
|
||||||
|
.onConflictDoUpdate({ target: appConfig.key, set: { value, updatedAt: new Date() } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertCategory(values: typeof category.$inferInsert) {
|
||||||
|
const [row] = await db
|
||||||
|
.insert(category)
|
||||||
|
.values(values)
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: category.slug,
|
||||||
|
set: {
|
||||||
|
nameAr: values.nameAr,
|
||||||
|
nameEn: values.nameEn,
|
||||||
|
nameDe: values.nameDe,
|
||||||
|
descriptionAr: values.descriptionAr,
|
||||||
|
descriptionEn: values.descriptionEn,
|
||||||
|
descriptionDe: values.descriptionDe,
|
||||||
|
sortOrder: values.sortOrder,
|
||||||
|
isActive: values.isActive,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncProjectContent(
|
||||||
|
projectId: string,
|
||||||
|
sections: Array<Omit<typeof portfolioSection.$inferInsert, "projectId">>,
|
||||||
|
assets: Array<Omit<typeof portfolioAsset.$inferInsert, "projectId">>,
|
||||||
|
) {
|
||||||
|
await db.delete(portfolioSection).where(eq(portfolioSection.projectId, projectId));
|
||||||
|
await db.delete(portfolioAsset).where(eq(portfolioAsset.projectId, projectId));
|
||||||
|
|
||||||
|
const createdSections = [];
|
||||||
|
for (const section of sections) {
|
||||||
|
const [row] = await db
|
||||||
|
.insert(portfolioSection)
|
||||||
|
.values({ projectId, ...section })
|
||||||
|
.returning();
|
||||||
|
createdSections.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdAssets = [];
|
||||||
|
for (const asset of assets) {
|
||||||
|
const [row] = await db
|
||||||
|
.insert(portfolioAsset)
|
||||||
|
.values({ projectId, ...asset })
|
||||||
|
.returning();
|
||||||
|
createdAssets.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { createdSections, createdAssets };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncProjectMediaUsages(
|
||||||
|
projectId: string,
|
||||||
|
mediaMap: {
|
||||||
|
coverAssetId: string | null | undefined;
|
||||||
|
sectionUsages: Array<{ fieldKey: string; assetId: string | null | undefined }>;
|
||||||
|
assetUsages: Array<{ fieldKey: string; assetId: string | null | undefined }>;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
await db
|
||||||
|
.delete(mediaUsage)
|
||||||
|
.where(and(eq(mediaUsage.entityType, "portfolio-project"), eq(mediaUsage.entityId, projectId)));
|
||||||
|
|
||||||
|
const usages: (typeof mediaUsage.$inferInsert)[] = [];
|
||||||
|
|
||||||
|
if (mediaMap.coverAssetId) {
|
||||||
|
usages.push({
|
||||||
|
assetId: mediaMap.coverAssetId,
|
||||||
|
usageType: "PORTFOLIO_COVER",
|
||||||
|
entityType: "portfolio-project",
|
||||||
|
entityId: projectId,
|
||||||
|
fieldKey: "cover",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const sectionUsage of mediaMap.sectionUsages) {
|
||||||
|
if (!sectionUsage.assetId) continue;
|
||||||
|
usages.push({
|
||||||
|
assetId: sectionUsage.assetId,
|
||||||
|
usageType: "PORTFOLIO_SECTION",
|
||||||
|
entityType: "portfolio-project",
|
||||||
|
entityId: projectId,
|
||||||
|
fieldKey: sectionUsage.fieldKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const assetUsage of mediaMap.assetUsages) {
|
||||||
|
if (!assetUsage.assetId) continue;
|
||||||
|
usages.push({
|
||||||
|
assetId: assetUsage.assetId,
|
||||||
|
usageType: "PORTFOLIO_ASSET",
|
||||||
|
entityType: "portfolio-project",
|
||||||
|
entityId: projectId,
|
||||||
|
fieldKey: assetUsage.fieldKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (usages.length > 0) {
|
||||||
|
await db.insert(mediaUsage).values(usages);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SITE_SETTINGS_VALUE = JSON.stringify({
|
||||||
|
titleTemplate: "{pageTitle} | moh-sass",
|
||||||
|
locales: {
|
||||||
|
ar: {
|
||||||
|
siteName: "moh-sass",
|
||||||
|
titleTemplate: "{pageTitle} | {siteName}",
|
||||||
|
siteDescription: "Multilingual Next.js base project",
|
||||||
|
subhead: "",
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
siteName: "moh-sass",
|
||||||
|
titleTemplate: "{pageTitle} | {siteName}",
|
||||||
|
siteDescription: "Multilingual Next.js base project",
|
||||||
|
subhead: "",
|
||||||
|
},
|
||||||
|
de: {
|
||||||
|
siteName: "moh-sass",
|
||||||
|
titleTemplate: "{pageTitle} | {siteName}",
|
||||||
|
siteDescription: "Multilingual Next.js base project",
|
||||||
|
subhead: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await db
|
||||||
|
.delete(mediaUsage)
|
||||||
|
.where(eq(mediaUsage.entityType, "portfolio-project"));
|
||||||
|
await db.delete(portfolioSection);
|
||||||
|
await db.delete(portfolioAsset);
|
||||||
|
await db.delete(portfolioProject);
|
||||||
|
await db.delete(category);
|
||||||
|
|
||||||
|
await upsertAppConfig("siteName", "moh-sass");
|
||||||
|
await upsertAppConfig("site_settings", SITE_SETTINGS_VALUE);
|
||||||
|
|
||||||
|
const brandCategory = await upsertCategory({
|
||||||
|
slug: "branding",
|
||||||
|
nameAr: "الهوية البصرية",
|
||||||
|
nameEn: "Branding",
|
||||||
|
nameDe: "Branding",
|
||||||
|
descriptionAr: "مشاريع هوية بصرية وشعارات وأنظمة علامة.",
|
||||||
|
descriptionEn: "Brand identity, logo, and design system work.",
|
||||||
|
descriptionDe: "Branding, Logos und visuelle Systeme.",
|
||||||
|
sortOrder: 1,
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const webCategory = await upsertCategory({
|
||||||
|
slug: "web-experiences",
|
||||||
|
nameAr: "تجارب الويب",
|
||||||
|
nameEn: "Web Experiences",
|
||||||
|
nameDe: "Web Experiences",
|
||||||
|
descriptionAr: "مواقع وصفحات هبوط وتجارب رقمية سريعة.",
|
||||||
|
descriptionEn: "Websites, landing pages, and digital experiences.",
|
||||||
|
descriptionDe: "Webseiten, Landingpages und digitale Erlebnisse.",
|
||||||
|
sortOrder: 2,
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const commerceCategory = await upsertCategory({
|
||||||
|
slug: "commerce",
|
||||||
|
nameAr: "التجارة الرقمية",
|
||||||
|
nameEn: "Commerce",
|
||||||
|
nameDe: "Commerce",
|
||||||
|
descriptionAr: "متاجر وتجارب شراء رقمية مع تركيز على الوضوح والتحويل.",
|
||||||
|
descriptionEn: "Commerce experiences with a focus on clarity and conversion.",
|
||||||
|
descriptionDe: "Commerce-Projekte mit Fokus auf Klarheit und Conversion.",
|
||||||
|
sortOrder: 3,
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const gridCover = await upsertMediaAsset({
|
||||||
|
source: "UPLOAD",
|
||||||
|
kind: "IMAGE",
|
||||||
|
url: "/uploads/portfolio/demo-cover.svg",
|
||||||
|
fileName: "demo-cover.svg",
|
||||||
|
label: "Portfolio Grid Cover",
|
||||||
|
altText: "Portfolio Grid Cover",
|
||||||
|
mimeType: "image/svg+xml",
|
||||||
|
size: 1024,
|
||||||
|
});
|
||||||
|
|
||||||
|
const storyCover = await upsertMediaAsset({
|
||||||
|
source: "UPLOAD",
|
||||||
|
kind: "IMAGE",
|
||||||
|
url: "/uploads/portfolio/demo-cover.svg",
|
||||||
|
fileName: "demo-cover.svg",
|
||||||
|
label: "Portfolio Story Cover",
|
||||||
|
altText: "Portfolio Story Cover",
|
||||||
|
mimeType: "image/svg+xml",
|
||||||
|
size: 1024,
|
||||||
|
});
|
||||||
|
|
||||||
|
const caseStudyCover = await upsertMediaAsset({
|
||||||
|
source: "UPLOAD",
|
||||||
|
kind: "IMAGE",
|
||||||
|
url: "/uploads/portfolio/demo-cover.svg",
|
||||||
|
fileName: "demo-cover.svg",
|
||||||
|
label: "Portfolio Case Study Cover",
|
||||||
|
altText: "Portfolio Case Study Cover",
|
||||||
|
mimeType: "image/svg+xml",
|
||||||
|
size: 1024,
|
||||||
|
});
|
||||||
|
|
||||||
|
const projects = [
|
||||||
|
{
|
||||||
|
slug: "grid-product-launch",
|
||||||
|
categoryId: commerceCategory.id,
|
||||||
|
viewMode: "GRID" as const,
|
||||||
|
titleAr: "إطلاق منتج رقمي",
|
||||||
|
titleEn: "Grid Product Launch",
|
||||||
|
titleDe: "Grid Product Launch",
|
||||||
|
summaryAr: "مثال عرض شبكي لمشروع سريع مع أقسام قصيرة وأصول داعمة.",
|
||||||
|
summaryEn: "Grid view example for a fast product launch page.",
|
||||||
|
summaryDe: "Grid-Ansicht als Beispiel fuer einen schnellen Produktlaunch.",
|
||||||
|
clientName: "Launch Studio",
|
||||||
|
projectYear: 2026,
|
||||||
|
serviceLabelAr: "تجربة إطلاق",
|
||||||
|
serviceLabelEn: "Launch Experience",
|
||||||
|
serviceLabelDe: "Launch Experience",
|
||||||
|
previewUrl: "https://example.com/preview/grid-product-launch",
|
||||||
|
coverImagePath: gridCover.url,
|
||||||
|
isFeatured: true,
|
||||||
|
isPublished: true,
|
||||||
|
publishedAt: new Date("2026-01-12T09:00:00.000Z"),
|
||||||
|
sortOrder: 1,
|
||||||
|
coverAssetId: gridCover.id,
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
type: "RICH_TEXT" as const,
|
||||||
|
titleAr: "الفكرة",
|
||||||
|
titleEn: "Concept",
|
||||||
|
titleDe: "Konzept",
|
||||||
|
bodyAr: "واجهة سريعة لعرض المنتج والتركيز على الرسالة الأساسية.",
|
||||||
|
bodyEn: "A fast modular presentation focused on the main launch message.",
|
||||||
|
bodyDe: "Eine schnelle modulare Darstellung mit Fokus auf die Hauptbotschaft.",
|
||||||
|
imagePath: null,
|
||||||
|
linkUrl: null,
|
||||||
|
sortOrder: 0,
|
||||||
|
mediaAssetId: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "GALLERY" as const,
|
||||||
|
titleAr: "الصورة الرئيسية",
|
||||||
|
titleEn: "Hero Visual",
|
||||||
|
titleDe: "Hero Visual",
|
||||||
|
bodyAr: "",
|
||||||
|
bodyEn: "",
|
||||||
|
bodyDe: "",
|
||||||
|
imagePath: gridCover.url,
|
||||||
|
linkUrl: null,
|
||||||
|
sortOrder: 1,
|
||||||
|
mediaAssetId: gridCover.id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
assets: [
|
||||||
|
{
|
||||||
|
kind: "IMAGE" as const,
|
||||||
|
filePath: gridCover.url,
|
||||||
|
altAr: "غلاف مشروع Grid",
|
||||||
|
altEn: "Grid project cover",
|
||||||
|
altDe: "Grid Projekt Cover",
|
||||||
|
sortOrder: 0,
|
||||||
|
mediaAssetId: gridCover.id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "campaign-site",
|
||||||
|
categoryId: webCategory.id,
|
||||||
|
viewMode: "STORY" as const,
|
||||||
|
titleAr: "موقع حملة",
|
||||||
|
titleEn: "Campaign Site",
|
||||||
|
titleDe: "Campaign Site",
|
||||||
|
summaryAr: "مثال عرض قصصي لمشروع ويب مع تسلسل سردي أوضح.",
|
||||||
|
summaryEn: "Story view example for a launch campaign website.",
|
||||||
|
summaryDe: "Story-Ansicht als Beispiel fuer eine Kampagnenseite.",
|
||||||
|
clientName: "Launch Client",
|
||||||
|
projectYear: 2024,
|
||||||
|
serviceLabelAr: "موقع تسويقي",
|
||||||
|
serviceLabelEn: "Marketing Website",
|
||||||
|
serviceLabelDe: "Marketing Website",
|
||||||
|
previewUrl: "https://example.com/preview/campaign-site",
|
||||||
|
coverImagePath: storyCover.url,
|
||||||
|
isFeatured: false,
|
||||||
|
isPublished: true,
|
||||||
|
publishedAt: new Date("2024-09-05T09:00:00.000Z"),
|
||||||
|
sortOrder: 2,
|
||||||
|
coverAssetId: storyCover.id,
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
type: "RICH_TEXT" as const,
|
||||||
|
titleAr: "السياق",
|
||||||
|
titleEn: "Context",
|
||||||
|
titleDe: "Kontext",
|
||||||
|
bodyAr: "الحملة احتاجت صفحة مرنة وسريعة تتبدل بين أكثر من مرحلة.",
|
||||||
|
bodyEn: "The campaign needed a flexible page that could adapt across phases.",
|
||||||
|
bodyDe: "Die Kampagne brauchte eine flexible Seite fuer mehrere Phasen.",
|
||||||
|
imagePath: null,
|
||||||
|
linkUrl: null,
|
||||||
|
sortOrder: 0,
|
||||||
|
mediaAssetId: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "GALLERY" as const,
|
||||||
|
titleAr: "العرض البصري",
|
||||||
|
titleEn: "Visual Flow",
|
||||||
|
titleDe: "Visueller Ablauf",
|
||||||
|
bodyAr: "",
|
||||||
|
bodyEn: "",
|
||||||
|
bodyDe: "",
|
||||||
|
imagePath: storyCover.url,
|
||||||
|
linkUrl: null,
|
||||||
|
sortOrder: 1,
|
||||||
|
mediaAssetId: storyCover.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "LINK" as const,
|
||||||
|
titleAr: "المعاينة",
|
||||||
|
titleEn: "Preview",
|
||||||
|
titleDe: "Vorschau",
|
||||||
|
bodyAr: "رابط العرض المباشر.",
|
||||||
|
bodyEn: "Direct preview link.",
|
||||||
|
bodyDe: "Direkter Vorschau-Link.",
|
||||||
|
imagePath: null,
|
||||||
|
linkUrl: "https://example.com/preview/campaign-site",
|
||||||
|
sortOrder: 2,
|
||||||
|
mediaAssetId: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
assets: [
|
||||||
|
{
|
||||||
|
kind: "IMAGE" as const,
|
||||||
|
filePath: storyCover.url,
|
||||||
|
altAr: "غلاف مشروع Story",
|
||||||
|
altEn: "Story project cover",
|
||||||
|
altDe: "Story Projekt Cover",
|
||||||
|
sortOrder: 0,
|
||||||
|
mediaAssetId: storyCover.id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "brand-redesign",
|
||||||
|
categoryId: brandCategory.id,
|
||||||
|
viewMode: "CASE_STUDY" as const,
|
||||||
|
titleAr: "إعادة تصميم الهوية",
|
||||||
|
titleEn: "Brand Redesign",
|
||||||
|
titleDe: "Brand Redesign",
|
||||||
|
summaryAr: "مثال عرض دراسة حالة يركز على التحدي والحل والنتيجة.",
|
||||||
|
summaryEn: "Case study example focused on challenge, solution, and outcome.",
|
||||||
|
summaryDe: "Case-Study-Ansicht mit Fokus auf Herausforderung, Loesung und Ergebnis.",
|
||||||
|
clientName: "Studio Client",
|
||||||
|
projectYear: 2025,
|
||||||
|
serviceLabelAr: "هوية بصرية",
|
||||||
|
serviceLabelEn: "Brand Identity",
|
||||||
|
serviceLabelDe: "Brand Identity",
|
||||||
|
previewUrl: "https://example.com/preview/brand-redesign",
|
||||||
|
coverImagePath: caseStudyCover.url,
|
||||||
|
isFeatured: true,
|
||||||
|
isPublished: true,
|
||||||
|
publishedAt: new Date("2025-01-10T09:00:00.000Z"),
|
||||||
|
sortOrder: 3,
|
||||||
|
coverAssetId: caseStudyCover.id,
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
type: "RICH_TEXT" as const,
|
||||||
|
titleAr: "التحدي",
|
||||||
|
titleEn: "Challenge",
|
||||||
|
titleDe: "Herausforderung",
|
||||||
|
bodyAr: "كان المطلوب تحديث الهوية بدون خسارة التعرف البصري الحالي.",
|
||||||
|
bodyEn: "The brief required a refreshed identity without losing recognition.",
|
||||||
|
bodyDe: "Die Marke sollte modernisiert werden, ohne die Wiedererkennbarkeit zu verlieren.",
|
||||||
|
imagePath: null,
|
||||||
|
linkUrl: null,
|
||||||
|
sortOrder: 0,
|
||||||
|
mediaAssetId: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "RICH_TEXT" as const,
|
||||||
|
titleAr: "الحل",
|
||||||
|
titleEn: "Solution",
|
||||||
|
titleDe: "Loesung",
|
||||||
|
bodyAr: "تم بناء نظام مرئي أوضح مع قواعد استخدام قابلة للتوسع.",
|
||||||
|
bodyEn: "A clearer visual system with scalable usage rules was created.",
|
||||||
|
bodyDe: "Es wurde ein klareres visuelles System mit skalierbaren Regeln aufgebaut.",
|
||||||
|
imagePath: null,
|
||||||
|
linkUrl: null,
|
||||||
|
sortOrder: 1,
|
||||||
|
mediaAssetId: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "GALLERY" as const,
|
||||||
|
titleAr: "التنفيذ البصري",
|
||||||
|
titleEn: "Visual Execution",
|
||||||
|
titleDe: "Visuelle Umsetzung",
|
||||||
|
bodyAr: "",
|
||||||
|
bodyEn: "",
|
||||||
|
bodyDe: "",
|
||||||
|
imagePath: caseStudyCover.url,
|
||||||
|
linkUrl: null,
|
||||||
|
sortOrder: 2,
|
||||||
|
mediaAssetId: caseStudyCover.id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
assets: [
|
||||||
|
{
|
||||||
|
kind: "IMAGE" as const,
|
||||||
|
filePath: caseStudyCover.url,
|
||||||
|
altAr: "غلاف مشروع Case Study",
|
||||||
|
altEn: "Case study project cover",
|
||||||
|
altDe: "Case Study Projekt Cover",
|
||||||
|
sortOrder: 0,
|
||||||
|
mediaAssetId: caseStudyCover.id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const projectConfig of projects) {
|
||||||
|
const { sections, assets, coverAssetId, ...projectValues } = projectConfig;
|
||||||
|
|
||||||
|
const [project] = await db
|
||||||
|
.insert(portfolioProject)
|
||||||
|
.values(projectValues)
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: portfolioProject.slug,
|
||||||
|
set: {
|
||||||
|
categoryId: projectValues.categoryId,
|
||||||
|
viewMode: projectValues.viewMode,
|
||||||
|
titleAr: projectValues.titleAr,
|
||||||
|
titleEn: projectValues.titleEn,
|
||||||
|
titleDe: projectValues.titleDe,
|
||||||
|
summaryAr: projectValues.summaryAr,
|
||||||
|
summaryEn: projectValues.summaryEn,
|
||||||
|
summaryDe: projectValues.summaryDe,
|
||||||
|
clientName: projectValues.clientName,
|
||||||
|
projectYear: projectValues.projectYear,
|
||||||
|
serviceLabelAr: projectValues.serviceLabelAr,
|
||||||
|
serviceLabelEn: projectValues.serviceLabelEn,
|
||||||
|
serviceLabelDe: projectValues.serviceLabelDe,
|
||||||
|
previewUrl: projectValues.previewUrl,
|
||||||
|
coverImagePath: projectValues.coverImagePath,
|
||||||
|
isFeatured: projectValues.isFeatured,
|
||||||
|
isPublished: projectValues.isPublished,
|
||||||
|
publishedAt: projectValues.publishedAt,
|
||||||
|
sortOrder: projectValues.sortOrder,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const created = await syncProjectContent(
|
||||||
|
project.id,
|
||||||
|
sections.map((section) => ({
|
||||||
|
type: section.type,
|
||||||
|
titleAr: section.titleAr,
|
||||||
|
titleEn: section.titleEn,
|
||||||
|
titleDe: section.titleDe,
|
||||||
|
bodyAr: section.bodyAr,
|
||||||
|
bodyEn: section.bodyEn,
|
||||||
|
bodyDe: section.bodyDe,
|
||||||
|
imagePath: section.imagePath,
|
||||||
|
linkUrl: section.linkUrl,
|
||||||
|
sortOrder: section.sortOrder,
|
||||||
|
})),
|
||||||
|
assets.map((asset) => ({
|
||||||
|
kind: asset.kind,
|
||||||
|
filePath: asset.filePath,
|
||||||
|
altAr: asset.altAr,
|
||||||
|
altEn: asset.altEn,
|
||||||
|
altDe: asset.altDe,
|
||||||
|
sortOrder: asset.sortOrder,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
await syncProjectMediaUsages(project.id, {
|
||||||
|
coverAssetId,
|
||||||
|
sectionUsages: created.createdSections.map((sectionRow, index) => ({
|
||||||
|
fieldKey: sectionRow.id,
|
||||||
|
assetId: sections[index]?.mediaAssetId,
|
||||||
|
})),
|
||||||
|
assetUsages: created.createdAssets.map((assetRow, index) => ({
|
||||||
|
fieldKey: assetRow.id,
|
||||||
|
assetId: assets[index]?.mediaAssetId,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.then(async () => {
|
||||||
|
await client.end();
|
||||||
|
})
|
||||||
|
.catch(async (error) => {
|
||||||
|
console.error("Seed failed:", error);
|
||||||
|
await client.end();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -125,8 +125,7 @@ export async function createStandaloneMediaAsset(input: {
|
|||||||
uploadFile: FormDataEntryValue | null;
|
uploadFile: FormDataEntryValue | null;
|
||||||
}) {
|
}) {
|
||||||
if (input.uploadFile instanceof File && input.uploadFile.size > 0) {
|
if (input.uploadFile instanceof File && input.uploadFile.size > 0) {
|
||||||
const kind = getKindFromUploadFile(input.uploadFile);
|
const savedFile = await saveMediaUpload(input.uploadFile, input.kind.toLowerCase());
|
||||||
const savedFile = await saveMediaUpload(input.uploadFile, kind.toLowerCase());
|
|
||||||
const derivedLabel = input.uploadFile.name.replace(/\.[^.]+$/, "").trim();
|
const derivedLabel = input.uploadFile.name.replace(/\.[^.]+$/, "").trim();
|
||||||
const trimmedLabel = input.label.trim() || derivedLabel || "Media asset";
|
const trimmedLabel = input.label.trim() || derivedLabel || "Media asset";
|
||||||
|
|
||||||
@@ -136,7 +135,7 @@ export async function createStandaloneMediaAsset(input: {
|
|||||||
|
|
||||||
return createMediaAsset({
|
return createMediaAsset({
|
||||||
source: MediaSource.UPLOAD,
|
source: MediaSource.UPLOAD,
|
||||||
kind,
|
kind: input.kind,
|
||||||
url: savedFile.url,
|
url: savedFile.url,
|
||||||
fileName: savedFile.fileName,
|
fileName: savedFile.fileName,
|
||||||
label: trimmedLabel,
|
label: trimmedLabel,
|
||||||
|
|||||||
@@ -37,65 +37,16 @@ export function resolveMediaUploadPath(filePath: string) {
|
|||||||
throw new Error("Only managed media uploads can be resolved.");
|
throw new Error("Only managed media uploads can be resolved.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const relativePath = filePath.slice("/uploads/media/".length);
|
const relativePath = filePath.replace("/uploads/media/", "");
|
||||||
|
|
||||||
if (!relativePath || relativePath.includes("\0")) {
|
|
||||||
throw new Error("Resolved media upload path escapes the upload root.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const absolutePath = path.resolve(MEDIA_UPLOAD_ROOT, relativePath);
|
const absolutePath = path.resolve(MEDIA_UPLOAD_ROOT, relativePath);
|
||||||
|
|
||||||
// `startsWith(root)` alone would accept a sibling directory such as
|
if (!absolutePath.startsWith(MEDIA_UPLOAD_ROOT)) {
|
||||||
// `.../uploads/media-evil/...`; require the separator so only true children pass.
|
|
||||||
if (absolutePath !== MEDIA_UPLOAD_ROOT && !absolutePath.startsWith(MEDIA_UPLOAD_ROOT + path.sep)) {
|
|
||||||
throw new Error("Resolved media upload path escapes the upload root.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (absolutePath === MEDIA_UPLOAD_ROOT) {
|
|
||||||
throw new Error("Resolved media upload path escapes the upload root.");
|
throw new Error("Resolved media upload path escapes the upload root.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return absolutePath;
|
return absolutePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAGIC_SIGNATURES: Record<string, Array<{ offset: number; bytes: number[] }>> = {
|
|
||||||
".png": [{ offset: 0, bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] }],
|
|
||||||
".jpg": [{ offset: 0, bytes: [0xff, 0xd8, 0xff] }],
|
|
||||||
".gif": [{ offset: 0, bytes: [0x47, 0x49, 0x46, 0x38] }],
|
|
||||||
".webp": [
|
|
||||||
{ offset: 0, bytes: [0x52, 0x49, 0x46, 0x46] },
|
|
||||||
{ offset: 8, bytes: [0x57, 0x45, 0x42, 0x50] },
|
|
||||||
],
|
|
||||||
".pdf": [{ offset: 0, bytes: [0x25, 0x50, 0x44, 0x46] }],
|
|
||||||
".ico": [{ offset: 0, bytes: [0x00, 0x00, 0x01, 0x00] }],
|
|
||||||
};
|
|
||||||
|
|
||||||
const SVG_FORBIDDEN_PATTERN = /<script[\s>]|javascript:|on[a-z]+\s*=|<foreignObject|<iframe|<embed|<object|xlink:href\s*=\s*["']\s*(?!#|data:image\/)/i;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify that the file bytes match the extension derived from the declared
|
|
||||||
* MIME type. The browser-supplied `file.type` is untrusted: without this check
|
|
||||||
* an HTML/JS payload could be stored as `.png` and served from our origin.
|
|
||||||
*/
|
|
||||||
export function isMediaContentValid(extension: string, buffer: Buffer): boolean {
|
|
||||||
if (extension === ".svg") {
|
|
||||||
const head = buffer.subarray(0, 4096).toString("utf8").trimStart();
|
|
||||||
const looksLikeSvg = head.startsWith("<svg") || (head.startsWith("<?xml") && /<svg[\s>]/i.test(head));
|
|
||||||
|
|
||||||
return looksLikeSvg && !SVG_FORBIDDEN_PATTERN.test(buffer.toString("utf8"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const signatures = MAGIC_SIGNATURES[extension];
|
|
||||||
|
|
||||||
if (!signatures) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return signatures.every(({ offset, bytes }) =>
|
|
||||||
bytes.every((byte, index) => buffer[offset + index] === byte),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function removeManagedMediaFile(filePath: string | null | undefined) {
|
export async function removeManagedMediaFile(filePath: string | null | undefined) {
|
||||||
if (!isManagedMediaFilePath(filePath)) {
|
if (!isManagedMediaFilePath(filePath)) {
|
||||||
return false;
|
return false;
|
||||||
@@ -125,23 +76,16 @@ export async function saveMediaUpload(file: File, folder: string) {
|
|||||||
throw new Error("File is too large.");
|
throw new Error("File is too large.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
|
||||||
|
|
||||||
if (!isMediaContentValid(extension, buffer)) {
|
|
||||||
throw new Error("File content does not match its declared type.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const safeFolder = sanitizeBaseName(folder) || "misc";
|
|
||||||
const safeBaseName = sanitizeBaseName(file.name.replace(/\.[^.]+$/, "")) || "asset";
|
const safeBaseName = sanitizeBaseName(file.name.replace(/\.[^.]+$/, "")) || "asset";
|
||||||
const finalName = `${safeBaseName}-${randomUUID().slice(0, 8)}${extension}`;
|
const finalName = `${safeBaseName}-${randomUUID().slice(0, 8)}${extension}`;
|
||||||
const targetDir = path.join(MEDIA_UPLOAD_ROOT, safeFolder);
|
const targetDir = path.join(MEDIA_UPLOAD_ROOT, folder);
|
||||||
const targetPath = path.join(targetDir, finalName);
|
const targetPath = path.join(targetDir, finalName);
|
||||||
|
|
||||||
await mkdir(targetDir, { recursive: true });
|
await mkdir(targetDir, { recursive: true });
|
||||||
await writeFile(targetPath, buffer);
|
await writeFile(targetPath, Buffer.from(await file.arrayBuffer()));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
url: `/uploads/media/${safeFolder}/${finalName}`,
|
url: `/uploads/media/${folder}/${finalName}`,
|
||||||
fileName: finalName,
|
fileName: finalName,
|
||||||
mimeType: file.type,
|
mimeType: file.type,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
|
|||||||
@@ -30,9 +30,7 @@ export const mediaFieldInputSchema = z
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const isRootRelative = value.url.startsWith("/") && !value.url.startsWith("//");
|
if (value.url && !/^https?:\/\//.test(value.url) && !value.url.startsWith("/")) {
|
||||||
|
|
||||||
if (value.url && !/^https?:\/\//i.test(value.url) && !isRootRelative) {
|
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
path: ["url"],
|
path: ["url"],
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { and, desc, eq } from "drizzle-orm";
|
import { and, count, desc, eq } from "drizzle-orm";
|
||||||
|
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { mediaAsset, mediaUsage } from "@/lib/db/schema";
|
import { mediaAsset, mediaUsage } from "@/lib/db/schema";
|
||||||
|
import type { MediaAsset, MediaUsage } from "@/lib/db/schema";
|
||||||
import type { MediaKind, MediaSource, MediaUsageType } from "@/lib/db/enums";
|
import type { MediaKind, MediaSource, MediaUsageType } from "@/lib/db/enums";
|
||||||
|
|
||||||
type MediaAssetRow = typeof mediaAsset.$inferSelect;
|
|
||||||
type MediaUsageRow = typeof mediaUsage.$inferSelect;
|
|
||||||
|
|
||||||
export type MediaAssetView = Pick<
|
export type MediaAssetView = Pick<
|
||||||
MediaAssetRow,
|
MediaAsset,
|
||||||
"id" | "source" | "kind" | "url" | "fileName" | "label" | "altText" | "mimeType" | "size" | "createdAt"
|
"id" | "source" | "kind" | "url" | "fileName" | "label" | "altText" | "mimeType" | "size" | "createdAt"
|
||||||
> & {
|
> & {
|
||||||
usages: Array<Pick<MediaUsageRow, "id" | "usageType" | "entityType" | "entityId" | "fieldKey">>;
|
usages: Array<
|
||||||
|
Pick<MediaUsage, "id" | "usageType" | "entityType" | "entityId" | "fieldKey">
|
||||||
|
>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MediaOption = Pick<MediaAssetView, "id" | "kind" | "url" | "label" | "source">;
|
export type MediaOption = Pick<MediaAssetView, "id" | "kind" | "url" | "label" | "source">;
|
||||||
@@ -22,7 +22,11 @@ export type PortfolioMediaBindings = {
|
|||||||
assetIds: Record<string, string>;
|
assetIds: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function mapMediaAsset(asset: MediaAssetRow & { usages: MediaUsageRow[] }): MediaAssetView {
|
function mapMediaAsset(
|
||||||
|
asset: MediaAsset & {
|
||||||
|
usages: MediaUsage[];
|
||||||
|
},
|
||||||
|
): MediaAssetView {
|
||||||
return {
|
return {
|
||||||
id: asset.id,
|
id: asset.id,
|
||||||
source: asset.source,
|
source: asset.source,
|
||||||
@@ -46,15 +50,19 @@ function mapMediaAsset(asset: MediaAssetRow & { usages: MediaUsageRow[] }): Medi
|
|||||||
|
|
||||||
export async function getAdminMediaAssets() {
|
export async function getAdminMediaAssets() {
|
||||||
const assets = await db.query.mediaAsset.findMany({
|
const assets = await db.query.mediaAsset.findMany({
|
||||||
with: { usages: { orderBy: [desc(mediaUsage.createdAt)] } },
|
with: {
|
||||||
orderBy: [desc(mediaAsset.createdAt)],
|
usages: {
|
||||||
|
orderBy: (usage, { desc: descOrder }) => [descOrder(usage.createdAt)],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: (asset, { desc: descOrder }) => [descOrder(asset.createdAt)],
|
||||||
});
|
});
|
||||||
|
|
||||||
return assets.map(mapMediaAsset);
|
return assets.map(mapMediaAsset);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMediaOptions(filters?: { kind?: MediaKind }): Promise<MediaOption[]> {
|
export async function getMediaOptions(filters?: { kind?: MediaKind }) {
|
||||||
return db
|
const assets = await db
|
||||||
.select({
|
.select({
|
||||||
id: mediaAsset.id,
|
id: mediaAsset.id,
|
||||||
kind: mediaAsset.kind,
|
kind: mediaAsset.kind,
|
||||||
@@ -65,12 +73,16 @@ export async function getMediaOptions(filters?: { kind?: MediaKind }): Promise<M
|
|||||||
.from(mediaAsset)
|
.from(mediaAsset)
|
||||||
.where(filters?.kind ? eq(mediaAsset.kind, filters.kind) : undefined)
|
.where(filters?.kind ? eq(mediaAsset.kind, filters.kind) : undefined)
|
||||||
.orderBy(desc(mediaAsset.createdAt));
|
.orderBy(desc(mediaAsset.createdAt));
|
||||||
|
|
||||||
|
return assets;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMediaAssetById(id: string) {
|
export async function getMediaAssetById(id: string) {
|
||||||
const asset = await db.query.mediaAsset.findFirst({
|
const asset = await db.query.mediaAsset.findFirst({
|
||||||
where: eq(mediaAsset.id, id),
|
where: eq(mediaAsset.id, id),
|
||||||
with: { usages: true },
|
with: {
|
||||||
|
usages: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return asset ? mapMediaAsset(asset) : null;
|
return asset ? mapMediaAsset(asset) : null;
|
||||||
@@ -86,7 +98,7 @@ export async function createMediaAsset(input: {
|
|||||||
mimeType?: string | null;
|
mimeType?: string | null;
|
||||||
size?: number | null;
|
size?: number | null;
|
||||||
}) {
|
}) {
|
||||||
const [created] = await db
|
const [asset] = await db
|
||||||
.insert(mediaAsset)
|
.insert(mediaAsset)
|
||||||
.values({
|
.values({
|
||||||
source: input.source,
|
source: input.source,
|
||||||
@@ -100,7 +112,7 @@ export async function createMediaAsset(input: {
|
|||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
return created;
|
return asset;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function replaceEntityMediaUsages(input: {
|
export async function replaceEntityMediaUsages(input: {
|
||||||
@@ -115,7 +127,12 @@ export async function replaceEntityMediaUsages(input: {
|
|||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
await tx
|
await tx
|
||||||
.delete(mediaUsage)
|
.delete(mediaUsage)
|
||||||
.where(and(eq(mediaUsage.entityType, input.entityType), eq(mediaUsage.entityId, input.entityId)));
|
.where(
|
||||||
|
and(
|
||||||
|
eq(mediaUsage.entityType, input.entityType),
|
||||||
|
eq(mediaUsage.entityId, input.entityId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
if (input.usages.length === 0) {
|
if (input.usages.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -147,7 +164,9 @@ export async function getPortfolioMediaBindings(projectId: string): Promise<Port
|
|||||||
fieldKey: mediaUsage.fieldKey,
|
fieldKey: mediaUsage.fieldKey,
|
||||||
})
|
})
|
||||||
.from(mediaUsage)
|
.from(mediaUsage)
|
||||||
.where(and(eq(mediaUsage.entityType, "portfolio-project"), eq(mediaUsage.entityId, projectId)));
|
.where(
|
||||||
|
and(eq(mediaUsage.entityType, "portfolio-project"), eq(mediaUsage.entityId, projectId)),
|
||||||
|
);
|
||||||
|
|
||||||
return usages.reduce<PortfolioMediaBindings>(
|
return usages.reduce<PortfolioMediaBindings>(
|
||||||
(result, usage) => {
|
(result, usage) => {
|
||||||
@@ -174,5 +193,10 @@ export async function getPortfolioMediaBindings(projectId: string): Promise<Port
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function countMediaUsageReferences(assetId: string) {
|
export async function countMediaUsageReferences(assetId: string) {
|
||||||
return db.$count(mediaUsage, eq(mediaUsage.assetId, assetId));
|
const [row] = await db
|
||||||
|
.select({ value: count() })
|
||||||
|
.from(mediaUsage)
|
||||||
|
.where(eq(mediaUsage.assetId, assetId));
|
||||||
|
|
||||||
|
return row?.value ?? 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,50 +11,17 @@ import {
|
|||||||
getSiteSettings,
|
getSiteSettings,
|
||||||
getSiteSettingsMediaBindings,
|
getSiteSettingsMediaBindings,
|
||||||
} from "./app-config";
|
} from "./app-config";
|
||||||
|
import { AppLocale, getLocalizedPath, getLocalizedPathWithDefault, resolveLocale } from "./locale";
|
||||||
|
import { buildSiteIconUrls } from "./site-icons";
|
||||||
|
|
||||||
export function getSiteUrl(): URL {
|
function getSiteUrl(): URL {
|
||||||
return new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "https://mohfarawati.de");
|
return new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "https://mohfarawati.de");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toAbsoluteUrl(pathname: string): string {
|
function toAbsoluteUrl(pathname: string): string {
|
||||||
return new URL(pathname, getSiteUrl()).toString();
|
return new URL(pathname, getSiteUrl()).toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
const NOINDEX_ROBOTS: Metadata["robots"] = {
|
|
||||||
index: false,
|
|
||||||
follow: false,
|
|
||||||
nocache: true,
|
|
||||||
googleBot: { index: false, follow: false, noimageindex: true },
|
|
||||||
};
|
|
||||||
|
|
||||||
const INDEX_ROBOTS: Metadata["robots"] = {
|
|
||||||
index: true,
|
|
||||||
follow: true,
|
|
||||||
googleBot: { index: true, follow: true, "max-image-preview": "large", "max-snippet": -1, "max-video-preview": -1 },
|
|
||||||
};
|
|
||||||
|
|
||||||
export function buildRobotsMetadata(seo: SeoSettings, noIndex = false): Metadata["robots"] {
|
|
||||||
return seo.allowIndexing && !noIndex ? INDEX_ROBOTS : NOINDEX_ROBOTS;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildVerification(seo: SeoSettings): Metadata["verification"] {
|
|
||||||
const verification: NonNullable<Metadata["verification"]> = {};
|
|
||||||
|
|
||||||
if (seo.googleSiteVerification) {
|
|
||||||
verification.google = seo.googleSiteVerification;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (seo.bingSiteVerification) {
|
|
||||||
verification.other = { "msvalidate.01": seo.bingSiteVerification };
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.keys(verification).length > 0 ? verification : undefined;
|
|
||||||
}
|
|
||||||
import { AppLocale, getLocalizedPath, getLocalizedPathWithDefault, resolveLocale } from "./locale";
|
|
||||||
import { getSeoSettings } from "./app-config";
|
|
||||||
import { buildDefaultSeoSettings, toOpenGraphLocale, type SeoSettings } from "./seo-settings";
|
|
||||||
import { buildSiteIconUrls } from "./site-icons";
|
|
||||||
|
|
||||||
export function buildLocaleAlternates(pathname: string, defaultLocale: AppLocale) {
|
export function buildLocaleAlternates(pathname: string, defaultLocale: AppLocale) {
|
||||||
const languages = Object.fromEntries(
|
const languages = Object.fromEntries(
|
||||||
appLocales.map((locale) => [locale, toAbsoluteUrl(getLocalizedPathWithDefault(locale, pathname, defaultLocale))]),
|
appLocales.map((locale) => [locale, toAbsoluteUrl(getLocalizedPathWithDefault(locale, pathname, defaultLocale))]),
|
||||||
@@ -79,7 +46,7 @@ export function applyTitleTemplateFn(title: string, template: string, siteName:
|
|||||||
.replace(PAGE_TITLE_TOKEN, title);
|
.replace(PAGE_TITLE_TOKEN, title);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMetadataImages(imageUrl?: string | null, alt?: string) {
|
function buildMetadataImages(imageUrl?: string | null) {
|
||||||
if (!imageUrl) {
|
if (!imageUrl) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -87,29 +54,22 @@ function buildMetadataImages(imageUrl?: string | null, alt?: string) {
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
url: toAbsoluteUrl(imageUrl),
|
url: toAbsoluteUrl(imageUrl),
|
||||||
alt,
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function buildAppMetadata(): Promise<Metadata> {
|
export async function buildAppMetadata(): Promise<Metadata> {
|
||||||
const [settings, bindings, seo] = await Promise.all([
|
const [settings, bindings] = await Promise.all([getSiteSettings(), getSiteSettingsMediaBindings()]);
|
||||||
getSiteSettings(),
|
|
||||||
getSiteSettingsMediaBindings(),
|
|
||||||
getSeoSettings(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return buildAppMetadataFromConfig(settings, bindings, seo);
|
return buildAppMetadataFromConfig(settings, bindings);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildAppMetadataFromConfig(
|
export function buildAppMetadataFromConfig(
|
||||||
settings: SiteSettings,
|
settings: SiteSettings,
|
||||||
bindings: SiteSettingsMediaBindings,
|
bindings: SiteSettingsMediaBindings,
|
||||||
seo: SeoSettings = buildDefaultSeoSettings(),
|
|
||||||
): Metadata {
|
): Metadata {
|
||||||
const defaultLocaleSettings = settings.locales[settings.defaultLocale];
|
const defaultLocaleSettings = settings.locales[settings.defaultLocale];
|
||||||
const openGraphImages = buildMetadataImages(bindings.defaultOgImage?.url, defaultLocaleSettings.siteName);
|
const openGraphImages = buildMetadataImages(bindings.defaultOgImage?.url);
|
||||||
const keywords = seo.locales[settings.defaultLocale].keywords;
|
|
||||||
const siteIconUrls = buildSiteIconUrls({
|
const siteIconUrls = buildSiteIconUrls({
|
||||||
siteName: defaultLocaleSettings.siteName,
|
siteName: defaultLocaleSettings.siteName,
|
||||||
faviconVersion: bindings.favicon?.version,
|
faviconVersion: bindings.favicon?.version,
|
||||||
@@ -121,10 +81,6 @@ export function buildAppMetadataFromConfig(
|
|||||||
title: defaultLocaleSettings.siteName,
|
title: defaultLocaleSettings.siteName,
|
||||||
description: defaultLocaleSettings.siteDescription,
|
description: defaultLocaleSettings.siteDescription,
|
||||||
applicationName: defaultLocaleSettings.siteName,
|
applicationName: defaultLocaleSettings.siteName,
|
||||||
keywords: keywords ? keywords.split(",").map((keyword) => keyword.trim()).filter(Boolean) : undefined,
|
|
||||||
robots: buildRobotsMetadata(seo),
|
|
||||||
verification: buildVerification(seo),
|
|
||||||
formatDetection: { telephone: false },
|
|
||||||
manifest: siteIconUrls.manifestHref,
|
manifest: siteIconUrls.manifestHref,
|
||||||
icons: {
|
icons: {
|
||||||
icon: [{ url: siteIconUrls.faviconHref }],
|
icon: [{ url: siteIconUrls.faviconHref }],
|
||||||
@@ -136,8 +92,7 @@ export function buildAppMetadataFromConfig(
|
|||||||
description: defaultLocaleSettings.siteDescription,
|
description: defaultLocaleSettings.siteDescription,
|
||||||
url: toAbsoluteUrl(getLocalizedPathWithDefault(settings.defaultLocale, "/", settings.defaultLocale)),
|
url: toAbsoluteUrl(getLocalizedPathWithDefault(settings.defaultLocale, "/", settings.defaultLocale)),
|
||||||
siteName: defaultLocaleSettings.siteName,
|
siteName: defaultLocaleSettings.siteName,
|
||||||
locale: toOpenGraphLocale(settings.defaultLocale),
|
locale: settings.defaultLocale,
|
||||||
alternateLocale: appLocales.filter((locale) => locale !== settings.defaultLocale).map(toOpenGraphLocale),
|
|
||||||
type: "website",
|
type: "website",
|
||||||
images: openGraphImages,
|
images: openGraphImages,
|
||||||
},
|
},
|
||||||
@@ -145,25 +100,12 @@ export function buildAppMetadataFromConfig(
|
|||||||
card: openGraphImages ? "summary_large_image" : "summary",
|
card: openGraphImages ? "summary_large_image" : "summary",
|
||||||
title: defaultLocaleSettings.siteName,
|
title: defaultLocaleSettings.siteName,
|
||||||
description: defaultLocaleSettings.siteDescription,
|
description: defaultLocaleSettings.siteDescription,
|
||||||
site: seo.twitterHandle || undefined,
|
|
||||||
creator: seo.twitterHandle || undefined,
|
|
||||||
images: openGraphImages?.map((image) => image.url),
|
images: openGraphImages?.map((image) => image.url),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
type LocalizedMetadataOptions = {
|
type LocalizedMetadataInput = {
|
||||||
/** Page-specific share image (e.g. a project cover). Falls back to the default OG image. */
|
|
||||||
image?: string | null;
|
|
||||||
/** Force `noindex` (thank-you pages, coming-soon, etc.). */
|
|
||||||
noIndex?: boolean;
|
|
||||||
/** Open Graph object type. Portfolio projects use `article`. */
|
|
||||||
type?: "website" | "article";
|
|
||||||
publishedTime?: Date | null;
|
|
||||||
modifiedTime?: Date | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type LocalizedMetadataInput = LocalizedMetadataOptions & {
|
|
||||||
locale: string;
|
locale: string;
|
||||||
pathname: string;
|
pathname: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -177,190 +119,65 @@ export async function buildLocalizedMetadata({
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
applyTitleTemplate,
|
applyTitleTemplate,
|
||||||
...options
|
|
||||||
}: LocalizedMetadataInput): Promise<Metadata> {
|
}: LocalizedMetadataInput): Promise<Metadata> {
|
||||||
const [settings, bindings, seo] = await Promise.all([
|
const [settings, bindings] = await Promise.all([getSiteSettings(), getSiteSettingsMediaBindings()]);
|
||||||
getSiteSettings(),
|
|
||||||
getSiteSettingsMediaBindings(),
|
|
||||||
getSeoSettings(),
|
|
||||||
]);
|
|
||||||
const localeKey = resolveLocale(locale, settings.defaultLocale);
|
const localeKey = resolveLocale(locale, settings.defaultLocale);
|
||||||
|
|
||||||
return buildLocalizedMetadataFromConfig({
|
return buildLocalizedMetadataFromConfig({
|
||||||
settings,
|
settings,
|
||||||
bindings,
|
bindings,
|
||||||
seo,
|
|
||||||
locale: localeKey,
|
locale: localeKey,
|
||||||
pathname,
|
pathname,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
applyTitleTemplate,
|
applyTitleTemplate,
|
||||||
...options,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildLocalizedMetadataFromConfig(
|
export function buildLocalizedMetadataFromConfig(input: {
|
||||||
input: LocalizedMetadataOptions & {
|
settings: SiteSettings;
|
||||||
settings: SiteSettings;
|
bindings: SiteSettingsMediaBindings;
|
||||||
bindings: SiteSettingsMediaBindings;
|
locale: AppLocale;
|
||||||
seo?: SeoSettings;
|
pathname: string;
|
||||||
locale: AppLocale;
|
title: string;
|
||||||
pathname: string;
|
description?: string;
|
||||||
title: string;
|
applyTitleTemplate?: boolean;
|
||||||
description?: string;
|
}): Metadata {
|
||||||
applyTitleTemplate?: boolean;
|
|
||||||
},
|
|
||||||
): Metadata {
|
|
||||||
const {
|
const {
|
||||||
settings,
|
settings,
|
||||||
bindings,
|
bindings,
|
||||||
seo = buildDefaultSeoSettings(),
|
|
||||||
locale,
|
locale,
|
||||||
pathname,
|
pathname,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
applyTitleTemplate = true,
|
applyTitleTemplate = true,
|
||||||
image,
|
|
||||||
noIndex = false,
|
|
||||||
type = "website",
|
|
||||||
publishedTime,
|
|
||||||
modifiedTime,
|
|
||||||
} = input;
|
} = input;
|
||||||
const localeKey = resolveLocale(locale, settings.defaultLocale);
|
const localeKey = resolveLocale(locale, settings.defaultLocale);
|
||||||
const localeSettings = settings.locales[localeKey];
|
const localeSettings = settings.locales[localeKey];
|
||||||
const resolvedDescription = (description?.trim() || localeSettings.siteDescription).slice(0, 300);
|
const resolvedDescription = description?.trim() || localeSettings.siteDescription;
|
||||||
const resolvedTitle = applyTitleTemplate
|
const resolvedTitle = applyTitleTemplate
|
||||||
? applyTitleTemplateFn(title, localeSettings.titleTemplate, localeSettings.siteName)
|
? applyTitleTemplateFn(title, localeSettings.titleTemplate, localeSettings.siteName)
|
||||||
: title;
|
: title;
|
||||||
const openGraphImages = buildMetadataImages(image || bindings.defaultOgImage?.url, title);
|
const openGraphImages = buildMetadataImages(bindings.defaultOgImage?.url);
|
||||||
const keywords = seo.locales[localeKey].keywords;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: resolvedTitle,
|
title: resolvedTitle,
|
||||||
description: resolvedDescription,
|
description: resolvedDescription,
|
||||||
keywords: keywords ? keywords.split(",").map((keyword) => keyword.trim()).filter(Boolean) : undefined,
|
|
||||||
robots: buildRobotsMetadata(seo, noIndex),
|
|
||||||
alternates: buildLocaleAlternates(pathname, settings.defaultLocale),
|
alternates: buildLocaleAlternates(pathname, settings.defaultLocale),
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: resolvedTitle,
|
title: resolvedTitle,
|
||||||
description: resolvedDescription,
|
description: resolvedDescription,
|
||||||
url: toAbsoluteUrl(getLocalizedPath(localeKey, pathname, settings.defaultLocale)),
|
url: toAbsoluteUrl(getLocalizedPath(localeKey, pathname, settings.defaultLocale)),
|
||||||
siteName: localeSettings.siteName,
|
siteName: localeSettings.siteName,
|
||||||
locale: toOpenGraphLocale(localeKey),
|
locale: localeKey,
|
||||||
alternateLocale: appLocales.filter((entry) => entry !== localeKey).map(toOpenGraphLocale),
|
type: "website",
|
||||||
images: openGraphImages,
|
images: openGraphImages,
|
||||||
...(type === "article"
|
|
||||||
? {
|
|
||||||
type: "article" as const,
|
|
||||||
publishedTime: publishedTime?.toISOString(),
|
|
||||||
modifiedTime: (modifiedTime ?? publishedTime)?.toISOString(),
|
|
||||||
}
|
|
||||||
: { type: "website" as const }),
|
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: openGraphImages ? "summary_large_image" : "summary",
|
card: openGraphImages ? "summary_large_image" : "summary",
|
||||||
title: resolvedTitle,
|
title: resolvedTitle,
|
||||||
description: resolvedDescription,
|
description: resolvedDescription,
|
||||||
site: seo.twitterHandle || undefined,
|
images: openGraphImages?.map((image) => image.url),
|
||||||
creator: seo.twitterHandle || undefined,
|
|
||||||
images: openGraphImages?.map((entry) => entry.url),
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- JSON-LD ----------------------------------------------------------------
|
|
||||||
|
|
||||||
type JsonLd = Record<string, unknown>;
|
|
||||||
|
|
||||||
/** WebSite + publisher (Person/Organization) graph for the home page. */
|
|
||||||
export function buildSiteJsonLd(input: {
|
|
||||||
settings: SiteSettings;
|
|
||||||
seo: SeoSettings;
|
|
||||||
bindings: SiteSettingsMediaBindings;
|
|
||||||
locale: AppLocale;
|
|
||||||
}): JsonLd {
|
|
||||||
const { settings, seo, bindings, locale } = input;
|
|
||||||
const localeSettings = settings.locales[locale];
|
|
||||||
const siteUrl = getSiteUrl().toString();
|
|
||||||
const publisherName = seo.structuredDataName || localeSettings.siteName;
|
|
||||||
const logoUrl = bindings.siteLogoLight?.url ?? bindings.defaultOgImage?.url ?? null;
|
|
||||||
|
|
||||||
const publisher: JsonLd = {
|
|
||||||
"@type": seo.structuredDataType,
|
|
||||||
"@id": `${siteUrl}#${seo.structuredDataType.toLowerCase()}`,
|
|
||||||
name: publisherName,
|
|
||||||
url: siteUrl,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (seo.structuredDataJobTitle) {
|
|
||||||
publisher[seo.structuredDataType === "Person" ? "jobTitle" : "slogan"] = seo.structuredDataJobTitle;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (logoUrl) {
|
|
||||||
publisher[seo.structuredDataType === "Person" ? "image" : "logo"] = toAbsoluteUrl(logoUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (seo.sameAs.length > 0) {
|
|
||||||
publisher.sameAs = seo.sameAs;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
"@context": "https://schema.org",
|
|
||||||
"@graph": [
|
|
||||||
{
|
|
||||||
"@type": "WebSite",
|
|
||||||
"@id": `${siteUrl}#website`,
|
|
||||||
url: siteUrl,
|
|
||||||
name: localeSettings.siteName,
|
|
||||||
description: localeSettings.siteDescription || undefined,
|
|
||||||
inLanguage: appLocales,
|
|
||||||
publisher: { "@id": publisher["@id"] },
|
|
||||||
},
|
|
||||||
publisher,
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** CreativeWork for a single portfolio project (any view mode). */
|
|
||||||
export function buildProjectJsonLd(input: {
|
|
||||||
settings: SiteSettings;
|
|
||||||
seo: SeoSettings;
|
|
||||||
locale: AppLocale;
|
|
||||||
pathname: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
image?: string | null;
|
|
||||||
datePublished?: Date | null;
|
|
||||||
dateModified?: Date | null;
|
|
||||||
genre?: string;
|
|
||||||
keywords?: string[];
|
|
||||||
clientName?: string;
|
|
||||||
}): JsonLd {
|
|
||||||
const { settings, seo, locale, pathname } = input;
|
|
||||||
const siteUrl = getSiteUrl().toString();
|
|
||||||
const url = toAbsoluteUrl(getLocalizedPath(locale, pathname, settings.defaultLocale));
|
|
||||||
|
|
||||||
return {
|
|
||||||
"@context": "https://schema.org",
|
|
||||||
"@type": "CreativeWork",
|
|
||||||
"@id": `${url}#work`,
|
|
||||||
url,
|
|
||||||
name: input.title,
|
|
||||||
headline: input.title,
|
|
||||||
description: input.description || undefined,
|
|
||||||
image: input.image ? toAbsoluteUrl(input.image) : undefined,
|
|
||||||
inLanguage: locale,
|
|
||||||
genre: input.genre || undefined,
|
|
||||||
keywords: input.keywords && input.keywords.length > 0 ? input.keywords.join(", ") : undefined,
|
|
||||||
datePublished: input.datePublished?.toISOString(),
|
|
||||||
dateModified: (input.dateModified ?? input.datePublished)?.toISOString(),
|
|
||||||
author: { "@id": `${siteUrl}#${seo.structuredDataType.toLowerCase()}` },
|
|
||||||
sourceOrganization: input.clientName ? { "@type": "Organization", name: input.clientName } : undefined,
|
|
||||||
isPartOf: { "@id": `${siteUrl}#website` },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Serialize JSON-LD safely for a `<script type="application/ld+json">` tag. */
|
|
||||||
export function serializeJsonLd(data: JsonLd): string {
|
|
||||||
return JSON.stringify(data).replace(/</g, "\\u003c");
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -136,21 +136,13 @@ export function getPortfolioWizardProgress(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "sections",
|
key: "sections",
|
||||||
// Optional: no sections is valid. If sections were added, each must be ready.
|
complete: input.sections.length > 0 && completedSections === input.sections.length,
|
||||||
complete: completedSections === input.sections.length,
|
summary: `${completedSections}/${input.sections.length} sections ready.`,
|
||||||
summary:
|
|
||||||
input.sections.length === 0
|
|
||||||
? "Optional — no sections added."
|
|
||||||
: `${completedSections}/${input.sections.length} sections ready.`,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "assets",
|
key: "assets",
|
||||||
// Optional: no gallery assets is valid. If assets were added, each must be ready.
|
complete: input.assets.length > 0 && completedAssets === input.assets.length,
|
||||||
complete: completedAssets === input.assets.length,
|
summary: `${completedAssets}/${input.assets.length} assets ready.`,
|
||||||
summary:
|
|
||||||
input.assets.length === 0
|
|
||||||
? "Optional — no gallery assets added."
|
|
||||||
: `${completedAssets}/${input.assets.length} assets ready.`,
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,21 +123,3 @@ export const projectInputSchema = z.object({
|
|||||||
sections: z.array(sectionInputSchema),
|
sections: z.array(sectionInputSchema),
|
||||||
assets: z.array(assetInputSchema),
|
assets: z.array(assetInputSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Draft variant: the user-facing copy fields are optional so an unfinished
|
|
||||||
* project can be saved and completed later. The action still guarantees a
|
|
||||||
* slug, a category, and a year (auto-filled), and forces the project unpublished.
|
|
||||||
*/
|
|
||||||
export const projectDraftInputSchema = projectInputSchema.extend({
|
|
||||||
titleAr: optionalTrimmedText,
|
|
||||||
titleEn: optionalTrimmedText,
|
|
||||||
titleDe: optionalTrimmedText,
|
|
||||||
summaryAr: optionalTrimmedText,
|
|
||||||
summaryEn: optionalTrimmedText,
|
|
||||||
summaryDe: optionalTrimmedText,
|
|
||||||
serviceLabelAr: optionalTrimmedText,
|
|
||||||
serviceLabelEn: optionalTrimmedText,
|
|
||||||
serviceLabelDe: optionalTrimmedText,
|
|
||||||
clientName: optionalTrimmedText,
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,22 +1,71 @@
|
|||||||
import { cache } from "react";
|
|
||||||
|
|
||||||
import { and, asc, desc, eq } from "drizzle-orm";
|
import { and, asc, desc, eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
import { cache } from "react";
|
||||||
|
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import {
|
import { category, portfolioProject } from "@/lib/db/schema";
|
||||||
category as categoryTable,
|
import type { Category, PortfolioAsset, PortfolioProject, PortfolioSection } from "@/lib/db/schema";
|
||||||
portfolioAsset,
|
|
||||||
portfolioProject,
|
|
||||||
portfolioSection,
|
|
||||||
} from "@/lib/db/schema";
|
|
||||||
import type { PortfolioProjectViewMode } from "@/lib/db/enums";
|
import type { PortfolioProjectViewMode } from "@/lib/db/enums";
|
||||||
import { getPortfolioMediaBindings } from "@/lib/media";
|
import { getPortfolioMediaBindings } from "@/lib/media";
|
||||||
import type { AppLocale } from "@/lib/locale";
|
import type { AppLocale } from "@/lib/locale";
|
||||||
|
|
||||||
type CategoryRecord = typeof categoryTable.$inferSelect;
|
type CategoryRecord = Pick<
|
||||||
type SectionRecord = typeof portfolioSection.$inferSelect;
|
Category,
|
||||||
type AssetRecord = typeof portfolioAsset.$inferSelect;
|
| "id"
|
||||||
type ProjectRecord = typeof portfolioProject.$inferSelect;
|
| "slug"
|
||||||
|
| "nameAr"
|
||||||
|
| "nameEn"
|
||||||
|
| "nameDe"
|
||||||
|
| "descriptionAr"
|
||||||
|
| "descriptionEn"
|
||||||
|
| "descriptionDe"
|
||||||
|
| "sortOrder"
|
||||||
|
| "isActive"
|
||||||
|
>;
|
||||||
|
|
||||||
|
type SectionRecord = Pick<
|
||||||
|
PortfolioSection,
|
||||||
|
| "id"
|
||||||
|
| "type"
|
||||||
|
| "titleAr"
|
||||||
|
| "titleEn"
|
||||||
|
| "titleDe"
|
||||||
|
| "bodyAr"
|
||||||
|
| "bodyEn"
|
||||||
|
| "bodyDe"
|
||||||
|
| "imagePath"
|
||||||
|
| "linkUrl"
|
||||||
|
| "sortOrder"
|
||||||
|
>;
|
||||||
|
|
||||||
|
type AssetRecord = Pick<
|
||||||
|
PortfolioAsset,
|
||||||
|
"id" | "kind" | "filePath" | "altAr" | "altEn" | "altDe" | "sortOrder"
|
||||||
|
>;
|
||||||
|
|
||||||
|
type ProjectRecord = Pick<
|
||||||
|
PortfolioProject,
|
||||||
|
| "id"
|
||||||
|
| "slug"
|
||||||
|
| "viewMode"
|
||||||
|
| "titleAr"
|
||||||
|
| "titleEn"
|
||||||
|
| "titleDe"
|
||||||
|
| "summaryAr"
|
||||||
|
| "summaryEn"
|
||||||
|
| "summaryDe"
|
||||||
|
| "clientName"
|
||||||
|
| "projectYear"
|
||||||
|
| "serviceLabelAr"
|
||||||
|
| "serviceLabelEn"
|
||||||
|
| "serviceLabelDe"
|
||||||
|
| "previewUrl"
|
||||||
|
| "coverImagePath"
|
||||||
|
| "isFeatured"
|
||||||
|
| "isPublished"
|
||||||
|
| "publishedAt"
|
||||||
|
| "sortOrder"
|
||||||
|
>;
|
||||||
|
|
||||||
export type LocalizedContent = {
|
export type LocalizedContent = {
|
||||||
ar: string;
|
ar: string;
|
||||||
@@ -189,31 +238,35 @@ export function getLocalizedValue(
|
|||||||
|
|
||||||
export async function getAdminPortfolioCategories() {
|
export async function getAdminPortfolioCategories() {
|
||||||
const categories = await db.query.category.findMany({
|
const categories = await db.query.category.findMany({
|
||||||
orderBy: [asc(categoryTable.sortOrder), asc(categoryTable.createdAt)],
|
with: {
|
||||||
with: { projects: { columns: { id: true } } },
|
projects: {
|
||||||
|
columns: { id: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [asc(category.sortOrder), asc(category.createdAt)],
|
||||||
});
|
});
|
||||||
|
|
||||||
return categories.map((category) => ({
|
return categories.map((record) => ({
|
||||||
...mapCategory(category),
|
...mapCategory(record),
|
||||||
projectCount: category.projects.length,
|
projectCount: record.projects.length,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getActivePortfolioCategories() {
|
export async function getActivePortfolioCategories() {
|
||||||
const categories = await db.query.category.findMany({
|
const categories = await db.query.category.findMany({
|
||||||
where: eq(categoryTable.isActive, true),
|
where: eq(category.isActive, true),
|
||||||
orderBy: [asc(categoryTable.sortOrder), asc(categoryTable.createdAt)],
|
orderBy: [asc(category.sortOrder), asc(category.createdAt)],
|
||||||
});
|
});
|
||||||
|
|
||||||
return categories.map(mapCategory);
|
return categories.map(mapCategory);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getActivePortfolioCategoryBySlug(slug: string) {
|
export async function getActivePortfolioCategoryBySlug(slug: string) {
|
||||||
const category = await db.query.category.findFirst({
|
const record = await db.query.category.findFirst({
|
||||||
where: and(eq(categoryTable.slug, slug), eq(categoryTable.isActive, true)),
|
where: and(eq(category.slug, slug), eq(category.isActive, true)),
|
||||||
});
|
});
|
||||||
|
|
||||||
return category ? mapCategory(category) : null;
|
return record ? mapCategory(record) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAdminPortfolioProjects(filters?: {
|
export async function getAdminPortfolioProjects(filters?: {
|
||||||
@@ -221,20 +274,24 @@ export async function getAdminPortfolioProjects(filters?: {
|
|||||||
status?: "all" | "draft" | "published";
|
status?: "all" | "draft" | "published";
|
||||||
}) {
|
}) {
|
||||||
const conditions = [
|
const conditions = [
|
||||||
...(filters?.categoryId ? [eq(portfolioProject.categoryId, filters.categoryId)] : []),
|
filters?.categoryId ? eq(portfolioProject.categoryId, filters.categoryId) : undefined,
|
||||||
...(filters?.status === "draft"
|
filters?.status === "draft"
|
||||||
? [eq(portfolioProject.isPublished, false)]
|
? eq(portfolioProject.isPublished, false)
|
||||||
: filters?.status === "published"
|
: filters?.status === "published"
|
||||||
? [eq(portfolioProject.isPublished, true)]
|
? eq(portfolioProject.isPublished, true)
|
||||||
: []),
|
: undefined,
|
||||||
];
|
].filter(Boolean);
|
||||||
|
|
||||||
const projects = await db.query.portfolioProject.findMany({
|
const projects = await db.query.portfolioProject.findMany({
|
||||||
where: conditions.length ? and(...conditions) : undefined,
|
where: conditions.length ? and(...conditions) : undefined,
|
||||||
with: {
|
with: {
|
||||||
category: true,
|
category: true,
|
||||||
sections: { orderBy: [asc(portfolioSection.sortOrder), asc(portfolioSection.createdAt)] },
|
sections: {
|
||||||
assets: { orderBy: [asc(portfolioAsset.sortOrder), asc(portfolioAsset.createdAt)] },
|
orderBy: (section, { asc: ascOrder }) => [ascOrder(section.sortOrder), ascOrder(section.createdAt)],
|
||||||
|
},
|
||||||
|
assets: {
|
||||||
|
orderBy: (asset, { asc: ascOrder }) => [ascOrder(asset.sortOrder), ascOrder(asset.createdAt)],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
orderBy: [asc(portfolioProject.sortOrder), desc(portfolioProject.createdAt)],
|
orderBy: [asc(portfolioProject.sortOrder), desc(portfolioProject.createdAt)],
|
||||||
});
|
});
|
||||||
@@ -247,8 +304,12 @@ export async function getPublishedPortfolioProjects(filters?: { categorySlug?: s
|
|||||||
where: eq(portfolioProject.isPublished, true),
|
where: eq(portfolioProject.isPublished, true),
|
||||||
with: {
|
with: {
|
||||||
category: true,
|
category: true,
|
||||||
sections: { orderBy: [asc(portfolioSection.sortOrder), asc(portfolioSection.createdAt)] },
|
sections: {
|
||||||
assets: { orderBy: [asc(portfolioAsset.sortOrder), asc(portfolioAsset.createdAt)] },
|
orderBy: (section, { asc: ascOrder }) => [ascOrder(section.sortOrder), ascOrder(section.createdAt)],
|
||||||
|
},
|
||||||
|
assets: {
|
||||||
|
orderBy: (asset, { asc: ascOrder }) => [ascOrder(asset.sortOrder), ascOrder(asset.createdAt)],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
orderBy: [
|
orderBy: [
|
||||||
asc(portfolioProject.sortOrder),
|
asc(portfolioProject.sortOrder),
|
||||||
@@ -257,8 +318,6 @@ export async function getPublishedPortfolioProjects(filters?: { categorySlug?: s
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Prisma filtered on the related category (active + optional slug); the
|
|
||||||
// relational query filters the main table only, so narrow here.
|
|
||||||
return projects
|
return projects
|
||||||
.filter(
|
.filter(
|
||||||
(project) =>
|
(project) =>
|
||||||
@@ -273,8 +332,12 @@ export const getPublishedPortfolioProjectBySlug = cache(async function (slug: st
|
|||||||
where: and(eq(portfolioProject.slug, slug), eq(portfolioProject.isPublished, true)),
|
where: and(eq(portfolioProject.slug, slug), eq(portfolioProject.isPublished, true)),
|
||||||
with: {
|
with: {
|
||||||
category: true,
|
category: true,
|
||||||
sections: { orderBy: [asc(portfolioSection.sortOrder), asc(portfolioSection.createdAt)] },
|
sections: {
|
||||||
assets: { orderBy: [asc(portfolioAsset.sortOrder), asc(portfolioAsset.createdAt)] },
|
orderBy: (section, { asc: ascOrder }) => [ascOrder(section.sortOrder), ascOrder(section.createdAt)],
|
||||||
|
},
|
||||||
|
assets: {
|
||||||
|
orderBy: (asset, { asc: ascOrder }) => [ascOrder(asset.sortOrder), ascOrder(asset.createdAt)],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -285,37 +348,17 @@ export const getPublishedPortfolioProjectBySlug = cache(async function (slug: st
|
|||||||
return mapProject(project);
|
return mapProject(project);
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ResolvedPortfolioSlug =
|
|
||||||
| { kind: "category"; category: PortfolioCategoryView }
|
|
||||||
| { kind: "project"; project: PortfolioProjectView }
|
|
||||||
| null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves a `/portfolio/[slug]` segment to either a category or a project.
|
|
||||||
* Categories take precedence so `/portfolio/web` shows the category listing;
|
|
||||||
* a project slug only wins when no active category shares that slug.
|
|
||||||
*/
|
|
||||||
export async function resolvePortfolioSlug(slug: string): Promise<ResolvedPortfolioSlug> {
|
|
||||||
const category = await getActivePortfolioCategoryBySlug(slug);
|
|
||||||
if (category) {
|
|
||||||
return { kind: "category", category };
|
|
||||||
}
|
|
||||||
|
|
||||||
const project = await getPublishedPortfolioProjectBySlug(slug);
|
|
||||||
if (project) {
|
|
||||||
return { kind: "project", project };
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAdminPortfolioProjectById(id: string) {
|
export async function getAdminPortfolioProjectById(id: string) {
|
||||||
const project = await db.query.portfolioProject.findFirst({
|
const project = await db.query.portfolioProject.findFirst({
|
||||||
where: eq(portfolioProject.id, id),
|
where: eq(portfolioProject.id, id),
|
||||||
with: {
|
with: {
|
||||||
category: true,
|
category: true,
|
||||||
sections: { orderBy: [asc(portfolioSection.sortOrder), asc(portfolioSection.createdAt)] },
|
sections: {
|
||||||
assets: { orderBy: [asc(portfolioAsset.sortOrder), asc(portfolioAsset.createdAt)] },
|
orderBy: (section, { asc: ascOrder }) => [ascOrder(section.sortOrder), ascOrder(section.createdAt)],
|
||||||
|
},
|
||||||
|
assets: {
|
||||||
|
orderBy: (asset, { asc: ascOrder }) => [ascOrder(asset.sortOrder), ascOrder(asset.createdAt)],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
import { appLocales } from "../i18n/routing";
|
|
||||||
import type { SeoSettings } from "./seo-settings";
|
|
||||||
import type { SiteSettings, SiteSettingsMediaBindings } from "./site-settings";
|
|
||||||
|
|
||||||
export type SeoCheckStatus = "ok" | "warn" | "error";
|
|
||||||
|
|
||||||
export type SeoCheck = {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
status: SeoCheckStatus;
|
|
||||||
detail: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pure readiness checklist shown on the admin SEO page. Every input comes from
|
|
||||||
* the caller so the same function is testable without a database.
|
|
||||||
*/
|
|
||||||
export function buildSeoChecklist(input: {
|
|
||||||
seo: SeoSettings;
|
|
||||||
settings: SiteSettings;
|
|
||||||
bindings: SiteSettingsMediaBindings;
|
|
||||||
maintenanceEnabled: boolean;
|
|
||||||
publishedProjectCount: number;
|
|
||||||
sitemapEntryCount: number;
|
|
||||||
siteUrl: string;
|
|
||||||
}): SeoCheck[] {
|
|
||||||
const { seo, settings, bindings, maintenanceEnabled, publishedProjectCount, sitemapEntryCount, siteUrl } = input;
|
|
||||||
const checks: SeoCheck[] = [];
|
|
||||||
|
|
||||||
checks.push({
|
|
||||||
id: "indexing",
|
|
||||||
label: "Indexierung",
|
|
||||||
status: seo.allowIndexing && !maintenanceEnabled ? "ok" : "error",
|
|
||||||
detail: maintenanceEnabled
|
|
||||||
? "Wartungsmodus aktiv: robots.txt sperrt alles, Sitemap ist leer."
|
|
||||||
: seo.allowIndexing
|
|
||||||
? "Suchmaschinen duerfen die Seite indexieren."
|
|
||||||
: "Indexierung ist deaktiviert (noindex + robots disallow).",
|
|
||||||
});
|
|
||||||
|
|
||||||
checks.push({
|
|
||||||
id: "site-url",
|
|
||||||
label: "Oeffentliche URL",
|
|
||||||
status: /^https:\/\//.test(siteUrl) && !/localhost|127\.0\.0\.1/.test(siteUrl) ? "ok" : "warn",
|
|
||||||
detail: `Canonical Basis: ${siteUrl}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const locale of appLocales) {
|
|
||||||
const localeSettings = settings.locales[locale];
|
|
||||||
const descriptionLength = localeSettings.siteDescription.length;
|
|
||||||
const status: SeoCheckStatus =
|
|
||||||
descriptionLength === 0 ? "error" : descriptionLength < 50 || descriptionLength > 160 ? "warn" : "ok";
|
|
||||||
|
|
||||||
checks.push({
|
|
||||||
id: `description-${locale}`,
|
|
||||||
label: `Meta Description (${locale.toUpperCase()})`,
|
|
||||||
status,
|
|
||||||
detail:
|
|
||||||
descriptionLength === 0
|
|
||||||
? "Fehlt. Wird unter Settings > Localization gepflegt."
|
|
||||||
: `${descriptionLength} Zeichen (empfohlen 50-160).`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
checks.push({
|
|
||||||
id: "og-image",
|
|
||||||
label: "Standard Share Bild (OG)",
|
|
||||||
status: bindings.defaultOgImage ? "ok" : "warn",
|
|
||||||
detail: bindings.defaultOgImage
|
|
||||||
? "Gesetzt. Projekte nutzen ihr Cover, alle anderen Seiten dieses Bild."
|
|
||||||
: "Nicht gesetzt. Links ohne Vorschaubild. Unter Settings > Brand pflegen.",
|
|
||||||
});
|
|
||||||
|
|
||||||
checks.push({
|
|
||||||
id: "favicon",
|
|
||||||
label: "Favicon",
|
|
||||||
status: bindings.favicon ? "ok" : "warn",
|
|
||||||
detail: bindings.favicon ? "Gesetzt." : "Nicht gesetzt (Fallback-Icon wird generiert).",
|
|
||||||
});
|
|
||||||
|
|
||||||
checks.push({
|
|
||||||
id: "verification",
|
|
||||||
label: "Search Console / Bing",
|
|
||||||
status: seo.googleSiteVerification || seo.bingSiteVerification ? "ok" : "warn",
|
|
||||||
detail:
|
|
||||||
seo.googleSiteVerification || seo.bingSiteVerification
|
|
||||||
? "Verification Meta Tags werden ausgegeben."
|
|
||||||
: "Kein Verification Code hinterlegt.",
|
|
||||||
});
|
|
||||||
|
|
||||||
checks.push({
|
|
||||||
id: "structured-data",
|
|
||||||
label: "Strukturierte Daten (JSON-LD)",
|
|
||||||
status: seo.structuredDataName || settings.locales[settings.defaultLocale].siteName ? "ok" : "warn",
|
|
||||||
detail: `${seo.structuredDataType} + WebSite auf allen Seiten, CreativeWork pro Projekt.`,
|
|
||||||
});
|
|
||||||
|
|
||||||
checks.push({
|
|
||||||
id: "projects",
|
|
||||||
label: "Veroeffentlichte Projekte",
|
|
||||||
status: publishedProjectCount > 0 ? "ok" : "warn",
|
|
||||||
detail:
|
|
||||||
publishedProjectCount > 0
|
|
||||||
? `${publishedProjectCount} Projekt(e) in der Sitemap.`
|
|
||||||
: "Noch kein Projekt veroeffentlicht. Portfolio-Seiten sind leer.",
|
|
||||||
});
|
|
||||||
|
|
||||||
checks.push({
|
|
||||||
id: "sitemap",
|
|
||||||
label: "Sitemap",
|
|
||||||
status: sitemapEntryCount > 0 ? "ok" : maintenanceEnabled || !seo.allowIndexing ? "warn" : "error",
|
|
||||||
detail: `${sitemapEntryCount} URL(s) in /sitemap.xml (alle Sprachen, mit hreflang).`,
|
|
||||||
});
|
|
||||||
|
|
||||||
return checks;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function summarizeSeoChecklist(checks: SeoCheck[]) {
|
|
||||||
return {
|
|
||||||
ok: checks.filter((check) => check.status === "ok").length,
|
|
||||||
warn: checks.filter((check) => check.status === "warn").length,
|
|
||||||
error: checks.filter((check) => check.status === "error").length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
import type { AppLocale } from "./locale";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Site-wide SEO configuration stored as one JSON blob in `app_config`
|
|
||||||
* (key `seo_settings`). Everything here is pure: parsing/normalizing only.
|
|
||||||
* Read/write goes through `lib/app-config.ts`.
|
|
||||||
*/
|
|
||||||
export const SEO_SETTINGS_KEY = "seo_settings";
|
|
||||||
|
|
||||||
export type SeoStructuredDataType = "Person" | "Organization";
|
|
||||||
|
|
||||||
export type SeoLocaleSettings = {
|
|
||||||
/** Comma-separated keywords (optional, low SEO weight but harmless). */
|
|
||||||
keywords: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SeoSettings = {
|
|
||||||
/** Master switch: false → robots disallow all + `noindex` on every page. */
|
|
||||||
allowIndexing: boolean;
|
|
||||||
/** `google-site-verification` meta value. */
|
|
||||||
googleSiteVerification: string;
|
|
||||||
/** `msvalidate.01` meta value (Bing Webmaster). */
|
|
||||||
bingSiteVerification: string;
|
|
||||||
/** `@handle` used for twitter:site / twitter:creator. */
|
|
||||||
twitterHandle: string;
|
|
||||||
/** Publisher shape used for JSON-LD on the home page. */
|
|
||||||
structuredDataType: SeoStructuredDataType;
|
|
||||||
/** Name shown in JSON-LD (falls back to the site name when empty). */
|
|
||||||
structuredDataName: string;
|
|
||||||
/** Person job title / Organization tagline used in JSON-LD. */
|
|
||||||
structuredDataJobTitle: string;
|
|
||||||
/** Social profile URLs for `sameAs` in JSON-LD. */
|
|
||||||
sameAs: string[];
|
|
||||||
locales: Record<AppLocale, SeoLocaleSettings>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function buildDefaultSeoSettings(): SeoSettings {
|
|
||||||
return {
|
|
||||||
allowIndexing: true,
|
|
||||||
googleSiteVerification: "",
|
|
||||||
bingSiteVerification: "",
|
|
||||||
twitterHandle: "",
|
|
||||||
structuredDataType: "Person",
|
|
||||||
structuredDataName: "",
|
|
||||||
structuredDataJobTitle: "",
|
|
||||||
sameAs: [],
|
|
||||||
locales: {
|
|
||||||
ar: { keywords: "" },
|
|
||||||
en: { keywords: "" },
|
|
||||||
de: { keywords: "" },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeText(value: unknown, maxLength = 500): string {
|
|
||||||
return typeof value === "string" ? value.trim().slice(0, maxLength) : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Meta verification tokens are alphanumeric with `-` / `_`; anything else is dropped. */
|
|
||||||
export function normalizeVerificationToken(value: unknown): string {
|
|
||||||
const text = normalizeText(value, 200);
|
|
||||||
|
|
||||||
return /^[A-Za-z0-9_-]+$/.test(text) ? text : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeTwitterHandle(value: unknown): string {
|
|
||||||
const text = normalizeText(value, 60).replace(/^https?:\/\/(www\.)?(twitter|x)\.com\//i, "").replace(/^@+/, "");
|
|
||||||
|
|
||||||
return /^[A-Za-z0-9_]{1,15}$/.test(text) ? `@${text}` : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeSameAs(value: unknown): string[] {
|
|
||||||
const rawList = Array.isArray(value)
|
|
||||||
? value
|
|
||||||
: typeof value === "string"
|
|
||||||
? value.split(/[\n,]+/)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const urls = rawList
|
|
||||||
.map((entry) => normalizeText(entry, 500))
|
|
||||||
.filter((entry) => /^https:\/\/[^\s]+$/i.test(entry));
|
|
||||||
|
|
||||||
return Array.from(new Set(urls)).slice(0, 20);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeStructuredDataType(value: unknown): SeoStructuredDataType {
|
|
||||||
return value === "Organization" ? "Organization" : "Person";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeKeywords(value: unknown): string {
|
|
||||||
const text = normalizeText(value, 1000);
|
|
||||||
|
|
||||||
return text
|
|
||||||
.split(",")
|
|
||||||
.map((keyword) => keyword.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.slice(0, 30)
|
|
||||||
.join(", ");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseSeoSettingsValue(rawValue: string | null | undefined): SeoSettings {
|
|
||||||
const defaults = buildDefaultSeoSettings();
|
|
||||||
|
|
||||||
if (!rawValue) {
|
|
||||||
return defaults;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(rawValue) as Record<string, unknown>;
|
|
||||||
const locales =
|
|
||||||
parsed.locales && typeof parsed.locales === "object"
|
|
||||||
? (parsed.locales as Record<string, Record<string, unknown> | undefined>)
|
|
||||||
: {};
|
|
||||||
|
|
||||||
return {
|
|
||||||
allowIndexing: parsed.allowIndexing !== false,
|
|
||||||
googleSiteVerification: normalizeVerificationToken(parsed.googleSiteVerification),
|
|
||||||
bingSiteVerification: normalizeVerificationToken(parsed.bingSiteVerification),
|
|
||||||
twitterHandle: normalizeTwitterHandle(parsed.twitterHandle),
|
|
||||||
structuredDataType: normalizeStructuredDataType(parsed.structuredDataType),
|
|
||||||
structuredDataName: normalizeText(parsed.structuredDataName, 120),
|
|
||||||
structuredDataJobTitle: normalizeText(parsed.structuredDataJobTitle, 160),
|
|
||||||
sameAs: normalizeSameAs(parsed.sameAs),
|
|
||||||
locales: {
|
|
||||||
ar: { keywords: normalizeKeywords(locales.ar?.keywords) },
|
|
||||||
en: { keywords: normalizeKeywords(locales.en?.keywords) },
|
|
||||||
de: { keywords: normalizeKeywords(locales.de?.keywords) },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return defaults;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Map an app locale to the Open Graph `og:locale` format. */
|
|
||||||
export function toOpenGraphLocale(locale: AppLocale): string {
|
|
||||||
switch (locale) {
|
|
||||||
case "ar":
|
|
||||||
return "ar_AR";
|
|
||||||
case "en":
|
|
||||||
return "en_US";
|
|
||||||
default:
|
|
||||||
return "de_DE";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,7 @@ import { isManagedMediaFilePath, resolveMediaUploadPath } from "./media-storage"
|
|||||||
|
|
||||||
const INTERNAL_FAVICON_PATH = "/favicon.ico";
|
const INTERNAL_FAVICON_PATH = "/favicon.ico";
|
||||||
const INTERNAL_APPLE_ICON_PATH = "/apple-icon.png";
|
const INTERNAL_APPLE_ICON_PATH = "/apple-icon.png";
|
||||||
export const INTERNAL_MANIFEST_PATH = "/manifest.webmanifest";
|
const INTERNAL_MANIFEST_PATH = "/manifest.webmanifest";
|
||||||
const DEFAULT_ICON_VERSION = "default";
|
const DEFAULT_ICON_VERSION = "default";
|
||||||
const TRANSPARENT_PNG_BASE64 =
|
const TRANSPARENT_PNG_BASE64 =
|
||||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9sot5WQAAAAASUVORK5CYII=";
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9sot5WQAAAAASUVORK5CYII=";
|
||||||
|
|||||||
@@ -21,10 +21,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"comingSoon": {
|
"comingSoon": {
|
||||||
|
"badge": "تحديث مهني",
|
||||||
|
"kicker": "الموقع قيد إعادة البناء",
|
||||||
"titleLineOne": "الموقع",
|
"titleLineOne": "الموقع",
|
||||||
"titleLineTwo": "قيد التطوير",
|
"titleLineTwo": "قيد التطوير",
|
||||||
"titleLineThree": "وسيعود قريباً",
|
"titleLineThree": "وسيعود قريباً",
|
||||||
"description": "أعيد بناء الموقع ليعرض الخدمات، الأعمال المختارة، وطريقة التعاون بشكل مباشر ومنظم.",
|
"description": "أعيد بناء الموقع ليعرض الخدمات، الأعمال المختارة، وطريقة التعاون بشكل مباشر ومنظم.",
|
||||||
|
"primaryCta": "ابدأ مشروعاً",
|
||||||
|
"secondaryCta": "العودة للرئيسية",
|
||||||
|
"status": "قيد التطوير · يعود قريباً",
|
||||||
|
"countdownLabel": "الإطلاق خلال",
|
||||||
"unitDays": "أيام",
|
"unitDays": "أيام",
|
||||||
"unitHours": "ساعات",
|
"unitHours": "ساعات",
|
||||||
"unitMinutes": "دقائق",
|
"unitMinutes": "دقائق",
|
||||||
@@ -264,91 +270,9 @@
|
|||||||
},
|
},
|
||||||
"aboutPage": {
|
"aboutPage": {
|
||||||
"title": "من أنا",
|
"title": "من أنا",
|
||||||
"description": "أنا مطور Full-Stack ومصمم جرافيك مقيم في برلين. بتنقل من الفكرة للكود - هوية بصرية وتصميم واجهات والهندسة يلي بتطلعهم عالنور.",
|
"description": "نظرة مركزة على دراستي، دوري الحالي، ونوع شغل الواجهات الذي أقدمه.",
|
||||||
"heroEyebrow": "من أنا",
|
"heroEyebrow": "من أنا",
|
||||||
"story": {
|
"placeholder": "محتوى صفحة من أنا سينضاف هون قريباً."
|
||||||
"eyebrow": "مين أنا",
|
|
||||||
"title": "بابني منتجات وبصمم كيف بتبين وكيف حاسس فيها المستخدم.",
|
|
||||||
"paragraphs": [
|
|
||||||
"أغلب المطورين بسلموا التصميم لغيرهم، وأغلب المصممين بسلموا الكود لغيرهم. أنا بعمل الاثنين - يعني فجوة أقل بين شكل المنتج وطريقة اشتغاله فعلياً.",
|
|
||||||
"خلفيتي بتغطي الهوية البصرية والتصميم بقد ما بتغطي TypeScript وأنظمة Backend، فبصمم وأنا حاسب حساب التنفيذ، وببني وأنا محافظ على الحس البصري."
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"capabilities": {
|
|
||||||
"eyebrow": "شو بعمل",
|
|
||||||
"title": "تطوير وتصميم، بشخص واحد.",
|
|
||||||
"description": "اختصاصين، جهة تواصل وحدة - من الهوية البصرية لحتى الكود الجاهز للإنتاج.",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"title": "تطوير الواجهات",
|
|
||||||
"description": "واجهات Next.js وReact وTypeScript مبنية للسرعة والوضوح وقابلية الصيانة على المدى الطويل."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Backend والبيانات",
|
|
||||||
"description": "Node.js وPostgreSQL وPrisma - أنظمة موثوقة شغالة وراء الواجهة."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "تصميم بصري وهوية",
|
|
||||||
"description": "هوية وتصميم UI وأنظمة تخطيط مصممة بـ Figma وIllustrator."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "أتمتة وأدوات",
|
|
||||||
"description": "سير عمل داخلي وأدوات بتشيل الشغل اليدوي المتكرر."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"tools": {
|
|
||||||
"eyebrow": "الأدوات",
|
|
||||||
"title": "الأدوات اليومية",
|
|
||||||
"items": [
|
|
||||||
"Figma",
|
|
||||||
"Adobe Illustrator",
|
|
||||||
"Photoshop",
|
|
||||||
"Next.js",
|
|
||||||
"TypeScript",
|
|
||||||
"Tailwind CSS",
|
|
||||||
"Docker"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"process": {
|
|
||||||
"eyebrow": "كيف بشتغل",
|
|
||||||
"title": "آلية عمل بتخلي التصميم والتطوير ماشيين مع بعض.",
|
|
||||||
"description": "أربع خطوات، بلا فجوة بين شكل المنتج وطريقة بناءه.",
|
|
||||||
"steps": [
|
|
||||||
{
|
|
||||||
"title": "Discover",
|
|
||||||
"description": "بفهم المشكلة والجمهور والقيود قبل ما افتح أي أداة."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Design",
|
|
||||||
"description": "الاتجاه البصري وبنية الـ UX بيتحددوا مع بعض، مش الواحد بعد التاني."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Build",
|
|
||||||
"description": "بنفذ بنفس العناية يلي كانت بالتصميم - كود نظيف وجاهز للإنتاج."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Refine",
|
|
||||||
"description": "بصقل التفاصيل، بجرب النتيجة، وبطلق المنتج بثقة."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"personalNote": {
|
|
||||||
"eyebrow": "برا الشغل",
|
|
||||||
"text": "مقيم ببرلين. لما ما كون مصمم أو مبرمج، غالباً عم بحاول افهم الفرق بين الشغلتين."
|
|
||||||
},
|
|
||||||
"contactCta": {
|
|
||||||
"eyebrow": "ابدأ الحديث",
|
|
||||||
"title": "عندك مشروع محتاج تصميم وكود مع بعض؟",
|
|
||||||
"description": "هات الملخص أو السكتش الأولي أو بس المشكلة. أقدر أساعد أشكل الاتجاه وأصمم النظام وأبنيه.",
|
|
||||||
"contactCta": "تواصل معي",
|
|
||||||
"githubCta": "GitHub",
|
|
||||||
"emailLabel": "البريد الإلكتروني",
|
|
||||||
"emailValue": "hello@moh-sass.dev",
|
|
||||||
"availabilityLabel": "التوفر",
|
|
||||||
"availabilityValue": "مفتوح لشغل منتجات مركز",
|
|
||||||
"githubHref": "https://github.com/mohfarawati"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"contactPage": {
|
"contactPage": {
|
||||||
"title": "تواصل",
|
"title": "تواصل",
|
||||||
|
|||||||
@@ -21,10 +21,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"comingSoon": {
|
"comingSoon": {
|
||||||
|
"badge": "Professionelles Update",
|
||||||
|
"kicker": "Die Website wird neu aufgebaut",
|
||||||
"titleLineOne": "Website",
|
"titleLineOne": "Website",
|
||||||
"titleLineTwo": "in Bearbeitung",
|
"titleLineTwo": "in Bearbeitung",
|
||||||
"titleLineThree": "bald wieder online",
|
"titleLineThree": "bald wieder online",
|
||||||
"description": "Ich baue die Website neu auf, damit Leistungen, ausgewählte Arbeiten und Zusammenarbeit klarer, direkter und ohne Wiederholungen sichtbar werden.",
|
"description": "Ich baue die Website neu auf, damit Leistungen, ausgewählte Arbeiten und Zusammenarbeit klarer, direkter und ohne Wiederholungen sichtbar werden.",
|
||||||
|
"primaryCta": "Projekt starten",
|
||||||
|
"secondaryCta": "Zur Startseite",
|
||||||
|
"status": "In Entwicklung · bald zurück",
|
||||||
|
"countdownLabel": "Start in",
|
||||||
"unitDays": "Tage",
|
"unitDays": "Tage",
|
||||||
"unitHours": "Stunden",
|
"unitHours": "Stunden",
|
||||||
"unitMinutes": "Minuten",
|
"unitMinutes": "Minuten",
|
||||||
@@ -264,91 +270,9 @@
|
|||||||
},
|
},
|
||||||
"aboutPage": {
|
"aboutPage": {
|
||||||
"title": "Über mich",
|
"title": "Über mich",
|
||||||
"description": "Ich bin Full-Stack-Entwickler und Grafikdesigner mit Sitz in Berlin. Ich bewege mich von der Idee bis zum Code — visuelle Identität, Interface-Design und die Technik, die es live bringt.",
|
"description": "Ein fokussierter Überblick über meine Ausbildung, meine aktuelle Rolle und die Art von Frontend Arbeit, die ich liefere.",
|
||||||
"heroEyebrow": "Über mich",
|
"heroEyebrow": "Über mich",
|
||||||
"story": {
|
"placeholder": "Der Inhalt der About Seite kommt bald hier hin."
|
||||||
"eyebrow": "Wer ich bin",
|
|
||||||
"title": "Ich baue Produkte und gestalte, wie sie aussehen und sich anfühlen.",
|
|
||||||
"paragraphs": [
|
|
||||||
"Die meisten Entwickler geben Design ab. Die meisten Designer geben Code ab. Ich mache beides — dadurch gibt es weniger Lücken zwischen dem, wie ein Produkt aussieht, und dem, wie es tatsächlich funktioniert.",
|
|
||||||
"Mein Hintergrund reicht genauso weit in Marken- und visuelles Design wie in TypeScript und Backend-Systeme, deshalb gestalte ich mit Blick auf die Umsetzung und baue mit erhaltenem visuellem Urteilsvermögen."
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"capabilities": {
|
|
||||||
"eyebrow": "Was ich mache",
|
|
||||||
"title": "Entwicklung und Design, aus einer Hand.",
|
|
||||||
"description": "Zwei Disziplinen, ein Ansprechpartner — von der visuellen Identität bis zum produktionsreifen Code.",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"title": "Frontend-Entwicklung",
|
|
||||||
"description": "Next.js-, React- und TypeScript-Interfaces, gebaut für Geschwindigkeit, Klarheit und langfristige Wartbarkeit."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Backend & Daten",
|
|
||||||
"description": "Node.js, PostgreSQL und Prisma — verlässliche Systeme hinter dem Interface."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Visuelles & Marken-Design",
|
|
||||||
"description": "Identität, UI-Design und Layout-Systeme, gestaltet in Figma und Illustrator."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Automatisierung & Tooling",
|
|
||||||
"description": "Interne Workflows und Tools, die manuelle, sich wiederholende Arbeit entfernen."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"tools": {
|
|
||||||
"eyebrow": "Tools",
|
|
||||||
"title": "Täglicher Stack",
|
|
||||||
"items": [
|
|
||||||
"Figma",
|
|
||||||
"Adobe Illustrator",
|
|
||||||
"Photoshop",
|
|
||||||
"Next.js",
|
|
||||||
"TypeScript",
|
|
||||||
"Tailwind CSS",
|
|
||||||
"Docker"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"process": {
|
|
||||||
"eyebrow": "Wie ich arbeite",
|
|
||||||
"title": "Ein Prozess, der Design und Entwicklung im Takt hält.",
|
|
||||||
"description": "Vier Schritte, keine Übergabe-Lücke zwischen Aussehen und Umsetzung.",
|
|
||||||
"steps": [
|
|
||||||
{
|
|
||||||
"title": "Discover",
|
|
||||||
"description": "Problem, Zielgruppe und Rahmenbedingungen klären, bevor überhaupt ein Tool geöffnet wird."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Design",
|
|
||||||
"description": "Visuelle Richtung und UX-Struktur werden gemeinsam erarbeitet, nicht nacheinander."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Build",
|
|
||||||
"description": "Umsetzung mit derselben Sorgfalt wie im Design — sauberer, produktionsreifer Code."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Refine",
|
|
||||||
"description": "Details verfeinern, das Ergebnis testen und mit Zuversicht ausliefern."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"personalNote": {
|
|
||||||
"eyebrow": "Feierabend",
|
|
||||||
"text": "Ansässig in Berlin. Wenn ich nicht gerade designe oder code, untersuche ich wahrscheinlich den Unterschied zwischen beidem."
|
|
||||||
},
|
|
||||||
"contactCta": {
|
|
||||||
"eyebrow": "Gespräch starten",
|
|
||||||
"title": "Ein Projekt, das Design und Code gleichzeitig braucht?",
|
|
||||||
"description": "Bring das Briefing, die grobe Skizze oder einfach das Problem mit. Ich kann die Richtung formen, das System entwerfen und es bauen.",
|
|
||||||
"contactCta": "Kontakt aufnehmen",
|
|
||||||
"githubCta": "GitHub",
|
|
||||||
"emailLabel": "E-Mail",
|
|
||||||
"emailValue": "hello@moh-sass.dev",
|
|
||||||
"availabilityLabel": "Verfügbarkeit",
|
|
||||||
"availabilityValue": "Offen für fokussierte Produktarbeit",
|
|
||||||
"githubHref": "https://github.com/mohfarawati"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"contactPage": {
|
"contactPage": {
|
||||||
"title": "Kontakt",
|
"title": "Kontakt",
|
||||||
|
|||||||
@@ -21,10 +21,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"comingSoon": {
|
"comingSoon": {
|
||||||
|
"badge": "Professional update",
|
||||||
|
"kicker": "The site is being rebuilt",
|
||||||
"titleLineOne": "Website",
|
"titleLineOne": "Website",
|
||||||
"titleLineTwo": "under development",
|
"titleLineTwo": "under development",
|
||||||
"titleLineThree": "returning soon",
|
"titleLineThree": "returning soon",
|
||||||
"description": "I am rebuilding the site to present services, selected work, and collaboration details with sharper structure and less noise.",
|
"description": "I am rebuilding the site to present services, selected work, and collaboration details with sharper structure and less noise.",
|
||||||
|
"primaryCta": "Start a project",
|
||||||
|
"secondaryCta": "Back to homepage",
|
||||||
|
"status": "In development · back soon",
|
||||||
|
"countdownLabel": "Launching in",
|
||||||
"unitDays": "Days",
|
"unitDays": "Days",
|
||||||
"unitHours": "Hours",
|
"unitHours": "Hours",
|
||||||
"unitMinutes": "Minutes",
|
"unitMinutes": "Minutes",
|
||||||
@@ -264,91 +270,9 @@
|
|||||||
},
|
},
|
||||||
"aboutPage": {
|
"aboutPage": {
|
||||||
"title": "About",
|
"title": "About",
|
||||||
"description": "I'm a full-stack developer and graphic designer based in Berlin. I move from concept to code — visual identity, interface design, and the engineering that ships it.",
|
"description": "A focused overview of my education, current role, and the kind of frontend work I deliver.",
|
||||||
"heroEyebrow": "About",
|
"heroEyebrow": "About",
|
||||||
"story": {
|
"placeholder": "About page content will be added here soon."
|
||||||
"eyebrow": "Who I am",
|
|
||||||
"title": "I build products and design the way they look and feel.",
|
|
||||||
"paragraphs": [
|
|
||||||
"Most developers hand off design. Most designers hand off code. I do both — which means fewer gaps between how a product looks and how it actually works.",
|
|
||||||
"My background spans brand and visual design as much as it spans TypeScript and backend systems, so I design with implementation in mind and build with visual judgment intact."
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"capabilities": {
|
|
||||||
"eyebrow": "What I do",
|
|
||||||
"title": "Development and design, handled by the same person.",
|
|
||||||
"description": "Two disciplines, one point of contact — from visual identity to production code.",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"title": "Frontend Development",
|
|
||||||
"description": "Next.js, React, and TypeScript interfaces built for speed, clarity, and long-term maintainability."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Backend & Data",
|
|
||||||
"description": "Node.js, PostgreSQL, and Prisma — reliable systems working behind the interface."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Visual & Brand Design",
|
|
||||||
"description": "Identity, UI design, and layout systems crafted in Figma and Illustrator."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Automation & Tooling",
|
|
||||||
"description": "Internal workflows and tools that remove manual, repetitive work."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"tools": {
|
|
||||||
"eyebrow": "Tools",
|
|
||||||
"title": "Daily stack",
|
|
||||||
"items": [
|
|
||||||
"Figma",
|
|
||||||
"Adobe Illustrator",
|
|
||||||
"Photoshop",
|
|
||||||
"Next.js",
|
|
||||||
"TypeScript",
|
|
||||||
"Tailwind CSS",
|
|
||||||
"Docker"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"process": {
|
|
||||||
"eyebrow": "How I work",
|
|
||||||
"title": "A process that keeps design and engineering in sync.",
|
|
||||||
"description": "Four steps, no handoff gap between how it looks and how it's built.",
|
|
||||||
"steps": [
|
|
||||||
{
|
|
||||||
"title": "Discover",
|
|
||||||
"description": "Understand the problem, audience, and constraints before opening any tool."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Design",
|
|
||||||
"description": "Visual direction and UX structure worked out together, not in sequence."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Build",
|
|
||||||
"description": "Implement with the same care the design had — clean, production-ready code."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Refine",
|
|
||||||
"description": "Polish detail, test the result, and ship with confidence."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"personalNote": {
|
|
||||||
"eyebrow": "Off the clock",
|
|
||||||
"text": "Based in Berlin. When I'm not designing or coding, I'm probably studying the difference between the two."
|
|
||||||
},
|
|
||||||
"contactCta": {
|
|
||||||
"eyebrow": "Start the conversation",
|
|
||||||
"title": "Have a project that needs both design and code?",
|
|
||||||
"description": "Bring the brief, the rough sketch, or just the problem. I can shape the direction, design the system, and build it.",
|
|
||||||
"contactCta": "Contact me",
|
|
||||||
"githubCta": "GitHub",
|
|
||||||
"emailLabel": "Email",
|
|
||||||
"emailValue": "hello@moh-sass.dev",
|
|
||||||
"availabilityLabel": "Availability",
|
|
||||||
"availabilityValue": "Open for focused product work",
|
|
||||||
"githubHref": "https://github.com/mohfarawati"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"contactPage": {
|
"contactPage": {
|
||||||
"title": "Contact",
|
"title": "Contact",
|
||||||
|
|||||||
@@ -3,19 +3,19 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "git config core.hooksPath .githooks || true",
|
"dev": "next dev",
|
||||||
"dev": "next dev -p 3014",
|
|
||||||
"build": "next build --webpack",
|
"build": "next build --webpack",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:push": "drizzle-kit push",
|
"db:push": "drizzle-kit push",
|
||||||
"db:studio": "drizzle-kit studio"
|
"db:studio": "drizzle-kit studio",
|
||||||
|
"db:seed": "tsx lib/db/seed.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@paralleldrive/cuid2": "^2.2.2",
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"@types/nodemailer": "^7.0.11",
|
"@types/nodemailer": "^7.0.11",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.44.5",
|
||||||
"framer-motion": "^12.35.0",
|
"framer-motion": "^12.35.0",
|
||||||
"gsap": "^3.15.0",
|
"gsap": "^3.15.0",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
@@ -33,8 +33,7 @@
|
|||||||
"next-intl": "^4.8.3",
|
"next-intl": "^4.8.3",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"nodemailer": "^8.0.1",
|
"nodemailer": "^8.0.1",
|
||||||
"pg": "^8.20.0",
|
"postgres": "^3.4.5",
|
||||||
"postgres": "^3.4.9",
|
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-hook-form": "^7.71.2",
|
"react-hook-form": "^7.71.2",
|
||||||
@@ -42,22 +41,16 @@
|
|||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@electric-sql/pglite": "^0.5.4",
|
|
||||||
"@testing-library/dom": "^10.4.1",
|
|
||||||
"@testing-library/jest-dom": "^7.0.0",
|
|
||||||
"@testing-library/react": "^16.3.2",
|
|
||||||
"@testing-library/user-event": "^14.6.3",
|
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/pg": "^8.18.0",
|
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.4",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.4",
|
||||||
"eslint-config-next": "^16.1.6",
|
"eslint-config-next": "^16.1.6",
|
||||||
"jsdom": "^30.0.1",
|
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
isLegacyAdminPath,
|
isLegacyAdminPath,
|
||||||
toInternalAdminPath,
|
toInternalAdminPath,
|
||||||
} from "./lib/admin-routing";
|
} from "./lib/admin-routing";
|
||||||
import { ADMIN_SESSION_COOKIE, verifyAdminSessionToken } from "./lib/admin-session-token";
|
|
||||||
import {
|
import {
|
||||||
FALLBACK_LOCALE,
|
FALLBACK_LOCALE,
|
||||||
getLocalizedPathWithDefault,
|
getLocalizedPathWithDefault,
|
||||||
@@ -24,6 +23,8 @@ import {
|
|||||||
stripLocalePrefix,
|
stripLocalePrefix,
|
||||||
} from "./lib/locale";
|
} from "./lib/locale";
|
||||||
|
|
||||||
|
const ADMIN_SESSION_COOKIE = "moh_admin_session";
|
||||||
|
|
||||||
type SiteRuntimeState = {
|
type SiteRuntimeState = {
|
||||||
defaultLocale: (typeof appLocales)[number];
|
defaultLocale: (typeof appLocales)[number];
|
||||||
maintenanceEnabled: boolean;
|
maintenanceEnabled: boolean;
|
||||||
@@ -219,7 +220,7 @@ export default async function middleware(request: NextRequest) {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
siteRuntimeState.maintenanceEnabled &&
|
siteRuntimeState.maintenanceEnabled &&
|
||||||
!verifyAdminSessionToken(request.cookies.get(ADMIN_SESSION_COOKIE)?.value) &&
|
!request.cookies.has(ADMIN_SESSION_COOKIE) &&
|
||||||
!isComingSoonPath(pathname)
|
!isComingSoonPath(pathname)
|
||||||
) {
|
) {
|
||||||
const locale = getPathLocale(pathname, configuredDefaultLocale);
|
const locale = getPathLocale(pathname, configuredDefaultLocale);
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 103 KiB |
@@ -1,605 +0,0 @@
|
|||||||
const { PrismaPg } = require("@prisma/adapter-pg");
|
|
||||||
const { PrismaClient } = require("@prisma/client");
|
|
||||||
const { Pool } = require("pg");
|
|
||||||
|
|
||||||
const connectionString =
|
|
||||||
process.env.DATABASE_URL ||
|
|
||||||
"postgresql://postgres:postgres@localhost:5432/moh_sass?schema=public";
|
|
||||||
|
|
||||||
const pool = new Pool({ connectionString });
|
|
||||||
const prisma = new PrismaClient({ adapter: new PrismaPg(pool) });
|
|
||||||
|
|
||||||
async function upsertMediaAsset(input) {
|
|
||||||
const existing = await prisma.mediaAsset.findFirst({
|
|
||||||
where: {
|
|
||||||
label: input.label,
|
|
||||||
url: input.url,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
return prisma.mediaAsset.update({
|
|
||||||
where: { id: existing.id },
|
|
||||||
data: input,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return prisma.mediaAsset.create({
|
|
||||||
data: input,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function syncProjectContent(projectId, sections, assets) {
|
|
||||||
await prisma.portfolioSection.deleteMany({
|
|
||||||
where: { projectId },
|
|
||||||
});
|
|
||||||
|
|
||||||
await prisma.portfolioAsset.deleteMany({
|
|
||||||
where: { projectId },
|
|
||||||
});
|
|
||||||
|
|
||||||
const createdSections = [];
|
|
||||||
|
|
||||||
for (const section of sections) {
|
|
||||||
const createdSection = await prisma.portfolioSection.create({
|
|
||||||
data: {
|
|
||||||
projectId,
|
|
||||||
...section,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
createdSections.push(createdSection);
|
|
||||||
}
|
|
||||||
|
|
||||||
const createdAssets = [];
|
|
||||||
|
|
||||||
for (const asset of assets) {
|
|
||||||
const createdAsset = await prisma.portfolioAsset.create({
|
|
||||||
data: {
|
|
||||||
projectId,
|
|
||||||
...asset,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
createdAssets.push(createdAsset);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { createdSections, createdAssets };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function syncProjectMediaUsages(projectId, mediaMap) {
|
|
||||||
await prisma.mediaUsage.deleteMany({
|
|
||||||
where: {
|
|
||||||
entityType: "portfolio-project",
|
|
||||||
entityId: projectId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const usages = [];
|
|
||||||
|
|
||||||
if (mediaMap.coverAssetId) {
|
|
||||||
usages.push({
|
|
||||||
assetId: mediaMap.coverAssetId,
|
|
||||||
usageType: "PORTFOLIO_COVER",
|
|
||||||
entityType: "portfolio-project",
|
|
||||||
entityId: projectId,
|
|
||||||
fieldKey: "cover",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const sectionUsage of mediaMap.sectionUsages) {
|
|
||||||
usages.push({
|
|
||||||
assetId: sectionUsage.assetId,
|
|
||||||
usageType: "PORTFOLIO_SECTION",
|
|
||||||
entityType: "portfolio-project",
|
|
||||||
entityId: projectId,
|
|
||||||
fieldKey: sectionUsage.fieldKey,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const assetUsage of mediaMap.assetUsages) {
|
|
||||||
usages.push({
|
|
||||||
assetId: assetUsage.assetId,
|
|
||||||
usageType: "PORTFOLIO_ASSET",
|
|
||||||
entityType: "portfolio-project",
|
|
||||||
entityId: projectId,
|
|
||||||
fieldKey: assetUsage.fieldKey,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (usages.length > 0) {
|
|
||||||
await prisma.mediaUsage.createMany({ data: usages });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
await prisma.mediaUsage.deleteMany({
|
|
||||||
where: {
|
|
||||||
entityType: "portfolio-project",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await prisma.portfolioSection.deleteMany();
|
|
||||||
await prisma.portfolioAsset.deleteMany();
|
|
||||||
await prisma.portfolioProject.deleteMany();
|
|
||||||
await prisma.category.deleteMany();
|
|
||||||
|
|
||||||
await prisma.appConfig.upsert({
|
|
||||||
where: { key: "siteName" },
|
|
||||||
update: { value: "moh-sass" },
|
|
||||||
create: { key: "siteName", value: "moh-sass" },
|
|
||||||
});
|
|
||||||
|
|
||||||
await prisma.appConfig.upsert({
|
|
||||||
where: { key: "site_settings" },
|
|
||||||
update: {
|
|
||||||
value: JSON.stringify({
|
|
||||||
titleTemplate: "{pageTitle} | moh-sass",
|
|
||||||
locales: {
|
|
||||||
ar: {
|
|
||||||
siteName: "moh-sass",
|
|
||||||
titleTemplate: "{pageTitle} | {siteName}",
|
|
||||||
siteDescription: "Multilingual Next.js base project",
|
|
||||||
subhead: "",
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
siteName: "moh-sass",
|
|
||||||
titleTemplate: "{pageTitle} | {siteName}",
|
|
||||||
siteDescription: "Multilingual Next.js base project",
|
|
||||||
subhead: "",
|
|
||||||
},
|
|
||||||
de: {
|
|
||||||
siteName: "moh-sass",
|
|
||||||
titleTemplate: "{pageTitle} | {siteName}",
|
|
||||||
siteDescription: "Multilingual Next.js base project",
|
|
||||||
subhead: "",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
key: "site_settings",
|
|
||||||
value: JSON.stringify({
|
|
||||||
titleTemplate: "{pageTitle} | moh-sass",
|
|
||||||
locales: {
|
|
||||||
ar: {
|
|
||||||
siteName: "moh-sass",
|
|
||||||
titleTemplate: "{pageTitle} | {siteName}",
|
|
||||||
siteDescription: "Multilingual Next.js base project",
|
|
||||||
subhead: "",
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
siteName: "moh-sass",
|
|
||||||
titleTemplate: "{pageTitle} | {siteName}",
|
|
||||||
siteDescription: "Multilingual Next.js base project",
|
|
||||||
subhead: "",
|
|
||||||
},
|
|
||||||
de: {
|
|
||||||
siteName: "moh-sass",
|
|
||||||
titleTemplate: "{pageTitle} | {siteName}",
|
|
||||||
siteDescription: "Multilingual Next.js base project",
|
|
||||||
subhead: "",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const brandCategory = await prisma.category.upsert({
|
|
||||||
where: { slug: "branding" },
|
|
||||||
update: {
|
|
||||||
nameAr: "الهوية البصرية",
|
|
||||||
nameEn: "Branding",
|
|
||||||
nameDe: "Branding",
|
|
||||||
descriptionAr: "مشاريع هوية بصرية وشعارات وأنظمة علامة.",
|
|
||||||
descriptionEn: "Brand identity, logo, and design system work.",
|
|
||||||
descriptionDe: "Branding, Logos und visuelle Systeme.",
|
|
||||||
sortOrder: 1,
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
slug: "branding",
|
|
||||||
nameAr: "الهوية البصرية",
|
|
||||||
nameEn: "Branding",
|
|
||||||
nameDe: "Branding",
|
|
||||||
descriptionAr: "مشاريع هوية بصرية وشعارات وأنظمة علامة.",
|
|
||||||
descriptionEn: "Brand identity, logo, and design system work.",
|
|
||||||
descriptionDe: "Branding, Logos und visuelle Systeme.",
|
|
||||||
sortOrder: 1,
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const webCategory = await prisma.category.upsert({
|
|
||||||
where: { slug: "web-experiences" },
|
|
||||||
update: {
|
|
||||||
nameAr: "تجارب الويب",
|
|
||||||
nameEn: "Web Experiences",
|
|
||||||
nameDe: "Web Experiences",
|
|
||||||
descriptionAr: "مواقع وصفحات هبوط وتجارب رقمية سريعة.",
|
|
||||||
descriptionEn: "Websites, landing pages, and digital experiences.",
|
|
||||||
descriptionDe: "Webseiten, Landingpages und digitale Erlebnisse.",
|
|
||||||
sortOrder: 2,
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
slug: "web-experiences",
|
|
||||||
nameAr: "تجارب الويب",
|
|
||||||
nameEn: "Web Experiences",
|
|
||||||
nameDe: "Web Experiences",
|
|
||||||
descriptionAr: "مواقع وصفحات هبوط وتجارب رقمية سريعة.",
|
|
||||||
descriptionEn: "Websites, landing pages, and digital experiences.",
|
|
||||||
descriptionDe: "Webseiten, Landingpages und digitale Erlebnisse.",
|
|
||||||
sortOrder: 2,
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const commerceCategory = await prisma.category.upsert({
|
|
||||||
where: { slug: "commerce" },
|
|
||||||
update: {
|
|
||||||
nameAr: "التجارة الرقمية",
|
|
||||||
nameEn: "Commerce",
|
|
||||||
nameDe: "Commerce",
|
|
||||||
descriptionAr: "متاجر وتجارب شراء رقمية مع تركيز على الوضوح والتحويل.",
|
|
||||||
descriptionEn: "Commerce experiences with a focus on clarity and conversion.",
|
|
||||||
descriptionDe: "Commerce-Projekte mit Fokus auf Klarheit und Conversion.",
|
|
||||||
sortOrder: 3,
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
slug: "commerce",
|
|
||||||
nameAr: "التجارة الرقمية",
|
|
||||||
nameEn: "Commerce",
|
|
||||||
nameDe: "Commerce",
|
|
||||||
descriptionAr: "متاجر وتجارب شراء رقمية مع تركيز على الوضوح والتحويل.",
|
|
||||||
descriptionEn: "Commerce experiences with a focus on clarity and conversion.",
|
|
||||||
descriptionDe: "Commerce-Projekte mit Fokus auf Klarheit und Conversion.",
|
|
||||||
sortOrder: 3,
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const gridCover = await upsertMediaAsset({
|
|
||||||
source: "UPLOAD",
|
|
||||||
kind: "IMAGE",
|
|
||||||
url: "/uploads/portfolio/demo-cover.svg",
|
|
||||||
fileName: "demo-cover.svg",
|
|
||||||
label: "Portfolio Grid Cover",
|
|
||||||
altText: "Portfolio Grid Cover",
|
|
||||||
mimeType: "image/svg+xml",
|
|
||||||
size: 1024,
|
|
||||||
});
|
|
||||||
|
|
||||||
const storyCover = await upsertMediaAsset({
|
|
||||||
source: "UPLOAD",
|
|
||||||
kind: "IMAGE",
|
|
||||||
url: "/uploads/portfolio/demo-cover.svg",
|
|
||||||
fileName: "demo-cover.svg",
|
|
||||||
label: "Portfolio Story Cover",
|
|
||||||
altText: "Portfolio Story Cover",
|
|
||||||
mimeType: "image/svg+xml",
|
|
||||||
size: 1024,
|
|
||||||
});
|
|
||||||
|
|
||||||
const caseStudyCover = await upsertMediaAsset({
|
|
||||||
source: "UPLOAD",
|
|
||||||
kind: "IMAGE",
|
|
||||||
url: "/uploads/portfolio/demo-cover.svg",
|
|
||||||
fileName: "demo-cover.svg",
|
|
||||||
label: "Portfolio Case Study Cover",
|
|
||||||
altText: "Portfolio Case Study Cover",
|
|
||||||
mimeType: "image/svg+xml",
|
|
||||||
size: 1024,
|
|
||||||
});
|
|
||||||
|
|
||||||
const projects = [
|
|
||||||
{
|
|
||||||
slug: "grid-product-launch",
|
|
||||||
categoryId: commerceCategory.id,
|
|
||||||
viewMode: "GRID",
|
|
||||||
titleAr: "إطلاق منتج رقمي",
|
|
||||||
titleEn: "Grid Product Launch",
|
|
||||||
titleDe: "Grid Product Launch",
|
|
||||||
summaryAr: "مثال عرض شبكي لمشروع سريع مع أقسام قصيرة وأصول داعمة.",
|
|
||||||
summaryEn: "Grid view example for a fast product launch page.",
|
|
||||||
summaryDe: "Grid-Ansicht als Beispiel fuer einen schnellen Produktlaunch.",
|
|
||||||
clientName: "Launch Studio",
|
|
||||||
projectYear: 2026,
|
|
||||||
serviceLabelAr: "تجربة إطلاق",
|
|
||||||
serviceLabelEn: "Launch Experience",
|
|
||||||
serviceLabelDe: "Launch Experience",
|
|
||||||
previewUrl: "https://example.com/preview/grid-product-launch",
|
|
||||||
coverImagePath: gridCover.url,
|
|
||||||
isFeatured: true,
|
|
||||||
isPublished: true,
|
|
||||||
publishedAt: new Date("2026-01-12T09:00:00.000Z"),
|
|
||||||
sortOrder: 1,
|
|
||||||
coverAssetId: gridCover.id,
|
|
||||||
sections: [
|
|
||||||
{
|
|
||||||
type: "RICH_TEXT",
|
|
||||||
titleAr: "الفكرة",
|
|
||||||
titleEn: "Concept",
|
|
||||||
titleDe: "Konzept",
|
|
||||||
bodyAr: "واجهة سريعة لعرض المنتج والتركيز على الرسالة الأساسية.",
|
|
||||||
bodyEn: "A fast modular presentation focused on the main launch message.",
|
|
||||||
bodyDe: "Eine schnelle modulare Darstellung mit Fokus auf die Hauptbotschaft.",
|
|
||||||
imagePath: null,
|
|
||||||
linkUrl: null,
|
|
||||||
sortOrder: 0,
|
|
||||||
mediaAssetId: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "GALLERY",
|
|
||||||
titleAr: "الصورة الرئيسية",
|
|
||||||
titleEn: "Hero Visual",
|
|
||||||
titleDe: "Hero Visual",
|
|
||||||
bodyAr: "",
|
|
||||||
bodyEn: "",
|
|
||||||
bodyDe: "",
|
|
||||||
imagePath: gridCover.url,
|
|
||||||
linkUrl: null,
|
|
||||||
sortOrder: 1,
|
|
||||||
mediaAssetId: gridCover.id,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
assets: [
|
|
||||||
{
|
|
||||||
kind: "IMAGE",
|
|
||||||
filePath: gridCover.url,
|
|
||||||
altAr: "غلاف مشروع Grid",
|
|
||||||
altEn: "Grid project cover",
|
|
||||||
altDe: "Grid Projekt Cover",
|
|
||||||
sortOrder: 0,
|
|
||||||
mediaAssetId: gridCover.id,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
slug: "campaign-site",
|
|
||||||
categoryId: webCategory.id,
|
|
||||||
viewMode: "STORY",
|
|
||||||
titleAr: "موقع حملة",
|
|
||||||
titleEn: "Campaign Site",
|
|
||||||
titleDe: "Campaign Site",
|
|
||||||
summaryAr: "مثال عرض قصصي لمشروع ويب مع تسلسل سردي أوضح.",
|
|
||||||
summaryEn: "Story view example for a launch campaign website.",
|
|
||||||
summaryDe: "Story-Ansicht als Beispiel fuer eine Kampagnenseite.",
|
|
||||||
clientName: "Launch Client",
|
|
||||||
projectYear: 2024,
|
|
||||||
serviceLabelAr: "موقع تسويقي",
|
|
||||||
serviceLabelEn: "Marketing Website",
|
|
||||||
serviceLabelDe: "Marketing Website",
|
|
||||||
previewUrl: "https://example.com/preview/campaign-site",
|
|
||||||
coverImagePath: storyCover.url,
|
|
||||||
isFeatured: false,
|
|
||||||
isPublished: true,
|
|
||||||
publishedAt: new Date("2024-09-05T09:00:00.000Z"),
|
|
||||||
sortOrder: 2,
|
|
||||||
coverAssetId: storyCover.id,
|
|
||||||
sections: [
|
|
||||||
{
|
|
||||||
type: "RICH_TEXT",
|
|
||||||
titleAr: "السياق",
|
|
||||||
titleEn: "Context",
|
|
||||||
titleDe: "Kontext",
|
|
||||||
bodyAr: "الحملة احتاجت صفحة مرنة وسريعة تتبدل بين أكثر من مرحلة.",
|
|
||||||
bodyEn: "The campaign needed a flexible page that could adapt across phases.",
|
|
||||||
bodyDe: "Die Kampagne brauchte eine flexible Seite fuer mehrere Phasen.",
|
|
||||||
imagePath: null,
|
|
||||||
linkUrl: null,
|
|
||||||
sortOrder: 0,
|
|
||||||
mediaAssetId: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "GALLERY",
|
|
||||||
titleAr: "العرض البصري",
|
|
||||||
titleEn: "Visual Flow",
|
|
||||||
titleDe: "Visueller Ablauf",
|
|
||||||
bodyAr: "",
|
|
||||||
bodyEn: "",
|
|
||||||
bodyDe: "",
|
|
||||||
imagePath: storyCover.url,
|
|
||||||
linkUrl: null,
|
|
||||||
sortOrder: 1,
|
|
||||||
mediaAssetId: storyCover.id,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "LINK",
|
|
||||||
titleAr: "المعاينة",
|
|
||||||
titleEn: "Preview",
|
|
||||||
titleDe: "Vorschau",
|
|
||||||
bodyAr: "رابط العرض المباشر.",
|
|
||||||
bodyEn: "Direct preview link.",
|
|
||||||
bodyDe: "Direkter Vorschau-Link.",
|
|
||||||
imagePath: null,
|
|
||||||
linkUrl: "https://example.com/preview/campaign-site",
|
|
||||||
sortOrder: 2,
|
|
||||||
mediaAssetId: null,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
assets: [
|
|
||||||
{
|
|
||||||
kind: "IMAGE",
|
|
||||||
filePath: storyCover.url,
|
|
||||||
altAr: "غلاف مشروع Story",
|
|
||||||
altEn: "Story project cover",
|
|
||||||
altDe: "Story Projekt Cover",
|
|
||||||
sortOrder: 0,
|
|
||||||
mediaAssetId: storyCover.id,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
slug: "brand-redesign",
|
|
||||||
categoryId: brandCategory.id,
|
|
||||||
viewMode: "CASE_STUDY",
|
|
||||||
titleAr: "إعادة تصميم الهوية",
|
|
||||||
titleEn: "Brand Redesign",
|
|
||||||
titleDe: "Brand Redesign",
|
|
||||||
summaryAr: "مثال عرض دراسة حالة يركز على التحدي والحل والنتيجة.",
|
|
||||||
summaryEn: "Case study example focused on challenge, solution, and outcome.",
|
|
||||||
summaryDe: "Case-Study-Ansicht mit Fokus auf Herausforderung, Loesung und Ergebnis.",
|
|
||||||
clientName: "Studio Client",
|
|
||||||
projectYear: 2025,
|
|
||||||
serviceLabelAr: "هوية بصرية",
|
|
||||||
serviceLabelEn: "Brand Identity",
|
|
||||||
serviceLabelDe: "Brand Identity",
|
|
||||||
previewUrl: "https://example.com/preview/brand-redesign",
|
|
||||||
coverImagePath: caseStudyCover.url,
|
|
||||||
isFeatured: true,
|
|
||||||
isPublished: true,
|
|
||||||
publishedAt: new Date("2025-01-10T09:00:00.000Z"),
|
|
||||||
sortOrder: 3,
|
|
||||||
coverAssetId: caseStudyCover.id,
|
|
||||||
sections: [
|
|
||||||
{
|
|
||||||
type: "RICH_TEXT",
|
|
||||||
titleAr: "التحدي",
|
|
||||||
titleEn: "Challenge",
|
|
||||||
titleDe: "Herausforderung",
|
|
||||||
bodyAr: "كان المطلوب تحديث الهوية بدون خسارة التعرف البصري الحالي.",
|
|
||||||
bodyEn: "The brief required a refreshed identity without losing recognition.",
|
|
||||||
bodyDe: "Die Marke sollte modernisiert werden, ohne die Wiedererkennbarkeit zu verlieren.",
|
|
||||||
imagePath: null,
|
|
||||||
linkUrl: null,
|
|
||||||
sortOrder: 0,
|
|
||||||
mediaAssetId: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "RICH_TEXT",
|
|
||||||
titleAr: "الحل",
|
|
||||||
titleEn: "Solution",
|
|
||||||
titleDe: "Loesung",
|
|
||||||
bodyAr: "تم بناء نظام مرئي أوضح مع قواعد استخدام قابلة للتوسع.",
|
|
||||||
bodyEn: "A clearer visual system with scalable usage rules was created.",
|
|
||||||
bodyDe: "Es wurde ein klareres visuelles System mit skalierbaren Regeln aufgebaut.",
|
|
||||||
imagePath: null,
|
|
||||||
linkUrl: null,
|
|
||||||
sortOrder: 1,
|
|
||||||
mediaAssetId: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "GALLERY",
|
|
||||||
titleAr: "التنفيذ البصري",
|
|
||||||
titleEn: "Visual Execution",
|
|
||||||
titleDe: "Visuelle Umsetzung",
|
|
||||||
bodyAr: "",
|
|
||||||
bodyEn: "",
|
|
||||||
bodyDe: "",
|
|
||||||
imagePath: caseStudyCover.url,
|
|
||||||
linkUrl: null,
|
|
||||||
sortOrder: 2,
|
|
||||||
mediaAssetId: caseStudyCover.id,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
assets: [
|
|
||||||
{
|
|
||||||
kind: "IMAGE",
|
|
||||||
filePath: caseStudyCover.url,
|
|
||||||
altAr: "غلاف مشروع Case Study",
|
|
||||||
altEn: "Case study project cover",
|
|
||||||
altDe: "Case Study Projekt Cover",
|
|
||||||
sortOrder: 0,
|
|
||||||
mediaAssetId: caseStudyCover.id,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const projectConfig of projects) {
|
|
||||||
const project = await prisma.portfolioProject.upsert({
|
|
||||||
where: { slug: projectConfig.slug },
|
|
||||||
update: {
|
|
||||||
categoryId: projectConfig.categoryId,
|
|
||||||
viewMode: projectConfig.viewMode,
|
|
||||||
titleAr: projectConfig.titleAr,
|
|
||||||
titleEn: projectConfig.titleEn,
|
|
||||||
titleDe: projectConfig.titleDe,
|
|
||||||
summaryAr: projectConfig.summaryAr,
|
|
||||||
summaryEn: projectConfig.summaryEn,
|
|
||||||
summaryDe: projectConfig.summaryDe,
|
|
||||||
clientName: projectConfig.clientName,
|
|
||||||
projectYear: projectConfig.projectYear,
|
|
||||||
serviceLabelAr: projectConfig.serviceLabelAr,
|
|
||||||
serviceLabelEn: projectConfig.serviceLabelEn,
|
|
||||||
serviceLabelDe: projectConfig.serviceLabelDe,
|
|
||||||
previewUrl: projectConfig.previewUrl,
|
|
||||||
coverImagePath: projectConfig.coverImagePath,
|
|
||||||
isFeatured: projectConfig.isFeatured,
|
|
||||||
isPublished: projectConfig.isPublished,
|
|
||||||
publishedAt: projectConfig.publishedAt,
|
|
||||||
sortOrder: projectConfig.sortOrder,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
slug: projectConfig.slug,
|
|
||||||
categoryId: projectConfig.categoryId,
|
|
||||||
viewMode: projectConfig.viewMode,
|
|
||||||
titleAr: projectConfig.titleAr,
|
|
||||||
titleEn: projectConfig.titleEn,
|
|
||||||
titleDe: projectConfig.titleDe,
|
|
||||||
summaryAr: projectConfig.summaryAr,
|
|
||||||
summaryEn: projectConfig.summaryEn,
|
|
||||||
summaryDe: projectConfig.summaryDe,
|
|
||||||
clientName: projectConfig.clientName,
|
|
||||||
projectYear: projectConfig.projectYear,
|
|
||||||
serviceLabelAr: projectConfig.serviceLabelAr,
|
|
||||||
serviceLabelEn: projectConfig.serviceLabelEn,
|
|
||||||
serviceLabelDe: projectConfig.serviceLabelDe,
|
|
||||||
previewUrl: projectConfig.previewUrl,
|
|
||||||
coverImagePath: projectConfig.coverImagePath,
|
|
||||||
isFeatured: projectConfig.isFeatured,
|
|
||||||
isPublished: projectConfig.isPublished,
|
|
||||||
publishedAt: projectConfig.publishedAt,
|
|
||||||
sortOrder: projectConfig.sortOrder,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const created = await syncProjectContent(project.id, projectConfig.sections.map((section) => ({
|
|
||||||
type: section.type,
|
|
||||||
titleAr: section.titleAr,
|
|
||||||
titleEn: section.titleEn,
|
|
||||||
titleDe: section.titleDe,
|
|
||||||
bodyAr: section.bodyAr,
|
|
||||||
bodyEn: section.bodyEn,
|
|
||||||
bodyDe: section.bodyDe,
|
|
||||||
imagePath: section.imagePath,
|
|
||||||
linkUrl: section.linkUrl,
|
|
||||||
sortOrder: section.sortOrder,
|
|
||||||
})), projectConfig.assets.map((asset) => ({
|
|
||||||
kind: asset.kind,
|
|
||||||
filePath: asset.filePath,
|
|
||||||
altAr: asset.altAr,
|
|
||||||
altEn: asset.altEn,
|
|
||||||
altDe: asset.altDe,
|
|
||||||
sortOrder: asset.sortOrder,
|
|
||||||
})));
|
|
||||||
|
|
||||||
await syncProjectMediaUsages(project.id, {
|
|
||||||
coverAssetId: projectConfig.coverAssetId,
|
|
||||||
sectionUsages: created.createdSections
|
|
||||||
.map((sectionRow, index) => ({
|
|
||||||
fieldKey: sectionRow.id,
|
|
||||||
assetId: projectConfig.sections[index]?.mediaAssetId,
|
|
||||||
}))
|
|
||||||
.filter((entry) => entry.assetId),
|
|
||||||
assetUsages: created.createdAssets
|
|
||||||
.map((assetRow, index) => ({
|
|
||||||
fieldKey: assetRow.id,
|
|
||||||
assetId: projectConfig.assets[index]?.mediaAssetId,
|
|
||||||
}))
|
|
||||||
.filter((entry) => entry.assetId),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main()
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("Seed failed:", error);
|
|
||||||
process.exit(1);
|
|
||||||
})
|
|
||||||
.finally(async () => {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
await pool.end();
|
|
||||||
});
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* Runs the test suite with Vitest's JSON reporter (plus the normal live output)
|
|
||||||
* and prints ONE compact, copy-pasteable summary at the end — pass/fail counts
|
|
||||||
* and every failing test with a one-line reason. Paste the block between the
|
|
||||||
* ===== markers to hand off the full picture without a wall of logs.
|
|
||||||
*
|
|
||||||
* Exits with the suite's own status, so `make test` / the pre-push hook still
|
|
||||||
* block on failure. This file is the same across all my projects; only RUNS
|
|
||||||
* differs (a project with several vitest configs lists one entry per config).
|
|
||||||
*/
|
|
||||||
import { spawnSync } from "node:child_process";
|
|
||||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
|
|
||||||
// The npm script(s) that together make up "the whole suite". Run through npm (not
|
|
||||||
// `npx vitest` directly) so nested tooling in a globalSetup — e.g. `npx drizzle-kit
|
|
||||||
// migrate` — resolves with the right PATH. A project split across several vitest
|
|
||||||
// configs lists one entry per config.
|
|
||||||
const RUNS = [{ label: "all", args: ["test"] }];
|
|
||||||
|
|
||||||
const reporterArgs = (out) => ["--", "--reporter=default", "--reporter=json", `--outputFile.json=${out}`];
|
|
||||||
|
|
||||||
const projectName = (() => {
|
|
||||||
try {
|
|
||||||
return JSON.parse(readFileSync("package.json", "utf8")).name ?? "project";
|
|
||||||
} catch {
|
|
||||||
return "project";
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
const workDir = mkdtempSync(join(tmpdir(), "test-summary-"));
|
|
||||||
const started = Date.now();
|
|
||||||
let status = 0;
|
|
||||||
const reports = [];
|
|
||||||
|
|
||||||
for (const run of RUNS) {
|
|
||||||
const out = join(workDir, `${run.label}.json`);
|
|
||||||
const res = spawnSync("npm", ["run", ...run.args, ...reporterArgs(out)], {
|
|
||||||
stdio: "inherit",
|
|
||||||
shell: process.platform === "win32",
|
|
||||||
});
|
|
||||||
if (res.status !== 0) status = res.status ?? 1;
|
|
||||||
try {
|
|
||||||
reports.push(JSON.parse(readFileSync(out, "utf8")));
|
|
||||||
} catch {
|
|
||||||
/* a crash before the report was written — status already non-zero */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let total = 0;
|
|
||||||
let passed = 0;
|
|
||||||
let failed = 0;
|
|
||||||
let skipped = 0;
|
|
||||||
let files = 0;
|
|
||||||
let filesFailed = 0;
|
|
||||||
const failures = [];
|
|
||||||
const cwd = process.cwd();
|
|
||||||
|
|
||||||
for (const r of reports) {
|
|
||||||
total += r.numTotalTests ?? 0;
|
|
||||||
passed += r.numPassedTests ?? 0;
|
|
||||||
failed += r.numFailedTests ?? 0;
|
|
||||||
skipped += (r.numPendingTests ?? 0) + (r.numTodoTests ?? 0);
|
|
||||||
for (const tr of r.testResults ?? []) {
|
|
||||||
files += 1;
|
|
||||||
const fileFailed = tr.status === "failed" || (tr.assertionResults ?? []).some((a) => a.status === "failed");
|
|
||||||
if (fileFailed) filesFailed += 1;
|
|
||||||
for (const a of tr.assertionResults ?? []) {
|
|
||||||
if (a.status !== "failed") continue;
|
|
||||||
const file = (tr.name ?? "").replace(`${cwd}/`, "");
|
|
||||||
const name = a.fullName || [...(a.ancestorTitles ?? []), a.title].filter(Boolean).join(" › ");
|
|
||||||
const reason =
|
|
||||||
(a.failureMessages ?? [])
|
|
||||||
.join("\n")
|
|
||||||
.split("\n")
|
|
||||||
.map((l) => l.trim())
|
|
||||||
.find((l) => l && !l.startsWith("at ")) ?? "";
|
|
||||||
failures.push({ file, name, reason: reason.slice(0, 200) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const elapsed = ((Date.now() - started) / 1000).toFixed(1);
|
|
||||||
const ok = status === 0 && failed === 0 && reports.length > 0;
|
|
||||||
const L = "=".repeat(38);
|
|
||||||
|
|
||||||
const lines = [];
|
|
||||||
lines.push(L);
|
|
||||||
lines.push(`TEST SUMMARY — ${projectName} (${new Date().toISOString().slice(0, 16).replace("T", " ")})`);
|
|
||||||
if (reports.length === 0) {
|
|
||||||
lines.push("❌ CRASH — the test run failed before producing a report (see output above).");
|
|
||||||
} else {
|
|
||||||
lines.push(
|
|
||||||
`${ok ? "✅ PASS" : "❌ FAIL"} — ${passed}/${total} tests passed` +
|
|
||||||
(failed ? `, ${failed} failed` : "") +
|
|
||||||
(skipped ? `, ${skipped} skipped` : "") +
|
|
||||||
` · ${files} files (${filesFailed} failed) · ${elapsed}s`,
|
|
||||||
);
|
|
||||||
if (failures.length) {
|
|
||||||
lines.push("");
|
|
||||||
lines.push(`FAILED (${failures.length}):`);
|
|
||||||
for (const f of failures) {
|
|
||||||
lines.push(` ✗ ${f.file} › ${f.name}`);
|
|
||||||
if (f.reason) lines.push(` ${f.reason}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lines.push(L);
|
|
||||||
|
|
||||||
console.log(`\n${lines.join("\n")}`);
|
|
||||||
rmSync(workDir, { recursive: true, force: true });
|
|
||||||
process.exit(status);
|
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
- Overview dashboard
|
- Overview dashboard
|
||||||
- Maintenance mode
|
- Maintenance mode
|
||||||
- Media Library
|
- Media Library
|
||||||
- Site Settings (Brand / Localization / SEO — see `docs/SEO.md`)
|
- Site Settings
|
||||||
- Marquee Settings
|
- Marquee Settings
|
||||||
- SMTP Settings
|
- SMTP Settings
|
||||||
- Contact Protection
|
- Contact Protection
|
||||||
|
|||||||
@@ -1,154 +0,0 @@
|
|||||||
# Test Plan — mohfarawati.de
|
|
||||||
|
|
||||||
Comprehensive automated test coverage for the multilingual Next.js portfolio + admin
|
|
||||||
workspace. Focus: **code correctness**. Scope excludes Playwright / browser E2E (per request).
|
|
||||||
|
|
||||||
## 1. Goals & principles
|
|
||||||
|
|
||||||
- Cover every application module: pure helpers, validation/schemas, data layer (Prisma),
|
|
||||||
server actions, API routes, middleware, forms, components, and architecture rules.
|
|
||||||
- **Do not change production behaviour.** Tests observe the code as-is. If a test reveals a
|
|
||||||
real bug or an architecture-rule violation, it is flagged for the owner — production code is
|
|
||||||
not changed without approval.
|
|
||||||
- Deterministic and self-contained: no external network, no reliance on a running app server.
|
|
||||||
- Real Postgres for the data layer (not mocks). See §3.
|
|
||||||
|
|
||||||
## 2. Test taxonomy & runner layout
|
|
||||||
|
|
||||||
Vitest with three **projects** (isolated environments), selected by file location:
|
|
||||||
|
|
||||||
| Project | Env | Location | Parallel | Purpose |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `unit` | node | `tests/unit/**` | yes | Pure functions, schemas, formatting, mapping logic |
|
|
||||||
| `integration` | node | `tests/integration/**` | serial | Prisma data layer, server actions, API routes, middleware |
|
|
||||||
| `component` | jsdom | `tests/component/**` | yes | React components & forms (RTL) |
|
|
||||||
|
|
||||||
Legacy flat `tests/*.test.ts` files are folded into the new structure (kept passing).
|
|
||||||
|
|
||||||
### Tooling added (dev-only)
|
|
||||||
- `@testing-library/react`, `@testing-library/dom`, `@testing-library/jest-dom`,
|
|
||||||
`@testing-library/user-event`, `jsdom`, `@vitejs/plugin-react` — component tests.
|
|
||||||
- `@electric-sql/pglite`, `pglite-prisma-adapter` — an **embedded real Postgres**
|
|
||||||
(Postgres compiled to WASM) that runs the project's actual migrations and Prisma queries
|
|
||||||
in-process, via a Prisma driver adapter.
|
|
||||||
|
|
||||||
### Database strategy (real Postgres)
|
|
||||||
`tests/helpers/integration-setup.ts` + `tests/helpers/global-db-setup.ts`:
|
|
||||||
- If `TEST_DATABASE_URL` is set → the real `lib/prisma` singleton is used unchanged, pointed
|
|
||||||
at that Postgres (e.g. the Docker instance). The global setup resets the schema and applies
|
|
||||||
every `prisma/migrations/*/migration.sql` once before the run.
|
|
||||||
- Otherwise → `lib/prisma` is mocked (test-only) with a Prisma client backed by an in-process
|
|
||||||
PGlite database. Each worker gets its own isolated database with the migrations applied —
|
|
||||||
real Postgres semantics, no external server, no shared-state races. **Production code is
|
|
||||||
never modified.**
|
|
||||||
- `resetDb()` (TRUNCATE all tables, restart identities) runs in `beforeEach`.
|
|
||||||
- The integration project runs **serially** (`fileParallelism: false`); each file gets a fresh
|
|
||||||
database connection.
|
|
||||||
|
|
||||||
### Next.js runtime mocks (integration)
|
|
||||||
Server actions/middleware depend on the Next runtime. `tests/helpers/next-mocks.ts` provides:
|
|
||||||
- `next/navigation` → `redirect()` throws a catchable `NEXT_REDIRECT` carrying the URL.
|
|
||||||
- `next/dist/client/components/redirect-error` → `isRedirectError()` recognises the above.
|
|
||||||
- `next/cache` → `revalidatePath()` spy (no-op, asserted).
|
|
||||||
- `next/headers` → controllable `cookies()` / `headers()` stores.
|
|
||||||
- `@/lib/admin-auth` `isAdminAuthenticated` → toggled per test (auth guard tests).
|
|
||||||
- `nodemailer` → captured transport (no real SMTP).
|
|
||||||
|
|
||||||
## 3. Coverage matrix
|
|
||||||
|
|
||||||
### 3.1 Unit — pure lib
|
|
||||||
|
|
||||||
| Module | Cases |
|
|
||||||
|---|---|
|
|
||||||
| `admin-routing` | host resolution (forwarded/comma/port), `isAdminHost`, `hasDedicatedAdminHost`, legacy/dev/internal path predicates, `toInternalAdminPath`, `fromDevelopmentAdminPath`, `getAdminAppPath` dev vs prod, `buildAdminUrl`/`buildSiteUrl`, env overrides, normalization edge cases |
|
|
||||||
| `admin-feedback` | `withFlash` (success/error/both/none, encoding), `readFlash` |
|
|
||||||
| `admin-navigation` | tree shape, `active`/`expanded` flags for each section, portfolio child mapping, href de-dup filter |
|
|
||||||
| `form-data` | `isCheckedFormValue` truthy/falsey set |
|
|
||||||
| `locale` | `isSupportedLocale`, `resolveLocale`, `getDirection` (rtl for ar), `stripLocalePrefix`, `getLocalizedPath(WithDefault)` incl. prefix stripping/rebuilding |
|
|
||||||
| `utils` | `cn` merge/dedupe/conditional |
|
|
||||||
| `site-theme` | `buildSiteThemeTokens` (hex→hsl, derive dark/secondary, clamps), `buildSiteThemeStyleText` structure, invalid hex fallback |
|
|
||||||
| `marquee-settings` | defaults, `parseMarqueeSettingsValue` (invalid json, partial, trims), `syncMarqueeSettingsToGermanSource`, `splitMarqueeRowItems` (newline/comma/blank) |
|
|
||||||
| `site-settings` | `normalizeSiteDefaultLocale`, `normalizeSitePrimaryColor`, `buildDefaultSiteSettings`, `parseSiteSettingsValue` (merge, invalid json, legacy title, invalid locale/color) |
|
|
||||||
| `site-icons` | `buildSiteIconUrls` (version, relative vs absolute favicon url, name fallback), `buildSiteIconResponse` transparent fallback for non-managed paths |
|
|
||||||
| `media-storage` (pure) | `sanitizeBaseName`, `getExtensionForMimeType` (all mimes + unknown), `isManagedMediaFilePath`, `resolveMediaUploadPath` (root confinement + traversal guard) |
|
|
||||||
| `media-validation` | `mediaFieldInputSchema`: library needs assetId, external needs url, url format rule, upload mode, kind enum, trimming |
|
|
||||||
| `media-service` (pure) | `inferMediaKindFromMimeType`, `inferMediaKindFromFileName`, `getKindFromUploadFile` |
|
|
||||||
| `portfolio` (pure) | `resolvePortfolioProjectViewMode`, `getLocalizedValue` (direct/fallback/any) |
|
|
||||||
| `portfolio-form-progress` | slug/year/sortOrder validators, section readiness per type, asset readiness, wizard progress, first incomplete step |
|
|
||||||
| `portfolio-validation` | category/section/asset/project schemas: required fields, slug regex, coercions, view modes, section superRefine per type, media refinements, url rules |
|
|
||||||
| `metadata` | `applyTitleTemplateFn`, `buildLocaleAlternates`, `buildAppMetadataFromConfig`, `buildLocalizedMetadataFromConfig` (title template skip, description fallback, og/twitter, icons) |
|
|
||||||
| `mail` | `createSmtpTransport` (required host/user/pass errors, port/secure), `sendMail` (from with/without name), `sendContactMessage` (recipient + fallback, body fields), `sendTestEmail` (recipient fallback, transport reject) |
|
|
||||||
|
|
||||||
### 3.2 Integration — data layer (real DB)
|
|
||||||
|
|
||||||
| Module | Cases |
|
|
||||||
|---|---|
|
|
||||||
| `app-config` | maintenance get/set, site settings get (fallback name from `siteName` key) / update roundtrip, mail settings get/update, marquee get/update (german sync), `getSiteSettingsMediaBindings` (mediaUsage → bindings per field) |
|
|
||||||
| `media` | create asset, get by id (+usages), list, `getMediaOptions` kind filter, `replaceEntityMediaUsages` (transactional replace, unique constraint), `deleteEntityMediaUsages`, `getPortfolioMediaBindings` routing by usageType, `countMediaUsageReferences` |
|
|
||||||
| `portfolio` (queries) | admin categories (+project counts), active categories/by-slug, admin projects (status/category filters + ordering), published projects/by-slug, by-id with media bindings, localized mapping, `onDelete` Restrict/Cascade behaviour |
|
|
||||||
| `media-service.resolveMediaSelection` | library (found/missing), external (creates asset, filename from url), missing+required error, not-required empty |
|
|
||||||
| `admin-auth` (lockout) | `registerFailedAdminAttempt` increments & locks at threshold, `getAdminLockState`, `resetAdminFailedAttempts`, IP hashing via mocked headers; token `isPasswordValid`/verify with env |
|
|
||||||
|
|
||||||
### 3.3 Integration — API routes & middleware
|
|
||||||
|
|
||||||
| Target | Cases |
|
|
||||||
|---|---|
|
|
||||||
| `GET /api/health` | 200 + `database: up`; 503 + `database: down` when query throws |
|
|
||||||
| `GET /api/site/default-locale` | returns runtime `defaultLocale` + `maintenanceEnabled`, no-store header |
|
|
||||||
| `proxy` (middleware) | runtime default-locale passthrough, safe fallback on fetch failure, maintenance redirect, `SITE_RUNTIME_ORIGIN`, admin host rewrite → internal, dev `/root` handling, legacy 404 in prod, internal path 404 for non-admin in prod, basic-auth challenge/valid |
|
|
||||||
|
|
||||||
### 3.4 Integration — server actions
|
|
||||||
|
|
||||||
| Action file | Cases |
|
|
||||||
|---|---|
|
|
||||||
| `contact/actions` | valid → sends mail + redirect `/success`; invalid (short name/bad email/short message) → redirect with error; locale resolution |
|
|
||||||
| `maintenance/actions` | unauth → redirect to admin root; enable/disable toggles config + revalidates + success flash |
|
|
||||||
| `marquee/actions` | unauth guard; empty german rows throw per-row error → error flash; valid → saves (german-synced) + success flash |
|
|
||||||
| `smtp/actions` | `parseMailSettingsFormData` (port parse error, password retention when blank), save → success; `sendTestEmailAction` success + failure |
|
|
||||||
| `site-settings/actions` | brand save (primary color normalize, media selection + usage wiring, cleanup on error), localization save (siteName required, title template must contain `{pageTitle}`), `parseJsonObject` guard |
|
|
||||||
| `media/actions` | create (kind image/document, missing file error), delete (not found, in-use guard, managed-file removal) |
|
|
||||||
| `portfolio/actions` | `upsertCategoryAction` create/update + P2002 unique message; `deleteCategoryAction` blocks when projects exist; `saveProjectAction` create + update, sections/assets replace, `publishedAt` first-publish logic, media usage wiring, validation + error cleanup of created media; `deleteProjectAction` not-found + cascade + usage cleanup |
|
|
||||||
|
|
||||||
### 3.5 Component (jsdom + RTL)
|
|
||||||
|
|
||||||
Global mocks: `framer-motion`, `gsap`, `next/link`, `next/image`, `next-intl`, `next/navigation`.
|
|
||||||
|
|
||||||
| Component | Cases |
|
|
||||||
|---|---|
|
|
||||||
| `ui/badge` | variant classes, custom className merge, passthrough props |
|
|
||||||
| `ui/input` | renders, forwardRef, type/placeholder/disabled, className merge |
|
|
||||||
| `ui/textarea`, `ui/label`, `ui/card`*, `ui/app-card`, `ui/separator`, `ui/table`* | render, props, ref, composition |
|
|
||||||
| `admin/admin-flash` | null when empty, success `role=status`, error `role=alert`, both, className |
|
|
||||||
| `admin/marquee-settings-form` | renders 4 rows, default values, submit wiring to action |
|
|
||||||
| `site/portfolio-category-filter` | "all" link + per-category links, active state, localized labels, hrefs |
|
|
||||||
| `dashboard/dashboard-card`, `dashboard/stats-card` | presentational render, props |
|
|
||||||
| `layout/container`, `layout/hero-badge`, `home/section-heading`, `home/bento-card` | presentational render, children, className |
|
|
||||||
|
|
||||||
(*multi-part primitives tested for sub-component composition.)
|
|
||||||
|
|
||||||
### 3.6 Architecture-rule tests (`tests/integration/architecture`)
|
|
||||||
|
|
||||||
Enforced from `CLAUDE.md`:
|
|
||||||
1. **No Prisma in client components** — no file containing `"use client"` imports `lib/prisma`.
|
|
||||||
2. **Business logic out of UI** — components don't import server-only data modules directly
|
|
||||||
(allow-list of pure `lib/*` view/format helpers).
|
|
||||||
3. **Server actions are guarded** — every exported action in `app/**/actions.ts` calls an auth
|
|
||||||
guard (`ensureAdmin`/`requireAdminAuth`) except the public contact action.
|
|
||||||
4. **`"use server"` directive** — every `actions.ts` begins with `"use server"`.
|
|
||||||
5. **`lib/*` does not import from `app/`** — dependency direction.
|
|
||||||
6. **Admin mirror parity** — every `page.tsx` under `app/_admin/**` has matching re-export
|
|
||||||
stubs under `app/root/**` and `app/admin-internal/**` pointing back to `_admin`.
|
|
||||||
7. **No browser storage in components** — no `localStorage`/`sessionStorage` usage.
|
|
||||||
|
|
||||||
## 4. Deliverables & running
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm test # all projects
|
|
||||||
npx vitest run --project unit
|
|
||||||
npx vitest run --project integration
|
|
||||||
npx vitest run --project component
|
|
||||||
TEST_DATABASE_URL=postgres://… # optional: run integration against real Postgres
|
|
||||||
```
|
|
||||||
|
|
||||||
Findings (real bugs / rule violations) are reported to the owner; production code is only
|
|
||||||
changed after approval.
|
|
||||||