Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cb15608dd | ||
|
|
af66f61c26 | ||
|
|
7a71296e6d | ||
|
|
f3797bfe47 | ||
|
|
bee03398d0 | ||
|
|
1b6a96225e | ||
|
|
f0c25b802b | ||
|
|
affd4b7ee7 | ||
|
|
3e0c9a1605 | ||
|
|
90120b60db | ||
|
|
e6f00cbbb6 | ||
|
|
f50bb553e8 | ||
|
|
c7f8672cd0 | ||
|
|
6fcf893903 | ||
|
|
5cbcb2eac4 | ||
|
|
2dab728129 | ||
|
|
4bfbcbcacd | ||
|
|
83616433e4 | ||
|
|
c70833a725 | ||
|
|
11e991e54a | ||
|
|
c67c13d82f | ||
|
|
ca0eddfde8 | ||
|
|
5b037d7e0c | ||
|
|
6f6e52dd63 | ||
|
|
666b1d7547 | ||
|
|
60f094a754 | ||
|
|
f04e8c8463 | ||
|
|
9ed436330b | ||
|
|
494f69db85 | ||
|
|
75993100fa | ||
|
|
e89a248ca5 | ||
|
|
e9f05b02fc | ||
|
|
ed35413a44 | ||
|
|
a54df524c7 | ||
|
|
390936856c | ||
|
|
dc4a01cbe4 | ||
|
|
123ab8e354 | ||
|
|
007a691357 | ||
|
|
fa40fefe2f | ||
|
|
4eb663f42a | ||
|
|
cc0d9bd9c2 | ||
|
|
a9702dc3ff | ||
|
|
c71916cf08 | ||
|
|
f258e2a483 | ||
|
|
d33a5d6c63 | ||
|
|
6e5d14882e | ||
|
|
0995a3b5ca | ||
|
|
43d0a49c3d | ||
|
|
c97b07de19 | ||
|
|
57b19055b4 | ||
|
|
d9e0845266 | ||
|
|
ede34ab7ce | ||
|
|
3fac158185 | ||
|
|
001c8514b5 | ||
|
|
00a8e3248f | ||
|
|
9951a357ac | ||
|
|
3395e6ebf8 | ||
|
|
55c25ca203 | ||
|
|
53808c416a | ||
|
|
9dad18daa5 | ||
|
|
d4b8c1ce4a | ||
|
|
04c3d73964 | ||
|
|
442983fe06 | ||
|
|
cb5be5426d | ||
|
|
85a0e2e051 | ||
|
|
f1d5d9e859 | ||
|
|
0fb5e0cb66 | ||
|
|
d08eaa45fc | ||
|
|
3f50ab034e | ||
|
|
55e67e38e2 | ||
|
|
c35d411874 | ||
|
|
07c8d7c668 | ||
|
|
5663c3ae38 | ||
|
|
acb9c3df38 | ||
|
|
d5f36274aa | ||
|
|
ce7d485b0c | ||
|
|
4ac5b15473 | ||
|
|
c08950e865 | ||
|
|
8dea906d23 | ||
|
|
cb9e136d16 | ||
|
|
a02a419879 | ||
|
|
222140aa44 | ||
|
|
1187f5d48d | ||
|
|
6e64da48d6 | ||
|
|
f2bffea312 | ||
|
|
1669227f19 | ||
|
|
c644766a64 | ||
|
|
05514b0c99 | ||
|
|
66b80117b1 | ||
|
|
7bceb6cd2b | ||
|
|
569bb4e270 | ||
|
|
0a6f5f34ce | ||
|
|
5f27bb9950 | ||
|
|
d83199ab1a | ||
|
|
2cfe9115d2 | ||
|
|
d4b586f29f | ||
|
|
9013d38e31 | ||
|
|
f3cbdd8076 | ||
|
|
f143fc6099 |
Executable
+32
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
# Stop hook: the agent may only finish when typecheck and unit tests pass on the current code.
|
||||
set -uo pipefail
|
||||
cd "${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel)}"
|
||||
|
||||
payload="$(cat)"
|
||||
# Avoid a loop: if we already blocked once in this turn, let it stop and show the failure to the user.
|
||||
if printf '%s' "$payload" | grep -q '"stop_hook_active"[[:space:]]*:[[:space:]]*true'; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Nothing changed in the app, nothing to verify.
|
||||
if [ -z "$(git status --porcelain -- apps/web package.json pnpm-lock.yaml)" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
run() {
|
||||
local label="$1"; shift
|
||||
local out
|
||||
if ! out="$("$@" 2>&1)"; then
|
||||
{
|
||||
echo "VERIFY FAILED: $label"
|
||||
echo "$out" | tail -n 40
|
||||
echo "Fix the cause (do not weaken tests), then finish again."
|
||||
} >&2
|
||||
exit 2
|
||||
fi
|
||||
}
|
||||
|
||||
run "typecheck" pnpm --filter @mintel/web typecheck
|
||||
run "unit tests" pnpm --filter @mintel/web test
|
||||
exit 0
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"permissions": {
|
||||
"deny": [
|
||||
"Read(./.env)",
|
||||
"Read(./.env.*)",
|
||||
"Read(./apps/web/.env)",
|
||||
"Read(./apps/web/.env.*)",
|
||||
"Edit(./.env)",
|
||||
"Edit(./.env.*)",
|
||||
"Bash(git push --force*)",
|
||||
"Bash(git push -f*)",
|
||||
"Bash(git push * main*)",
|
||||
"Bash(git push * master*)",
|
||||
"Bash(git commit *--no-verify*)",
|
||||
"Bash(git commit * -n *)",
|
||||
"Bash(git reset --hard*)",
|
||||
"Bash(git clean*)",
|
||||
"Bash(rm -rf*)"
|
||||
],
|
||||
"ask": [
|
||||
"Edit(./.gitea/workflows/**)",
|
||||
"Write(./.gitea/workflows/**)",
|
||||
"Edit(./Dockerfile*)",
|
||||
"Edit(./docker-compose*)",
|
||||
"Bash(git push*)"
|
||||
]
|
||||
},
|
||||
"hooks": {
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/verify.sh",
|
||||
"timeout": 180
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
---
|
||||
name: video-film
|
||||
description: Make or change a social media film or image post in apps/video (Remotion) in the approved mintel.me style - one continuous shot, minimal, with a camera pull-back as the reveal, in German and English. Use when asked for a new video, a new topic, changes to an existing film, new formats, or to re-render the video library.
|
||||
---
|
||||
|
||||
# Making a film for mintel.me
|
||||
|
||||
The videos in `apps/video` are short looping films for LinkedIn and ads. The owner approved this
|
||||
style after rejecting several others. Read `apps/video/CLAUDE.md` first; this skill is the procedure.
|
||||
|
||||
## What the owner rejected, so do not go back to it
|
||||
|
||||
- Slides with a panel, a progress bar, labels and a frame around everything.
|
||||
- Much text, small text, text sizes that change from line to line.
|
||||
- Hectic motion: hops of a third of a second, six statements in 15 seconds.
|
||||
- Busy drawings: icons, labels, counters, stacks, several effects at once.
|
||||
- A tiny logo in a black box.
|
||||
- German that reads translated.
|
||||
|
||||
## The style
|
||||
|
||||
One continuous shot on a dark stage. Only squares, dots, lines and bars. One statement at a time,
|
||||
two short lines, large and centred under the drawing. Calm: nothing moves faster than it has to, every
|
||||
statement stays for about three seconds.
|
||||
|
||||
Every film tells the same kind of story in six beats, on the shared timeline `T` in
|
||||
`src/films/shot.ts` (690 frames at 30 fps, 23 seconds):
|
||||
|
||||
| Beat | Caption id | What happens |
|
||||
| :--- | :--------- | :----------- |
|
||||
| Hook | `hook` | Close on something small and abstract that looks like something else. "Looks like ping-pong." |
|
||||
| Reveal | `reveal` | The camera pulls back and shows what it really is. "It's your office." This is the aha moment. |
|
||||
| Problem | `problem` | The whole picture keeps going, and it is clearly too much. |
|
||||
| Turn | `turn` | Everything slows to a halt, then the form changes: arcs become a line, a pile leaves by itself. |
|
||||
| Calm | `calm` | The result at rest, with one quiet movement. |
|
||||
| Call to action | `cta` | A question that picks up the hook ("Done with ping-pong?") and the website's button. |
|
||||
|
||||
Then the picture folds back into its opening, so the last frame equals the first and the film loops.
|
||||
Frame 0 is the preview image: it must already look good and carry the hook line.
|
||||
|
||||
## Procedure
|
||||
|
||||
1. **Bring the worktree up to date with `main`** and read the service in
|
||||
`apps/web/src/content/de.ts` and `en.ts`. Claims, prices and terms come from there and nowhere else.
|
||||
2. **Find the picture before writing code.** Answer in one line each:
|
||||
- What does the viewer see close up, and what harmless thing does it look like?
|
||||
- What does the camera reveal when it pulls back?
|
||||
- Which single change of form resolves it?
|
||||
If the reveal does not make you think "oh", look for another picture. Existing ones:
|
||||
ping-pong / office, skyscraper / pile of invoices, star / unanswered enquiries,
|
||||
blinking cursor / empty website, Jenga / fragile app, loading bar / backlog.
|
||||
3. **Write the six statements** in `src/content/videos.ts`, German and English, plus the post text.
|
||||
Two lines each, as short as possible: one font size is used for the whole film, set by the longest
|
||||
line. The hook is "Sieht aus wie ..." / "Looks like ...". A little sarcasm is welcome. German uses
|
||||
"Sie" and has to sound like a native wrote it; write it first, do not translate it from English.
|
||||
The post text opens with the hook. Copy rules of the root `CLAUDE.md` apply.
|
||||
4. **Write the film** as `src/films/<Name>Film.tsx`. Copy the structure of `AppFilm.tsx` (simplest)
|
||||
or `OrderFilm.tsx` (several moving things):
|
||||
- `cameraAt(frame, zoomIn, zoomOut, focus)` gives `transform`, `zoom`, `hair` and `others`.
|
||||
Everything that is not part of the opening picture gets `opacity={others}`.
|
||||
- Draw in stage coordinates, (0, 0) in the centre, 1000 x 700. Use `hair` for stroke widths and
|
||||
divide glow sizes by `zoom`, so lines look the same at every zoom.
|
||||
- All motion is a pure function of the frame: `progress`, `inOut`, `out`, `mix` from
|
||||
`src/illustrations/motion.ts`. No timers, no randomness, no CSS animations.
|
||||
- For the loop: after `T.fold` the opening picture has to be rebuilt exactly. Anything periodic
|
||||
needs a period that divides 690 (23, 30, 46). Compare frame 0 and frame 689.
|
||||
- Logic worth testing goes into `src/lib` with a spec, written first (see `hop.ts`).
|
||||
5. **Register it**: `src/compositions/<VideoId>.tsx` exporting a `VideoEntry` with `Film`, and the
|
||||
list in `src/Root.tsx`. A new id also goes into `VIDEO_IDS` in `videos.ts`.
|
||||
6. **Check**: `pnpm typecheck` and `pnpm test` in `apps/video`.
|
||||
7. **Render and look**: `node scripts/export.ts --only <VideoId>`, then pull frames from the MP4
|
||||
(start, middle of the pull-back, problem, turn, calm, call to action, last frame) in `feed` and
|
||||
`wide`, and read them. Check: nothing overlaps the text, the drawing fades at the stage edges
|
||||
during the camera move, frame 689 equals frame 0, German lines fit.
|
||||
8. **Report** what was rendered, what you looked at, and what you did not. You cannot play video:
|
||||
say so, and leave the judgement of pace and feel to the owner.
|
||||
|
||||
## Traps that have cost time here
|
||||
|
||||
- **A render can silently not happen.** Redirect logs with `>|`, not `>` (the shell refuses to
|
||||
overwrite an existing file and the command never starts). After every export, compare the file times
|
||||
with the clock before sending anything. Run one export at a time.
|
||||
- **Glows**: `drop-shadow` on SVG shapes is fine. Blur filters under a CSS mask, blend modes and dashed
|
||||
strokes came out with hard edges; build soft light from gradients that fade to transparent.
|
||||
- **The stage is not clipped by itself.** While the camera is close, the drawing is larger than the
|
||||
stage; `FilmStage` fades it at the edges. Do not rely on things being "off screen".
|
||||
- **Do not name a helper script `copy.py`, `json.py` or similar**: it shadows the Python module.
|
||||
- **Text from an old worktree is stale.** The website changed language and wording during this work.
|
||||
|
||||
## Image posts
|
||||
|
||||
Every film gets a poster, and the plan pairs it with a card (see `apps/web/plans/content-strategy.md`).
|
||||
|
||||
- Poster: add an entry to `src/content/images.ts` with `poster: { video, frame }`. Pick the frame where the
|
||||
whole picture is in view and the problem is obvious (around frame 250 to 300). Two lines, as short as
|
||||
possible: "Kein Pingpong." / "Ihr Büro."
|
||||
- Card: a question a client asks before the first call (`label`) and the answer in two or three lines.
|
||||
The answer must be on the website already.
|
||||
- Add the new posts to `src/content/plan.ts`, run `pnpm test`, then `node scripts/export.ts --images`
|
||||
and look at the PNGs in `feed` and `square`.
|
||||
- A poster is not a frozen frame inside a one-frame image: `Freeze` did not work there. The poster is a
|
||||
composition as long as the film, rendered at that frame.
|
||||
|
||||
## Formats and output
|
||||
|
||||
Compositions are `<VideoId>-<de|en>-<feed|wide|square|story>`. `node scripts/export.ts` renders
|
||||
`feed` (4:5, LinkedIn) and `wide` (16:9, YouTube and ads) in both languages into
|
||||
`out/library/<lang>/week-<n>/<slot>-<day>-<id>/`, with `post.txt` and an overview page
|
||||
`out/library/index.html`. `out/` is not committed. Keep it tidy: check frames, contact sheets and logs
|
||||
go into `out/tmp`, nothing loose into `out/`, and `out/tmp` is emptied before you report.
|
||||
+42
-64
@@ -158,22 +158,25 @@ jobs:
|
||||
run: |
|
||||
echo "Disk space before nuclear prune:"
|
||||
df -h
|
||||
|
||||
|
||||
# Massive cleanup of pre-installed runner software to free up ~5GB+
|
||||
# These are standard on VM-based runners and often unnecessary
|
||||
sudo rm -rf /usr/share/dotnet || true
|
||||
sudo rm -rf /usr/local/lib/android || true
|
||||
sudo rm -rf /opt/ghc || true
|
||||
sudo rm -rf /opt/hostedtoolcache/CodeQL || true
|
||||
|
||||
|
||||
# Comprehensive Docker purge
|
||||
docker buildx stop || true
|
||||
docker buildx rm -a || true
|
||||
docker rm -fv $(docker ps -aq --filter "ancestor=moby/buildkit") || true
|
||||
docker system prune -af --volumes
|
||||
docker builder prune -af
|
||||
docker buildx prune -af
|
||||
|
||||
|
||||
# Clean temp build artifacts
|
||||
rm -rf /tmp/docker-actions-toolkit-* || true
|
||||
|
||||
|
||||
echo "Disk space after nuclear prune:"
|
||||
df -h
|
||||
continue-on-error: true
|
||||
@@ -196,8 +199,7 @@ jobs:
|
||||
S3_BUCKET=${{ secrets.S3_BUCKET || vars.S3_BUCKET || 'mintel' }}
|
||||
S3_REGION=${{ secrets.S3_REGION || vars.S3_REGION || 'fsn1' }}
|
||||
S3_PREFIX=${{ secrets.S3_PREFIX || vars.S3_PREFIX || 'mintel.me' }}
|
||||
DATABASE_URI=${{ secrets.DATABASE_URI || (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_DB_PASSWORD && format('postgres://directus:{0}@postgres-db:5432/directus', secrets.TESTING_DIRECTUS_DB_PASSWORD)) || 'postgres://payload:payload@127.0.0.1:5432/payload' }}
|
||||
PAYLOAD_SECRET=${{ secrets.PAYLOAD_SECRET || 'secret' }}
|
||||
|
||||
BUILD_ID=${{ github.sha }}
|
||||
tags: registry.infra.mintel.me/mintel/mintel.me:${{ needs.prepare.outputs.image_tag }}
|
||||
# Temporarily disable registry cache export to save runner disk/bandwidth
|
||||
@@ -213,7 +215,7 @@ jobs:
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.ALPHA_SSH_KEY }}" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null
|
||||
ssh-keyscan -H infra.mintel.me >> ~/.ssh/known_hosts 2>/dev/null
|
||||
echo "Re-running docker build with plain progress to capture exact logs..."
|
||||
echo "${{ secrets.NPM_TOKEN }}" > /tmp/npm_token.txt
|
||||
docker build \
|
||||
@@ -225,7 +227,7 @@ jobs:
|
||||
--progress plain \
|
||||
-t temp-image . > docker_build_failed.log 2>&1
|
||||
cat login.log >> docker_build_failed.log || true
|
||||
scp docker_build_failed.log root@alpha.mintel.me:/root/docker_build_failed.log
|
||||
scp docker_build_failed.log root@infra.mintel.me:/root/docker_build_failed.log
|
||||
# JOB 4: Deploy
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
deploy:
|
||||
@@ -242,13 +244,6 @@ jobs:
|
||||
DIRECTUS_URL: ${{ needs.prepare.outputs.directus_url }}
|
||||
DIRECTUS_HOST: cms.${{ needs.prepare.outputs.traefik_host }}
|
||||
|
||||
# Database configuration
|
||||
postgres_DB_NAME: ${{ secrets.DIRECTUS_DB_NAME || vars.DIRECTUS_DB_NAME || 'directus' }}
|
||||
postgres_DB_USER: ${{ secrets.DIRECTUS_DB_USER || vars.DIRECTUS_DB_USER || 'directus' }}
|
||||
postgres_DB_PASSWORD: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_DB_PASSWORD) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_DB_PASSWORD) || secrets.DIRECTUS_DB_PASSWORD || vars.DIRECTUS_DB_PASSWORD || 'directus' }}
|
||||
DATABASE_URI: postgres://${{ secrets.DIRECTUS_DB_USER || vars.DIRECTUS_DB_USER || 'directus' }}:${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_DB_PASSWORD) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_DB_PASSWORD) || secrets.DIRECTUS_DB_PASSWORD || vars.DIRECTUS_DB_PASSWORD || 'directus' }}@postgres-db:5432/${{ secrets.DIRECTUS_DB_NAME || vars.DIRECTUS_DB_NAME || 'directus' }}
|
||||
PAYLOAD_SECRET: ${{ secrets.PAYLOAD_SECRET || vars.PAYLOAD_SECRET || 'secret' }}
|
||||
|
||||
# Mail
|
||||
MAIL_HOST: ${{ secrets.SMTP_HOST || vars.SMTP_HOST }}
|
||||
MAIL_PORT: ${{ secrets.SMTP_PORT || vars.SMTP_PORT || '587' }}
|
||||
@@ -311,11 +306,7 @@ jobs:
|
||||
SENTRY_DSN=$SENTRY_DSN
|
||||
PROJECT_COLOR=$PROJECT_COLOR
|
||||
LOG_LEVEL=$LOG_LEVEL
|
||||
postgres_DB_NAME=$postgres_DB_NAME
|
||||
postgres_DB_USER=$postgres_DB_USER
|
||||
postgres_DB_PASSWORD=$postgres_DB_PASSWORD
|
||||
DATABASE_URI=$DATABASE_URI
|
||||
PAYLOAD_SECRET=$PAYLOAD_SECRET
|
||||
|
||||
MAIL_HOST=$MAIL_HOST
|
||||
MAIL_PORT=$MAIL_PORT
|
||||
MAIL_USERNAME=$MAIL_USERNAME
|
||||
@@ -335,6 +326,7 @@ jobs:
|
||||
S3_REGION=$S3_REGION
|
||||
S3_PREFIX=$S3_PREFIX
|
||||
TARGET=$TARGET
|
||||
SKIP_RUNTIME_ENV_VALIDATION=true
|
||||
SENTRY_ENVIRONMENT=$TARGET
|
||||
PROJECT_NAME=$PROJECT_NAME
|
||||
ENV_FILE=$ENV_FILE
|
||||
@@ -355,11 +347,11 @@ jobs:
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.ALPHA_SSH_KEY }}" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null
|
||||
ssh-keyscan -H infra.mintel.me >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
# SSH keepalive to prevent timeout during long docker pull
|
||||
cat > ~/.ssh/config <<SSHCFG
|
||||
Host alpha.mintel.me
|
||||
Host infra.mintel.me
|
||||
ServerAliveInterval 15
|
||||
ServerAliveCountMax 20
|
||||
ConnectTimeout 30
|
||||
@@ -377,9 +369,9 @@ jobs:
|
||||
fi
|
||||
|
||||
# Upload files
|
||||
ssh root@alpha.mintel.me "mkdir -p $SITE_DIR/directus/schema $SITE_DIR/directus/uploads $SITE_DIR/directus/extensions"
|
||||
scp .env.deploy root@alpha.mintel.me:$SITE_DIR/$ENV_FILE
|
||||
scp docker-compose.yml root@alpha.mintel.me:$SITE_DIR/docker-compose.yml
|
||||
ssh root@infra.mintel.me "mkdir -p $SITE_DIR/directus/schema $SITE_DIR/directus/uploads $SITE_DIR/directus/extensions"
|
||||
scp .env.deploy root@infra.mintel.me:$SITE_DIR/$ENV_FILE
|
||||
scp docker-compose.yml root@infra.mintel.me:$SITE_DIR/docker-compose.yml
|
||||
|
||||
# Deploy
|
||||
echo "Testing available secrets against git.infra.mintel.me Docker registry..."
|
||||
@@ -408,7 +400,7 @@ jobs:
|
||||
TOKEN="$VALID_TOKEN"
|
||||
|
||||
# Deploy — alpha is pre-logged into registry.infra.mintel.me, no credential passing needed
|
||||
ssh root@alpha.mintel.me "
|
||||
ssh root@infra.mintel.me "
|
||||
docker network create '${{ needs.prepare.outputs.project_name }}-internal' || true
|
||||
docker volume create 'mintel-me_payload-db-data' || true
|
||||
cd $SITE_DIR
|
||||
@@ -436,7 +428,7 @@ jobs:
|
||||
|
||||
echo "Purging S3 cache for ${{ env.S3_PREFIX }} ..."
|
||||
rclone --config rclone.conf delete mintel-s3:${{ env.S3_BUCKET }}/${{ env.S3_PREFIX }}/cache/ --include "*" || true
|
||||
|
||||
|
||||
rm rclone.conf rclone-current-linux-amd64.deb
|
||||
|
||||
- name: 🧹 Post-Deploy Cleanup (Runner)
|
||||
@@ -462,7 +454,7 @@ jobs:
|
||||
COUNT=0
|
||||
MAX=24
|
||||
URL="${{ needs.prepare.outputs.next_public_url }}"
|
||||
|
||||
|
||||
echo "Verifying $URL is responsive..."
|
||||
while [ $COUNT -lt $MAX ]; do
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$URL/" || echo "000")
|
||||
@@ -474,50 +466,36 @@ jobs:
|
||||
sleep 5
|
||||
COUNT=$((COUNT + 1))
|
||||
done
|
||||
|
||||
|
||||
if [ "$STATUS" != "200" ]; then
|
||||
echo "❌ Site failed smoke test after 2 minutes! (Status: $STATUS)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: 🌐 Check Case Study Assets and Rewrites
|
||||
- name: 🌐 Check Legal Pages and Old URLs
|
||||
shell: sh
|
||||
run: |
|
||||
BASE_URL="${{ needs.prepare.outputs.next_public_url }}"
|
||||
|
||||
echo "Verifying diagnostic rewrite..."
|
||||
STATUS=$(curl -s -L -o /dev/null -w "%{http_code}" "$BASE_URL/robots-test")
|
||||
if [ "$STATUS" != "200" ]; then
|
||||
echo "❌ Diagnostic rewrite /robots-test failed! (Status: $STATUS)"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Diagnostic rewrite OK"
|
||||
|
||||
echo "Verifying case study page..."
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL/case-studies/klz-cables")
|
||||
if [ "$STATUS" != "200" ]; then
|
||||
echo "❌ Case study page failed! (Status: $STATUS)"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Case study page UP"
|
||||
|
||||
echo "Verifying breeze CSS (root path)..."
|
||||
CSS_PATH="/assets/klz-cables.com/wp-content/cache/breeze-minification/css/breeze_klz-cables-com-1-10895.css"
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL$CSS_PATH")
|
||||
if [ "$STATUS" != "200" ]; then
|
||||
echo "❌ Root asset path failed! (Status: $STATUS)"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Root asset path OK"
|
||||
|
||||
echo "Verifying breeze CSS (relative path from case-study)..."
|
||||
REL_CSS_PATH="/case-studies/assets/klz-cables.com/wp-content/cache/breeze-minification/css/breeze_klz-cables-com-1-10895.css"
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL$REL_CSS_PATH")
|
||||
if [ "$STATUS" != "200" ]; then
|
||||
echo "❌ Relative asset path failed! (Status: $STATUS)"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Relative asset path OK"
|
||||
|
||||
# The freelance page and the legal pages must be reachable in both languages.
|
||||
for PAGE in /freelance /en/freelance /impressum /datenschutz /en/imprint /en/privacy; do
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL$PAGE")
|
||||
if [ "$STATUS" != "200" ]; then
|
||||
echo "❌ $PAGE failed! (Status: $STATUS)"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ $PAGE OK"
|
||||
done
|
||||
|
||||
# URLs of the previous site must redirect instead of returning 404.
|
||||
for OLD in /blog /contact /websites /case-studies/klz-cables; do
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL$OLD")
|
||||
if [ "$STATUS" != "308" ]; then
|
||||
echo "❌ $OLD does not redirect! (Status: $STATUS)"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ $OLD redirects"
|
||||
done
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 6: Notifications
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
name: Nightly QA
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Build & Deploy"]
|
||||
branches: [main]
|
||||
types:
|
||||
- completed
|
||||
schedule:
|
||||
- cron: "0 3 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
TARGET_URL: "https://testing.mintel.me"
|
||||
PROJECT_NAME: "mintel.me"
|
||||
|
||||
jobs:
|
||||
# ────────────────────────────────────────────────────
|
||||
# 1. Static Checks (HTML, Assets, HTTP)
|
||||
# ────────────────────────────────────────────────────
|
||||
static:
|
||||
name: 🔍 Static Analysis
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: 🔐 Registry Auth
|
||||
run: |
|
||||
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||
- name: 📦 Cache node_modules
|
||||
uses: actions/cache@v4
|
||||
id: cache-deps
|
||||
with:
|
||||
path: node_modules
|
||||
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
- name: Install
|
||||
if: steps.cache-deps.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
pnpm store prune
|
||||
pnpm install --no-frozen-lockfile
|
||||
- name: 🌐 Install Chrome & Dependencies
|
||||
run: |
|
||||
apt-get update && apt-get install -y --fix-missing \
|
||||
libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \
|
||||
libxkbcommon0 libxcomposite1 libxdamage1 libxext6 libxfixes3 \
|
||||
libxrandr2 libgbm1 libpango-1.0-0 libcairo2 || true
|
||||
apt-get install -y libasound2t64 || apt-get install -y libasound2 || true
|
||||
npx puppeteer browsers install chrome || true
|
||||
- name: 🖼️ OG Images
|
||||
continue-on-error: true
|
||||
env:
|
||||
TEST_URL: ${{ env.TARGET_URL }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||
run: pnpm --filter @mintel/web run check:og
|
||||
|
||||
# ────────────────────────────────────────────────────
|
||||
# 2. E2E (Forms)
|
||||
# ────────────────────────────────────────────────────
|
||||
e2e:
|
||||
name: 📝 E2E
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: 🔐 Registry Auth
|
||||
run: |
|
||||
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||
- name: 📦 Cache node_modules
|
||||
uses: actions/cache@v4
|
||||
id: cache-deps
|
||||
with:
|
||||
path: node_modules
|
||||
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
- name: Install
|
||||
if: steps.cache-deps.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
pnpm store prune
|
||||
pnpm install --no-frozen-lockfile
|
||||
- name: 🌐 Install Chrome & Dependencies
|
||||
run: |
|
||||
apt-get update && apt-get install -y libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxrandr2 libgbm1 libasound2t64 libpango-1.0-0 libcairo2
|
||||
npx puppeteer browsers install chrome || true
|
||||
- name: 📝 E2E Form Submission Test
|
||||
continue-on-error: true
|
||||
env:
|
||||
TEST_URL: ${{ env.TARGET_URL }}
|
||||
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||
run: pnpm --filter @mintel/web run check:forms
|
||||
|
||||
# ────────────────────────────────────────────────────
|
||||
# 3. Performance (Lighthouse)
|
||||
# ────────────────────────────────────────────────────
|
||||
lighthouse:
|
||||
name: 🎭 Lighthouse
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: 🔐 Registry Auth
|
||||
run: |
|
||||
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||
- name: 📦 Cache node_modules
|
||||
uses: actions/cache@v4
|
||||
id: cache-deps
|
||||
with:
|
||||
path: node_modules
|
||||
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
- name: Install
|
||||
if: steps.cache-deps.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
pnpm store prune
|
||||
pnpm install --no-frozen-lockfile
|
||||
- name: 🌐 Install Chrome & Dependencies
|
||||
run: |
|
||||
apt-get update && apt-get install -y libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxrandr2 libgbm1 libasound2t64 libpango-1.0-0 libcairo2
|
||||
npx puppeteer browsers install chrome || true
|
||||
- name: 🎭 Desktop
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||
PAGESPEED_LIMIT: 5
|
||||
run: pnpm --filter @mintel/web run pagespeed:test
|
||||
- name: 📱 Mobile
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||
PAGESPEED_LIMIT: 5
|
||||
run: pnpm --filter @mintel/web run pagespeed:test
|
||||
|
||||
# ────────────────────────────────────────────────────
|
||||
# 4. Link Check & Dependency Audit
|
||||
# ────────────────────────────────────────────────────
|
||||
links:
|
||||
name: 🔗 Links & Deps
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: 🔐 Registry Auth
|
||||
run: |
|
||||
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||
- name: 📦 Cache node_modules
|
||||
uses: actions/cache@v4
|
||||
id: cache-deps
|
||||
with:
|
||||
path: node_modules
|
||||
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
- name: Install
|
||||
if: steps.cache-deps.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
pnpm store prune
|
||||
pnpm install --no-frozen-lockfile
|
||||
- name: 📦 Depcheck
|
||||
continue-on-error: true
|
||||
run: pnpm dlx depcheck --ignores="*eslint*,*typescript*,*tailwindcss*,*postcss*,*prettier*,*@types/*,*husky*,*lint-staged*,*@next/*,*@lhci/*,*commitlint*,*cspell*,*rimraf*,*@payloadcms/*,*start-server-and-test*,*html-validate*,*critters*,*dotenv*,*turbo*" || true
|
||||
- name: 🔗 Lychee Link Check
|
||||
uses: lycheeverse/lychee-action@v2
|
||||
continue-on-error: true
|
||||
with:
|
||||
args: --accept 200,204,429 --timeout 10 --insecure --exclude "file://*" --exclude "https://logs.infra.mintel.me/*" --exclude "https://git.infra.mintel.me/*" --exclude "https://mintel.me/*" '*.md' 'docs/*.md'
|
||||
fail: false
|
||||
|
||||
# ────────────────────────────────────────────────────
|
||||
# 5. Notification
|
||||
# ────────────────────────────────────────────────────
|
||||
notify:
|
||||
name: 🔔 Notify
|
||||
needs: [static, e2e, lighthouse, links]
|
||||
if: failure()
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: 🔔 Gotify
|
||||
shell: bash
|
||||
run: |
|
||||
STATIC="${{ needs.static.result }}"
|
||||
E2E="${{ needs.e2e.result }}"
|
||||
LIGHTHOUSE="${{ needs.lighthouse.result }}"
|
||||
LINKS="${{ needs.links.result }}"
|
||||
|
||||
if [[ "$STATIC" != "success" || "$LIGHTHOUSE" != "success" ]]; then
|
||||
PRIORITY=8
|
||||
EMOJI="🚨"
|
||||
STATUS="Failed"
|
||||
else
|
||||
PRIORITY=2
|
||||
EMOJI="✅"
|
||||
STATUS="Passed"
|
||||
fi
|
||||
|
||||
TITLE="$EMOJI ${{ env.PROJECT_NAME }} QA $STATUS"
|
||||
MESSAGE="Static: $STATIC | E2E: $E2E | Lighthouse: $LIGHTHOUSE | Links: $LINKS
|
||||
${{ env.TARGET_URL }}"
|
||||
|
||||
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||
-F "title=$TITLE" \
|
||||
-F "message=$MESSAGE" \
|
||||
-F "priority=$PRIORITY" || true
|
||||
+15
@@ -61,3 +61,18 @@ backups/
|
||||
_at-mintel/
|
||||
local_build_*.log
|
||||
*.tar
|
||||
|
||||
# scratch and generated artifacts (never commit)
|
||||
*.tmp
|
||||
*.log
|
||||
tmp.txt
|
||||
wget_out.txt
|
||||
build*.log
|
||||
public_test.html
|
||||
optimized_output.md
|
||||
|
||||
# test, coverage and mutation output
|
||||
/coverage
|
||||
apps/web/coverage/
|
||||
apps/web/reports/
|
||||
.stryker-tmp/
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
pnpm exec lint-staged
|
||||
pnpm --filter @mintel/web typecheck
|
||||
pnpm --filter @mintel/web test
|
||||
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
# Pushing is gated harder than committing: full coverage, and mutation when the domain core changed.
|
||||
pnpm --filter @mintel/web test:coverage || exit 1
|
||||
|
||||
base="$(git merge-base HEAD origin/main 2>/dev/null || git rev-list --max-parents=0 HEAD | tail -n 1)"
|
||||
if git diff --name-only "$base"...HEAD | grep -q '^apps/web/src/domain/'; then
|
||||
echo "src/domain changed: running mutation gate (about 2 minutes)"
|
||||
pnpm --filter @mintel/web test:mutation || exit 1
|
||||
fi
|
||||
@@ -0,0 +1,117 @@
|
||||
# mintel.me
|
||||
|
||||
Personal website of a senior freelance engineer. Next.js 16 (App Router), pnpm monorepo.
|
||||
App lives in `apps/web`. Social media videos live in `apps/video` (Remotion, standalone, own `CLAUDE.md`). Concept and scope: `apps/web/plans/freelance-relaunch.md` (read it first).
|
||||
|
||||
## Commands (run from repo root)
|
||||
|
||||
- `pnpm --filter @mintel/web typecheck` — must pass with zero errors
|
||||
- `pnpm --filter @mintel/web test` — unit specs (Vitest); 0 tests run counts as a failure
|
||||
- `pnpm --filter @mintel/web test:coverage` — `src/domain` must stay at 100%
|
||||
- `pnpm --filter @mintel/web test:mutation` — Stryker on `src/domain`, ~2 min, must score 100
|
||||
- `pnpm --filter @mintel/web lint`
|
||||
|
||||
## How to work here (spec first)
|
||||
|
||||
1. Write the behavior as concrete cases (inputs, outputs, edge cases, errors) as a `*.spec.ts`.
|
||||
2. Run it and confirm it fails for the right reason (missing behavior, not an import error).
|
||||
3. Implement the simplest clean solution, run again.
|
||||
4. Run typecheck, coverage and mutation. Survivors mean weak specs or dead code: fix one or the other.
|
||||
5. Report the exact commands and results. "Done" means they were run on the current code.
|
||||
Never weaken, skip or delete a test to make it pass.
|
||||
|
||||
## Copy rules (client-facing pages)
|
||||
|
||||
- Never mention AI as the owner's tool, and no internal method vocabulary (specs, TDD, mutation, coverage,
|
||||
pull requests). The automation service may name AI as part of what the client gets. Plain language.
|
||||
- Modest and factual: no promises the owner did not confirm, no superlatives. See the concept, section 1.
|
||||
- Two content pages. The homepage tells one story from top to bottom (who, what you need, examples, how
|
||||
working together goes, results, questions, contact); each section opens with a `bridge` sentence from
|
||||
`story` in the content files that leads over from the one before. The technical sections (project fit,
|
||||
tools, how a change is secured) live on `/freelance` (`/en/freelance`), for people who develop themselves
|
||||
or lead a team. Do not move technical detail back onto the homepage.
|
||||
- The header has the link to the freelance page, the language switch and "Get in touch", which jumps to the
|
||||
contact section (`#contact`). Contact is one email address: no calendar integration, no form. The only
|
||||
other pages are the imprint and the privacy notice. URLs of the previous site redirect to `/`
|
||||
(see `next.config.mjs`).
|
||||
- Title, description and link previews come from `site` in `src/content/de.ts` and `en.ts`.
|
||||
The share image is a screenshot of the real hero, one per language: after changing the hero headline or
|
||||
its design, run `pnpm --filter @mintel/web share-image` and commit the PNGs in `src/assets/share/`.
|
||||
- The email address is kept in parts (`src/content/site.ts`) and assembled in the browser by `EmailLink`.
|
||||
Never write the full address into markup, metadata or copy.
|
||||
- The hero design (binary-digit headline) is approved: do not change its design.
|
||||
- Look at the result before reporting: take screenshots (desktop and mobile), do not ship blind.
|
||||
|
||||
## Two languages (German is the main one)
|
||||
|
||||
- German lives at the root (`/`, `/freelance`, `/impressum`, `/datenschutz`), English under `/en`
|
||||
(`/en/freelance`, `/en/imprint`, `/en/privacy`). The app directory renders both under `app/[locale]`; `proxy.ts` maps the public addresses
|
||||
onto it. All routing and detection rules are pure functions in `src/domain/routes.ts` and `locale.ts`.
|
||||
- Detection: an address without a language follows the `lang` cookie (set only by the language switch), then
|
||||
the browser's `Accept-Language`, then German. `/en/...` is never redirected.
|
||||
- Every visible text is data in `src/content/de.ts` and `en.ts` (type `Content` in `types.ts`). Components
|
||||
hold no texts: server components take them from `contentFor(locale)`, client components from
|
||||
`useContent()`. A new text goes into both files; German uses the formal "Sie".
|
||||
- Never name a file `src/i18n.ts` or `i18n/request.ts`: `@mintel/next-config` would switch on next-intl.
|
||||
- The privacy notice describes the language cookie; keep it true when the detection changes.
|
||||
|
||||
## Motion (binary micro-interactions)
|
||||
|
||||
- Building blocks live in `apps/web/src/components/bits/`: `DecodeText` (mono labels resolving from digits),
|
||||
`BitWipe` (headline revealed behind a column of digits), `BitReveal` (picture assembling from bits),
|
||||
`BinaryTicker`, `ScrollBits`, `BitBurst`, `BitCurtain` (the language switch transition), `BitAura` (light
|
||||
along the edge of an element, digits drifting off it; glow only on dark surfaces, on light ones just a
|
||||
few blue digits), `BitSeam` (lit top edge of a dark section), `BitGlow` (light following the pointer on
|
||||
`data-glow` surfaces). Reuse them instead of inventing new effects.
|
||||
- The homepage is one story with a visible thread (`apps/web/src/components/story/`). Every chapter opens
|
||||
with one sentence from `story` in the content files; read one after the other they tell the way of a
|
||||
project from the idea to the first email. `StoryThread` is one trace down the whole page, in the look of a circuit
|
||||
board (45 degree bends, solder points, a second parallel track), that draws itself as the visitor scrolls:
|
||||
under each sentence it crosses the page, stands a component with the chapter number in binary on the track
|
||||
and continues on the other side, with a bit (1/0) travelling at its head. `Block` prop `chapter` gives a section its sentence and
|
||||
figure; how much of the line is drawn is decided in `src/domain/thread.ts`. A new section on the homepage
|
||||
needs a sentence that continues the story, or it breaks the thread. No rails, no "next question" links
|
||||
(owner rejected both).
|
||||
- Drawings use the circuit parts in `apps/web/src/components/illustrations/circuit.tsx`: `Chip` (a system
|
||||
or person, with pins), `Track` (straight runs, 45 degree bends), `Pad` (solder point), `Bit` (what travels).
|
||||
New drawings are built from these, not from pills, curves and round dots.
|
||||
- Two audiences, two pages (owner, 2026-10-05). The homepage is for people who commission a result and have
|
||||
no software of their own: a website or process automation (plus web presentations). It shows no project prices
|
||||
and no hourly terms, only "ask for an offer"; the one price there is the care offer for websites (89 EUR
|
||||
per month, see the concept, section 5). Everything for companies that book by the hour is on the
|
||||
freelance page only: the developer case, making an existing app reliable, how working together goes,
|
||||
project fit, tools, safeguards, rates and booking. Do not put any of that back on the homepage.
|
||||
- `DecodeText` is for monospaced text only; in proportional type the changing glyph widths make text jump.
|
||||
- Never put an in-view trigger on an element that clips itself away (clip-path): a fully clipped element
|
||||
never counts as visible. Observe an unclipped wrapper (see `BitWipe`).
|
||||
- Every effect must respect `prefers-reduced-motion` and leave content readable without JavaScript.
|
||||
|
||||
## Architecture rules
|
||||
|
||||
- Functional core, imperative shell. Pure logic goes in `apps/web/src/domain/` (no I/O, no clock, no
|
||||
randomness, no framework imports; pass `today` etc. in as arguments). Everything there is covered 100%
|
||||
and mutation-tested. Pages, routes and adapters stay thin and call into the core.
|
||||
- Parse at the boundary into precise types (`Result`, discriminated unions); no nullable state bags.
|
||||
- No `any`, `@ts-ignore`, `!` non-null assertions or eslint-disable to silence errors. Fix the cause.
|
||||
|
||||
## Do not
|
||||
|
||||
- Read or edit `.env*` (only `.env.example`). Never print or log secrets.
|
||||
- Commit scratch files (`tmp*`, `*.log`, `*.tmp`, debug HTML, build output). They are gitignored; keep it so.
|
||||
- Use `--no-verify`, force-push, push to `main`, or run `reset --hard`, `clean`, `rm -rf` without asking.
|
||||
- Edit `.gitea/workflows/*` or deploy config without asking.
|
||||
- Upgrade `vitest` past 4.1.x: `@stryker-mutator/vitest-runner` 10.0.0 runs zero tests per mutant under
|
||||
vitest 5 and reports false survivors. Re-verify mutation results after any tooling bump.
|
||||
- Add dependencies for trivial things, or touch unrelated code (no drive-by refactors).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `tsconfig` includes `.next/types`: after deleting a route, stale generated files there break `tsc`.
|
||||
Run `next typegen` and remove the stale file.
|
||||
- Run only one Stryker process at a time; parallel runs share `.stryker-tmp` and hang.
|
||||
- Stryker needs `plugins` listed explicitly in `stryker.config.json` (pnpm layout) and ignores the large
|
||||
asset folders via `ignorePatterns`; keep them in sync when adding top-level folders.
|
||||
- `@mintel/*` packages are `link:` dependencies on a sibling `at-mintel` checkout. The app itself now only
|
||||
uses `@mintel/next-config`; the other entries in `package.json` are leftovers from the old site.
|
||||
- Pushing `main` deploys to the testing environment; production is deployed from a `v*` tag.
|
||||
- Git branches: work on `feat/*`, never directly on `main`.
|
||||
+5
-6
@@ -13,8 +13,7 @@ ARG S3_SECRET_KEY
|
||||
ARG S3_BUCKET
|
||||
ARG S3_REGION
|
||||
ARG S3_PREFIX
|
||||
ARG DATABASE_URI
|
||||
ARG PAYLOAD_SECRET
|
||||
|
||||
ARG BUILD_ID
|
||||
|
||||
# Environment variables for Next.js build
|
||||
@@ -27,8 +26,7 @@ ENV S3_SECRET_KEY=$S3_SECRET_KEY
|
||||
ENV S3_BUCKET=$S3_BUCKET
|
||||
ENV S3_REGION=$S3_REGION
|
||||
ENV S3_PREFIX=$S3_PREFIX
|
||||
ENV DATABASE_URI=$DATABASE_URI
|
||||
ENV PAYLOAD_SECRET=$PAYLOAD_SECRET
|
||||
|
||||
ENV SKIP_RUNTIME_ENV_VALIDATION=true
|
||||
ENV NEXT_BUILD_WORKERS=1
|
||||
ENV NEXT_DISABLE_SOURCEMAPS=true
|
||||
@@ -75,12 +73,13 @@ WORKDIR /app
|
||||
|
||||
# Copy standalone output and static files (Monorepo paths)
|
||||
# Note: Base image already handles the non-root user and basic env
|
||||
COPY --from=builder --chown=1001:65533 /app/apps/web/public ./apps/web/public
|
||||
# Fallback target: Next.js sometimes resolves from the process root depending on version
|
||||
COPY --from=builder --chown=1001:65533 /app/apps/web/public ./public
|
||||
COPY --from=builder --chown=1001:65533 /app/apps/web/.next/standalone ./
|
||||
COPY --from=builder --chown=1001:65533 /app/apps/web/.next/static ./apps/web/.next/static
|
||||
|
||||
# Explicitly copy Payload dynamically generated importMap.js excluded by Standalone tracing
|
||||
COPY --from=builder --chown=1001:65533 /app/apps/web/app/(payload)/admin/importMap.js ./apps/web/app/(payload)/admin/importMap.js
|
||||
|
||||
|
||||
# Fix permissions for the non-root user (Standard uid/gid from base image)
|
||||
USER nextjs
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
import { lexicalEditor, BlocksFeature } from "@payloadcms/richtext-lexical";
|
||||
import { allBlocks } from "../blocks/allBlocks";
|
||||
|
||||
export const Posts: CollectionConfig = {
|
||||
slug: "posts",
|
||||
admin: {
|
||||
useAsTitle: "title",
|
||||
},
|
||||
access: {
|
||||
read: () => true, // Publicly readable API
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "aiOptimizer",
|
||||
type: "ui",
|
||||
admin: {
|
||||
position: "sidebar",
|
||||
components: {
|
||||
Field: "@/src/payload/components/OptimizeButton#OptimizeButton",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "title",
|
||||
type: "text",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "slug",
|
||||
type: "text",
|
||||
required: true,
|
||||
unique: true,
|
||||
admin: {
|
||||
position: "sidebar",
|
||||
},
|
||||
hooks: {
|
||||
beforeValidate: [
|
||||
({ value, data }) => {
|
||||
if (value) return value;
|
||||
if (data?.title) {
|
||||
return data.title
|
||||
.toLowerCase()
|
||||
.replace(/ /g, "-")
|
||||
.replace(/[^\w-]+/g, "");
|
||||
}
|
||||
return value;
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "description",
|
||||
type: "text",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "date",
|
||||
type: "date",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "tags",
|
||||
type: "array",
|
||||
required: true,
|
||||
fields: [
|
||||
{
|
||||
name: "tag",
|
||||
type: "text",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "featuredImage",
|
||||
type: "upload",
|
||||
relationTo: "media",
|
||||
admin: {
|
||||
description: "The main hero image for the blog post.",
|
||||
position: "sidebar",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "content",
|
||||
type: "richText",
|
||||
editor: lexicalEditor({
|
||||
features: ({ defaultFeatures }) => [
|
||||
...defaultFeatures,
|
||||
BlocksFeature({
|
||||
blocks: allBlocks,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
out
|
||||
.remotion
|
||||
@@ -0,0 +1,102 @@
|
||||
# apps/video
|
||||
|
||||
Social media videos (LinkedIn first, 16:9 for YouTube and Google Ads) in the mintel.me look, made with Remotion.
|
||||
Standalone package: it is **not** in `pnpm-workspace.yaml`, so it never ends up in the web build, the Dockerfile
|
||||
or `pnpm -r`. `apps/web` must never import from here, and this package imports nothing from `apps/web`.
|
||||
|
||||
## Commands (run from this folder)
|
||||
|
||||
- Install: `pnpm install --ignore-workspace`
|
||||
- Studio (the playground, http://localhost:3000): `pnpm studio`
|
||||
- One frame as PNG: `pnpm still WebsiteDone-de-feed out/check.png --frame=40`
|
||||
- One video: `pnpm render WebsiteDone-de-feed out/website.mp4`
|
||||
- The whole library: `pnpm library` (= `node scripts/export.ts`; `--only <VideoId>`, `--formats feed,wide`,
|
||||
`--page` to rewrite only post texts and the overview). Writes `out/library/index.html` (watch, copy the post
|
||||
text, download) and `out/library/<language>/week-<n>/<slot>-<day>-<id>/` with the files and `post.txt`, in plan order.
|
||||
- `pnpm typecheck`, `pnpm test`
|
||||
|
||||
Compositions are named `<VideoId>-<language>-<format>`: languages `en`, `de`; formats `feed` (4:5, LinkedIn),
|
||||
`wide` (16:9), `square`, `story` (9:16). `out/` is gitignored and holds only two things: `out/library`
|
||||
(what gets posted) and `out/tmp` (your check frames, sheets and logs; delete it when you are done).
|
||||
|
||||
## Where things are
|
||||
|
||||
- `src/content/videos.ts`: every word. Headlines per slide, the post text per video, both languages, and the
|
||||
German for labels inside drawings (`LABELS_DE`). Drawing code writes labels in English; they are translated
|
||||
when rendered. German addresses the viewer as "Sie".
|
||||
- `src/compositions/<Video>.tsx`: the slides of one video (ids, durations, illustrations). No text.
|
||||
- `src/slides`: `SlideDeck` (panel, headline, curtain, loop, portrait and 16:9 layout), `ServiceVideo`
|
||||
(frame + language + deck), `ctaSlide` (closing slide with the button).
|
||||
- `src/illustrations`: `parts.tsx` (tile, icon, cursor, data chip, window, page, badges, day box), `scenes.tsx`
|
||||
(scenes several videos share), `motion.ts` (easing helpers).
|
||||
- `src/brand`: tokens, fonts, `BrandFrame` (logo, labels, digit texture), `Panel` (the dark panel with the
|
||||
website's light effects), `BrandButton`.
|
||||
- `src/lib`: pure, tested logic (`bits.ts`, `aura.ts`).
|
||||
|
||||
## How to make a new video
|
||||
|
||||
1. Add the texts to `src/content/videos.ts` (new `VideoId`, both languages, post text).
|
||||
2. Add `src/compositions/<Name>.tsx` with its slides and register it in `src/Root.tsx`.
|
||||
3. Look before reporting: render the MP4, pull one frame per slide and read them, in `feed` and `wide`, in both
|
||||
languages (German lines are longer). Do not hand over a video you have not seen.
|
||||
4. `pnpm library --only <Name>` and tell the owner where the files are.
|
||||
|
||||
## Films (the current format, owner approved 2026-10-04)
|
||||
|
||||
Every video is a film in `src/films`: one continuous shot on a dark stage, not a deck of slides.
|
||||
- Shared: `FilmStage` (backdrop, one centred statement at a time, signature, address) and `shot.ts`
|
||||
(one timeline `T`, the camera move `cameraAt`, the six caption slots hook/reveal/problem/turn/calm/cta).
|
||||
- The pattern: close on something abstract that looks like something else ("Looks like ping-pong."), the
|
||||
camera pulls back and shows what it really is ("It's your office."), it stops, resolves, rests, and
|
||||
returns to the opening picture so the film loops. Frame 0 is the preview image.
|
||||
- Minimal and calm (owner feedback: earlier versions were hectic). Only squares, dots, lines and bars; no
|
||||
icons or labels in the drawing. One statement of two short lines, held for about three seconds.
|
||||
A little sarcasm in the hook is welcome. German has to read like a native wrote it.
|
||||
- One font size per film: the largest at which the longest line fits, so keep lines short.
|
||||
- The logo is the white signature (`public/logo-mark.svg`): bottom left, and very large and faint behind.
|
||||
- The slide deck code (`src/slides/SlideDeck`, `src/illustrations`, `BrandFrame`, `Panel`) is no longer used
|
||||
by any video and can be removed once the owner confirms.
|
||||
|
||||
## Image posts and the posting plan
|
||||
|
||||
- Image posts are made the same way as films: `src/content/images.ts` holds every word and, for a poster, which
|
||||
film and frame it shows; `src/images/ImagePost.tsx` draws them. A poster is a film rendered at one frame with
|
||||
two lines ("Not ping-pong. Your office."); a card is a client's question with the answer in large type.
|
||||
Cards say only what the website says. Compositions: `<ImageId>-<language>-<feed|square>`.
|
||||
- `src/content/plan.ts` is the posting plan (which film or image in which week); the reasoning is in
|
||||
`apps/web/plans/content-strategy.md`. The spec checks that every film and image is planned exactly once
|
||||
and that every video post opens with the hook of its film.
|
||||
- `pnpm library` renders films and images and writes the overview page in plan order;
|
||||
`--images`, `--videos`, `--only <id>`, `--page`.
|
||||
|
||||
## Rules
|
||||
|
||||
- Before any work: bring the worktree up to date with `main` (owner feedback, twice). Take wording, styles and
|
||||
effects from the website as it is **now**: texts in `apps/web/src/content/de.ts` and `en.ts`, styles in
|
||||
`apps/web/app/globals.css`, drawings in `apps/web/src/components/illustrations`. Do not state prices, terms
|
||||
or durations that are not in the texts.
|
||||
- Picture first, few words (owner feedback): 6 to 8 slides of about 2 s, each one large animated illustration
|
||||
plus a headline of two to four words. No paragraphs, no captions.
|
||||
- It has to look stunning (owner feedback, repeated: "not enough yet"). The videos carry every effect of the
|
||||
website: the dark `Panel` with `BitAura` (light along the edge, digits drifting off), `BitGlow` (light over
|
||||
the dot grid) and `bit-aurora` (indigo light drifting behind the drawing); slides change with the `BitCurtain`
|
||||
(ragged front of digits, `src/lib/curtain.ts`); the button has the `bit-cta` light running round its edge.
|
||||
When the website gets a new effect, bring it here as a pure function of the frame in `src/lib`.
|
||||
- Drawings follow `PathScenes.tsx`: fine light lines, tinted glass surfaces (`DRAW.tint`, `DRAW.glass`) instead
|
||||
of solid blocks, amber packets with a halo (`DRAW.mark`), emerald for done, soft glows on what is active.
|
||||
- UI in a video must be the website's real UI, rebuilt faithfully (owner feedback): read the component in
|
||||
`apps/web/src/components` first. The button is `BrandButton` (twin of `Button.tsx`); do not draw your own.
|
||||
- Almost no frame (owner feedback: the drawing matters far more than what surrounds it). No border, no name,
|
||||
no topic label, no progress bar. Only one small line at the bottom: the logo, small and dimmed, and
|
||||
`www.mintel.me` (with `www.` so that it reads as an address). The stage is scaled as large as the frame allows.
|
||||
- Light is drawn with plain gradients that fade to transparent. No `filter: blur`, `mix-blend-mode` or dashed
|
||||
strokes for glows: they came out with hard edges in the rendered video (owner feedback).
|
||||
- Every video loops: the first slide is complete on frame 0 (it is the preview image) and fades back in at the
|
||||
end. Anything that moves outside the slides (`Panel`) must repeat exactly over the length of the video.
|
||||
- Frame-driven only: all motion comes from `useCurrentFrame()` / `interpolate()`. No `setTimeout`,
|
||||
`setInterval`, CSS animations, `Math.random()` or framer-motion: they do not render correctly frame by frame.
|
||||
Pure logic goes in `src/lib` with a `*.spec.ts`.
|
||||
- 30 fps, about 15 seconds. The video must work without sound and make sense in the first two seconds.
|
||||
- Copy rules as on the website (see root `CLAUDE.md`): no AI as the owner's tool, no internal method vocabulary,
|
||||
modest and factual, no promises the owner did not confirm. Never write the full email address.
|
||||
- The hero design of the website (binary-digit headline) is approved: do not recreate it here with changes.
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@mintel/video",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Social media videos in the mintel.me look. Standalone: not part of the pnpm workspace, never imported by apps/web.",
|
||||
"scripts": {
|
||||
"studio": "remotion studio src/index.ts",
|
||||
"render": "remotion render src/index.ts",
|
||||
"still": "remotion still src/index.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"library": "node scripts/export.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@remotion/cli": "4.0.532",
|
||||
"@remotion/google-fonts": "4.0.532",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"remotion": "4.0.532"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/react": "^19.2.0",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "~4.1.11"
|
||||
}
|
||||
}
|
||||
Generated
+3632
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 29 KiB |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 745 744" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><rect id="Favicon" x="0.855" y="0.277" width="743.633" height="743.633" style="fill:none;"/><path d="M739.657,49.961c-0,-24.755 -20.098,-44.852 -44.853,-44.852l-644.264,-0c-24.755,-0 -44.853,20.097 -44.853,44.852l-0,644.265c-0,24.755 20.098,44.853 44.853,44.853l644.264,-0c24.755,-0 44.853,-20.098 44.853,-44.853l-0,-644.265Z"/><path d="M135.778,286.282c0.157,0.236 0.52,0.761 0.852,1.092c1.2,1.192 2.956,2.358 5.296,3.587l0.005,0.003l2,1.327l1.693,1.752l1.239,2.072l0.713,2.215l0.211,2.192l-0.208,2.068l-1.385,3.646l-2.46,2.971l-1.634,1.187l-1.911,0.914l-2.161,0.551l-2.323,0.093l-2.343,-0.412l-2.211,-0.892c-11.589,-6.087 -16.814,-12.713 -18.39,-18.621c-1.835,-6.879 0.575,-13.751 6.509,-19.965c12.824,-13.43 44.389,-24.257 57.136,-27.454l0.001,-0c30.563,-7.658 62.165,-12.34 93.648,-13.693c25.54,-1.098 54.423,-1.803 78.831,7.271c15.51,5.768 26.031,14.726 32.759,25.559c56.484,-46.707 118.893,-93.116 187.414,-119.225c10.958,-4.178 27.246,-8.604 40.339,-7.415c7.908,0.718 14.744,3.442 19.461,8.573c4.662,5.072 7.541,12.672 6.521,23.945c-3.634,40.091 -31.543,80.109 -52.575,112.911c-25.848,40.321 -53.663,79.557 -82.723,117.821c29.191,-23.305 58.382,-46.61 87.573,-69.913l1.731,-1.145l1.85,-0.796l3.75,-0.585l3.526,0.562l3.17,1.552l2.672,2.595l1.028,1.728l0.705,1.995l0.285,2.178l-0.185,2.22l-0.631,2.115l-0.992,1.91c-10.505,16.533 -21.014,33.063 -31.523,49.592l-0.001,0.003c-1.852,2.909 -11.995,19.195 -18.14,30.842c-0.096,0.182 -0.192,0.366 -0.288,0.553c13.673,-3.721 27.615,-13.517 38.776,-19.936c10.441,-6.004 20.778,-12.208 30.865,-18.787l0.003,-0.002l2.134,-1.1l2.356,-0.626l2.421,-0.072l2.289,0.463l2.022,0.893l1.703,1.201l2.495,3.003l1.373,3.598l0.232,1.999l-0.139,2.106l-0.573,2.146l-1.048,2.067l-1.498,1.843l-1.854,1.496l-0.007,0.005c-15.715,10.242 -31.914,19.975 -48.604,28.526c-6.986,3.579 -18.808,10.744 -29.918,13.789c-9.41,2.579 -18.37,2.143 -24.958,-2.988c-5.648,-4.398 -7.104,-11.077 -5.042,-18.895c3.104,-11.773 15.551,-27.001 19.276,-32.858l2.604,-4.095c-37.274,29.759 -74.551,59.518 -111.826,89.274l-1.82,1.214l-2.004,0.868l-2.111,0.471l-2.12,0.061l-2.039,-0.329l-1.892,-0.676l-3.201,-2.224l-2.259,-3.202l-0.689,-1.912l-0.33,-2.068l0.077,-2.148l0.5,-2.129l0.902,-2.005l1.242,-1.804c59.233,-71.996 118.215,-147.452 163.946,-228.856l0.002,-0.003c3.532,-6.277 19.498,-32.912 25.637,-54.458c1.456,-5.11 2.365,-9.885 2.213,-13.918c-0.128,-3.403 -1.052,-6.169 -4.397,-6.847c-6.883,-1.395 -14.409,-0.182 -21.911,2.075c-12.591,3.787 -25.072,10.598 -34.871,15.502l-0.002,0.001c-37.202,18.606 -72.519,40.846 -106.083,65.396c-19.253,14.077 -38.067,28.773 -56.448,44.009c5.948,31.922 -8.725,71.663 -25.261,97.617c-26.624,41.789 -61.114,78.396 -97.084,112.241c-35.155,33.081 -71.676,66.504 -111.219,94.355l-0.002,0.001c-4.905,3.453 -13.056,9.944 -21.245,13.763c-7.008,3.268 -14.124,4.488 -20.021,2.432c-7.666,-2.674 -10.711,-8.481 -11.105,-15.23c-0.288,-4.928 1.117,-10.472 2.745,-14.802l0.001,-0.003c17.424,-46.26 54.722,-88.018 86.091,-125.21c52.005,-61.657 108.894,-119.681 170.402,-171.929c-5.142,-9.861 -13.608,-17.675 -25.833,-20.957c-27.596,-7.404 -57.826,-6.098 -86.019,-3.428c-30.452,2.883 -61.745,7.625 -90.667,17.984c-6.667,2.388 -17.118,6.215 -22.892,11.134c-0.89,0.758 -1.884,2.115 -2.149,2.485Zm-6.046,295.699c3.853,-1.713 7.757,-4.116 11.623,-6.805c12.067,-8.393 23.538,-19.805 31.614,-26.433c45.641,-37.472 90.707,-76.66 129.793,-121.083c26.644,-30.283 57.988,-66.814 65.641,-107.833c1.02,-5.466 1.414,-11.09 1.137,-16.634c-41.419,35.852 -80.575,74.39 -117.54,114.67c-41.853,45.61 -85.416,93.945 -115.619,148.393c-0.671,1.213 -4.049,9.375 -6.649,15.725Z" style="fill:#fff;"/></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,7 @@
|
||||
import { Config } from "@remotion/cli/config";
|
||||
|
||||
Config.setVideoImageFormat("jpeg");
|
||||
Config.setCodec("h264");
|
||||
Config.setPixelFormat("yuv420p");
|
||||
Config.setCrf(16);
|
||||
Config.setOverwriteOutput(true);
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Renders the content library: every film and every image post in every language, with the text
|
||||
* to post next to it, and an overview page in the order of the posting plan.
|
||||
*
|
||||
* node scripts/export.ts everything
|
||||
* node scripts/export.ts --images only the image posts
|
||||
* node scripts/export.ts --videos only the films
|
||||
* node scripts/export.ts --only WebsiteDone one film or one image
|
||||
* node scripts/export.ts --formats feed one video format (feed, wide, square, story)
|
||||
* node scripts/export.ts --page only rewrite post texts and the overview page
|
||||
*
|
||||
* Output, in the order of the posting plan:
|
||||
* out/library/index.html
|
||||
* out/library/<language>/week-<n>/<slot>-<day>-<id>/ the files to upload and post.txt
|
||||
* Anything else in out/ is scratch: put checks and logs into out/tmp, which can be deleted at any time.
|
||||
* Run one export at a time, and redirect its log with `>|`, not `>`.
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { IMAGES, IMAGE_IDS, type ImageId } from "../src/content/images.ts";
|
||||
import { PLAN, POSTING_DAYS, type PlannedPost } from "../src/content/plan.ts";
|
||||
import { COPY, LANGS, VIDEO_IDS, type Lang, type VideoId } from "../src/content/videos.ts";
|
||||
|
||||
const LIBRARY = join("out", "library");
|
||||
const DEFAULT_VIDEO_FORMATS = ["feed", "wide"];
|
||||
const IMAGE_FORMATS = ["feed", "square"];
|
||||
const FORMAT_NOTE: Readonly<Record<string, string>> = {
|
||||
feed: "4:5, LinkedIn feed",
|
||||
wide: "16:9, YouTube and Google Ads",
|
||||
square: "1:1",
|
||||
story: "9:16, vertical",
|
||||
};
|
||||
|
||||
const option = (name: string): string | undefined => {
|
||||
const index = process.argv.indexOf(`--${name}`);
|
||||
return index === -1 ? undefined : process.argv[index + 1];
|
||||
};
|
||||
const flag = (name: string): boolean => process.argv.includes(`--${name}`);
|
||||
|
||||
const only = option("only");
|
||||
const videoFormats = option("formats")?.split(",") ?? DEFAULT_VIDEO_FORMATS;
|
||||
const pageOnly = flag("page");
|
||||
const wanted = (id: string): boolean => only === undefined || id === only;
|
||||
const videoIds = flag("images") ? [] : VIDEO_IDS.filter(wanted);
|
||||
const imageIds = flag("videos") ? [] : IMAGE_IDS.filter(wanted);
|
||||
if (videoIds.length + imageIds.length === 0) {
|
||||
throw new Error(`Nothing to export for "${only}". Known: ${[...VIDEO_IDS, ...IMAGE_IDS].join(", ")}`);
|
||||
}
|
||||
|
||||
/** Where a post lives inside the library: by language, then week, then day, as it will be posted. */
|
||||
const place = (id: string, lang: Lang): string => {
|
||||
for (const [weekIndex, week] of PLAN.entries()) {
|
||||
const slot = week.posts.findIndex((post) => post.id === id);
|
||||
if (slot !== -1) {
|
||||
return `${lang}/week-${weekIndex + 1}/${slot + 1}-${(POSTING_DAYS[slot] ?? "").toLowerCase()}-${id}`;
|
||||
}
|
||||
}
|
||||
throw new Error(`${id} is not in the posting plan (src/content/plan.ts)`);
|
||||
};
|
||||
const folder = (id: string, lang: Lang): string => join(LIBRARY, place(id, lang));
|
||||
const fileName = (id: string, lang: Lang, format: string, extension: string): string =>
|
||||
`${id}-${lang}-${format}.${extension}`;
|
||||
|
||||
/** Runs a Remotion command and makes sure it really wrote the file. */
|
||||
const render = (command: "render" | "still", composition: string, target: string, frame = 0): void => {
|
||||
console.log(`Rendering ${target}`);
|
||||
const startedAt = Date.now();
|
||||
const extra = command === "still" ? [`--frame=${frame}`] : [];
|
||||
execFileSync("pnpm", ["exec", "remotion", command, "src/index.ts", composition, target, "--log=error", ...extra], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
// A render can end without an error and without a new file (seen with two renders started at once).
|
||||
if (!existsSync(target) || statSync(target).mtimeMs < startedAt) {
|
||||
throw new Error(`${target} was not written. Run one export at a time.`);
|
||||
}
|
||||
};
|
||||
|
||||
const postText = (kind: PlannedPost["kind"], id: string, lang: Lang): string =>
|
||||
kind === "video" ? COPY[id as VideoId][lang].post : IMAGES[id as ImageId].copy[lang].post;
|
||||
const title = (kind: PlannedPost["kind"], id: string, lang: Lang): string =>
|
||||
kind === "video" ? COPY[id as VideoId][lang].title : IMAGES[id as ImageId].copy[lang].title;
|
||||
|
||||
for (const lang of LANGS) {
|
||||
for (const id of videoIds) {
|
||||
mkdirSync(folder(id, lang), { recursive: true });
|
||||
writeFileSync(join(folder(id, lang), "post.txt"), `${postText("video", id, lang)}\n`);
|
||||
if (pageOnly) continue;
|
||||
for (const format of videoFormats) {
|
||||
render("render", `${id}-${lang}-${format}`, join(folder(id, lang), fileName(id, lang, format, "mp4")));
|
||||
}
|
||||
}
|
||||
for (const id of imageIds) {
|
||||
mkdirSync(folder(id, lang), { recursive: true });
|
||||
writeFileSync(join(folder(id, lang), "post.txt"), `${postText("image", id, lang)}\n`);
|
||||
if (pageOnly) continue;
|
||||
for (const format of IMAGE_FORMATS) {
|
||||
render("still", `${id}-${lang}-${format}`, join(folder(id, lang), fileName(id, lang, format, "png")), IMAGES[id].poster?.frame);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const escapeHtml = (text: string): string =>
|
||||
text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
||||
|
||||
const media = (post: PlannedPost, lang: Lang): string => {
|
||||
const extension = post.kind === "video" ? "mp4" : "png";
|
||||
const shown = Object.keys(FORMAT_NOTE)
|
||||
.filter((format) => existsSync(join(folder(post.id, lang), fileName(post.id, lang, format, extension))))
|
||||
.map((format) => {
|
||||
const src = `${place(post.id, lang)}/${fileName(post.id, lang, format, extension)}`;
|
||||
const element =
|
||||
post.kind === "video"
|
||||
? `<video src="${src}" controls loop muted playsinline preload="metadata"></video>`
|
||||
: `<img src="${src}" alt="" loading="lazy">`;
|
||||
return `<figure class="${format}">${element}<figcaption>${FORMAT_NOTE[format]} · <a href="${src}" download>Download</a></figcaption></figure>`;
|
||||
})
|
||||
.join("\n");
|
||||
return shown || "<p>Not rendered yet.</p>";
|
||||
};
|
||||
|
||||
const card = (post: PlannedPost, day: string, lang: Lang): string => `<article lang="${lang}" data-lang="${lang}">
|
||||
<header><span class="tag">${day} · ${post.kind}</span><h3>${escapeHtml(title(post.kind, post.id, lang))}</h3></header>
|
||||
<div class="media">${media(post, lang)}</div>
|
||||
<div class="post">
|
||||
<div class="post-head"><span class="tag">Post text</span><button type="button" class="copy">Copy</button></div>
|
||||
<textarea readonly rows="12">${escapeHtml(postText(post.kind, post.id, lang))}</textarea>
|
||||
</div>
|
||||
</article>`;
|
||||
|
||||
const weeks = PLAN.map(
|
||||
(week, index) => `<section>
|
||||
<h2><span class="tag">Week ${index + 1}</span>${escapeHtml(week.topic)}</h2>
|
||||
${week.posts.flatMap((post, slot) => LANGS.map((lang) => card(post, POSTING_DAYS[slot] ?? "", lang))).join("\n")}
|
||||
</section>`,
|
||||
).join("\n");
|
||||
|
||||
const page = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Content library</title>
|
||||
<style>
|
||||
:root { --ink: #0f172a; --muted: #64748b; --line: #e2e8f0; --wash: #f8fafc; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: #fff; color: var(--ink); font-family: Inter, system-ui, sans-serif; }
|
||||
main { max-width: 1400px; margin: 0 auto; padding: 48px 20px 96px; }
|
||||
h1 { font-size: clamp(40px, 7vw, 88px); font-weight: 900; letter-spacing: -0.05em; line-height: 0.95; margin: 0 0 12px; }
|
||||
.intro { color: var(--muted); font-size: 18px; max-width: 62ch; margin: 0 0 24px; }
|
||||
.switch { display: flex; gap: 8px; margin-bottom: 40px; }
|
||||
section { border-top: 2px solid var(--ink); padding-top: 20px; margin-top: 56px; }
|
||||
h2 { font-size: 34px; letter-spacing: -0.03em; margin: 0 0 8px; display: flex; flex-direction: column; gap: 8px; }
|
||||
h3 { font-size: 22px; letter-spacing: -0.02em; margin: 0; }
|
||||
article { border-top: 1px solid var(--line); padding: 28px 0; display: grid; gap: 20px; }
|
||||
@media (min-width: 900px) { article { grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); } article header { grid-column: 1 / -1; } }
|
||||
body[data-show="de"] article[data-lang="en"], body[data-show="en"] article[data-lang="de"] { display: none; }
|
||||
header { display: flex; flex-direction: column; gap: 6px; }
|
||||
.tag, figcaption { font-family: ui-monospace, "JetBrains Mono", Menlo, monospace; font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--muted); }
|
||||
.media { display: flex; flex-wrap: wrap; gap: 20px; align-items: flex-start; }
|
||||
figure { margin: 0; }
|
||||
video, img { display: block; width: 100%; border-radius: 16px; background: var(--ink); }
|
||||
figure.feed, figure.square { width: min(100%, 300px); }
|
||||
figure.story { width: min(100%, 230px); }
|
||||
figure.wide { width: min(100%, 460px); }
|
||||
figcaption { margin-top: 10px; }
|
||||
a { color: var(--ink); }
|
||||
.post-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||
textarea { width: 100%; border: 1px solid var(--line); border-radius: 16px; padding: 16px; font: 15px/1.5 Inter, system-ui, sans-serif; color: var(--ink); background: var(--wash); resize: vertical; }
|
||||
button { border: 1px solid var(--ink); border-radius: 999px; background: var(--ink); color: #fff; padding: 10px 20px; font: 700 11px Inter, system-ui, sans-serif; letter-spacing: 0.18em; text-transform: uppercase; cursor: pointer; }
|
||||
button[aria-pressed="false"] { background: #fff; color: var(--ink); }
|
||||
</style>
|
||||
</head>
|
||||
<body data-show="de">
|
||||
<main>
|
||||
<h1>Content library</h1>
|
||||
<p class="intro">Six weeks, three posts each: the film on Tuesday, a card on Thursday, the poster on Saturday. The plan is in <code>src/content/plan.ts</code>, the reasoning in <code>apps/web/plans/content-strategy.md</code>.</p>
|
||||
<div class="switch">
|
||||
<button type="button" data-set="de" aria-pressed="true">Deutsch</button>
|
||||
<button type="button" data-set="en" aria-pressed="false">English</button>
|
||||
</div>
|
||||
${weeks}
|
||||
</main>
|
||||
<script>
|
||||
for (const button of document.querySelectorAll("[data-set]")) {
|
||||
button.addEventListener("click", () => {
|
||||
document.body.dataset.show = button.dataset.set;
|
||||
for (const other of document.querySelectorAll("[data-set]")) other.setAttribute("aria-pressed", String(other === button));
|
||||
});
|
||||
}
|
||||
for (const button of document.querySelectorAll(".copy")) {
|
||||
button.addEventListener("click", async () => {
|
||||
await navigator.clipboard.writeText(button.closest(".post").querySelector("textarea").value);
|
||||
button.textContent = "Copied";
|
||||
setTimeout(() => { button.textContent = "Copy"; }, 1500);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
mkdirSync(LIBRARY, { recursive: true });
|
||||
writeFileSync(join(LIBRARY, "index.html"), page);
|
||||
console.log(`Overview: ${join(LIBRARY, "index.html")}`);
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { FC } from "react";
|
||||
import { Composition } from "remotion";
|
||||
import { FORMATS, FPS, type FormatName } from "./brand/tokens";
|
||||
import { IMAGES, IMAGE_IDS } from "./content/images";
|
||||
import { LANGS } from "./content/videos";
|
||||
import { ImagePost } from "./images/ImagePost";
|
||||
import { VIDEOS } from "./registry";
|
||||
import { SHOT_FRAMES } from "./films/shot";
|
||||
import { ServiceVideo, videoDuration } from "./slides/ServiceVideo";
|
||||
|
||||
const FORMAT_NAMES = Object.keys(FORMATS) as FormatName[];
|
||||
/** Images are posted in the feed: 4:5, and square for places that crop. */
|
||||
const IMAGE_FORMATS = ["feed", "square"] as const;
|
||||
|
||||
/** Videos are `<VideoId>-<language>-<format>`, images `<ImageId>-<language>-<feed|square>`. */
|
||||
export const Root: FC = () => (
|
||||
<>
|
||||
{VIDEOS.flatMap((video) =>
|
||||
LANGS.flatMap((lang) =>
|
||||
FORMAT_NAMES.map((format) => (
|
||||
<Composition
|
||||
key={`${video.id}-${lang}-${format}`}
|
||||
id={`${video.id}-${lang}-${format}`}
|
||||
component={() => <ServiceVideo video={video} lang={lang} />}
|
||||
durationInFrames={videoDuration(video)}
|
||||
fps={FPS}
|
||||
width={FORMATS[format].width}
|
||||
height={FORMATS[format].height}
|
||||
/>
|
||||
)),
|
||||
),
|
||||
)}
|
||||
{IMAGE_IDS.flatMap((id) =>
|
||||
LANGS.flatMap((lang) =>
|
||||
IMAGE_FORMATS.map((format) => (
|
||||
// A poster is a film at one of its frames, so it needs the film's length; a card is a single frame.
|
||||
<Composition
|
||||
key={`${id}-${lang}-${format}`}
|
||||
id={`${id}-${lang}-${format}`}
|
||||
component={() => <ImagePost id={id} lang={lang} />}
|
||||
durationInFrames={IMAGES[id].poster ? SHOT_FRAMES : 1}
|
||||
fps={FPS}
|
||||
width={FORMATS[format].width}
|
||||
height={FORMATS[format].height}
|
||||
/>
|
||||
)),
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { Easing, interpolate, useCurrentFrame } from "remotion";
|
||||
import { FONT } from "../brand/fonts";
|
||||
import { COLOR } from "../brand/tokens";
|
||||
import { bitString } from "../lib/bits";
|
||||
|
||||
const COLUMN_DIGITS = 48;
|
||||
const SCRAMBLE_EVERY_FRAMES = 2;
|
||||
const EDGE_PCT = 2;
|
||||
|
||||
/**
|
||||
* Content revealed left to right behind a narrow column of binary digits (video twin of
|
||||
* BitWipe on the site). The content keeps its place, so nothing reflows.
|
||||
*/
|
||||
export const BitWipe: FC<{
|
||||
children: ReactNode;
|
||||
/** Frame (inside the current sequence) where the wipe starts. */
|
||||
from?: number;
|
||||
durationInFrames?: number;
|
||||
/** Size of the digit column in px. */
|
||||
digitSize?: number;
|
||||
tone?: "dark" | "light";
|
||||
}> = ({ children, from = 0, durationInFrames = 24, digitSize = 22, tone = "dark" }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const t = interpolate(frame, [from, from + durationInFrames], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
easing: Easing.bezier(0.65, 0, 0.35, 1),
|
||||
});
|
||||
const done = t >= 1;
|
||||
const rightInset = 100 - t * (100 + EDGE_PCT);
|
||||
const columnOpacity = interpolate(t, [0, 0.08, 0.86, 1], [0, 1, 1, 0]);
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative", display: "inline-block", whiteSpace: "nowrap" }}>
|
||||
<div
|
||||
style={{
|
||||
clipPath: done
|
||||
? "none"
|
||||
: `inset(-12% ${Math.max(0, rightInset)}% -12% -2%)`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{t > 0 && !done && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: `${t * 100}%`,
|
||||
width: digitSize * 1.1,
|
||||
overflow: "hidden",
|
||||
opacity: columnOpacity,
|
||||
fontFamily: FONT.mono,
|
||||
fontWeight: 700,
|
||||
fontSize: digitSize,
|
||||
lineHeight: 1.05,
|
||||
writingMode: "vertical-rl",
|
||||
wordBreak: "break-all",
|
||||
color: tone === "light" ? COLOR.paper : COLOR.ink,
|
||||
}}
|
||||
>
|
||||
{bitString(COLUMN_DIGITS, Math.floor(frame / SCRAMBLE_EVERY_FRAMES))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { CSSProperties, FC } from "react";
|
||||
import { Easing, interpolate, useCurrentFrame } from "remotion";
|
||||
import { FONT } from "../brand/fonts";
|
||||
import { COLOR } from "../brand/tokens";
|
||||
import { decodeText } from "../lib/bits";
|
||||
|
||||
const SCRAMBLE_EVERY_FRAMES = 2;
|
||||
|
||||
/** Mono label resolving from digits (video twin of DecodeText on the site). Monospaced only. */
|
||||
export const DecodeLabel: FC<{
|
||||
text: string;
|
||||
/** Frame (inside the current sequence) where decoding starts. */
|
||||
from?: number;
|
||||
durationInFrames?: number;
|
||||
style?: CSSProperties;
|
||||
}> = ({ text, from = 0, durationInFrames = 18, style }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const progress = interpolate(frame, [from, from + durationInFrames], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
easing: Easing.inOut(Easing.quad),
|
||||
});
|
||||
const tick = Math.floor(frame / SCRAMBLE_EVERY_FRAMES);
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
fontFamily: FONT.mono,
|
||||
fontWeight: 500,
|
||||
letterSpacing: "0.2em",
|
||||
textTransform: "uppercase",
|
||||
color: COLOR.muted,
|
||||
whiteSpace: "pre",
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{decodeText(text, progress, tick)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { CSSProperties, FC } from "react";
|
||||
import { useCurrentFrame } from "remotion";
|
||||
import { useT } from "../content/CopyContext";
|
||||
import { FONT } from "./fonts";
|
||||
import { COLOR, DRAW } from "./tokens";
|
||||
|
||||
/** One lap of the light round the edge, as on the website (6 s at 30 fps). */
|
||||
const LAP_FRAMES = 180;
|
||||
const SWEEP_FRAMES = 60;
|
||||
|
||||
/** Shows only a ring of the given thickness along the edge of its box. */
|
||||
const ring = (thickness: number): CSSProperties => ({
|
||||
padding: thickness,
|
||||
WebkitMask: "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)",
|
||||
WebkitMaskComposite: "xor",
|
||||
mask: "linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0)",
|
||||
});
|
||||
|
||||
/**
|
||||
* The primary button of the website (apps/web Button.tsx with `bit-cta`): dark pill, bold uppercase
|
||||
* label, the arrow in a circle, a light travelling round the edge with a soft glow behind it, and
|
||||
* the spot of light that follows the pointer there. Here that spot sweeps across on its own.
|
||||
*/
|
||||
export const BrandButton: FC<{
|
||||
label: string;
|
||||
/** Font size in px; padding, arrow and light scale with it. */
|
||||
size?: number;
|
||||
/** `light` is the white pill the website uses on dark surfaces. */
|
||||
tone?: "dark" | "light";
|
||||
}> = ({ label, size = 28, tone = "dark" }) => {
|
||||
const light = tone === "light";
|
||||
const frame = useCurrentFrame();
|
||||
const t = useT();
|
||||
const circle = size * 2.7;
|
||||
const angle = (frame / LAP_FRAMES) * 360;
|
||||
const travelling = `conic-gradient(from ${angle}deg, transparent 0 58%, rgba(${DRAW.light}, 0.95) 76%, #ffffff 86%, rgba(${DRAW.light}, 0.95) 93%, transparent 100%)`;
|
||||
const sweep = ((frame % SWEEP_FRAMES) / SWEEP_FRAMES) * 140 - 20;
|
||||
const edge: CSSProperties = { position: "absolute", borderRadius: 9999, background: travelling };
|
||||
|
||||
return (
|
||||
<span style={{ position: "relative", display: "inline-flex", borderRadius: 9999 }}>
|
||||
<span style={{ position: "absolute", inset: -14, filter: "blur(12px)", opacity: 0.8 }}>
|
||||
<span style={{ ...edge, inset: 8, ...ring(12) }} />
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
position: "relative",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: size * 1.1,
|
||||
overflow: "hidden",
|
||||
borderRadius: 9999,
|
||||
backgroundColor: light ? COLOR.paper : COLOR.ink,
|
||||
color: light ? COLOR.ink : COLOR.paper,
|
||||
padding: `${size * 0.8}px ${size * 0.8}px ${size * 0.8}px ${size * 2.3}px`,
|
||||
fontFamily: FONT.sans,
|
||||
fontWeight: 700,
|
||||
fontSize: size,
|
||||
letterSpacing: "0.18em",
|
||||
textTransform: "uppercase",
|
||||
boxShadow: "0 20px 30px -10px rgba(15, 23, 42, 0.35)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
background: `radial-gradient(${size * 5}px circle at ${sweep}% 50%, rgba(${DRAW.light}, 0.5), transparent 70%)`,
|
||||
}}
|
||||
/>
|
||||
<span style={{ position: "relative" }}>{t(label)}</span>
|
||||
<span
|
||||
style={{
|
||||
position: "relative",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
width: circle,
|
||||
height: circle,
|
||||
borderRadius: 9999,
|
||||
backgroundColor: light ? COLOR.ink : "rgba(255, 255, 255, 0.1)",
|
||||
color: COLOR.paper,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width={size * 1.2}
|
||||
height={size * 1.2}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2.25}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M5 12h14" />
|
||||
<path d="m12 5 7 7-7 7" />
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
<span style={{ ...edge, inset: -2, ...ring(4) }} />
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { AbsoluteFill, Img, staticFile, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import { bitString } from "../lib/bits";
|
||||
import { FONT } from "./fonts";
|
||||
import { COLOR } from "./tokens";
|
||||
|
||||
const LOGO_SIZE = 36;
|
||||
/** Width of one digit of the texture including its letter spacing, in px. */
|
||||
const RAIN_COLUMN_WIDTH = 28.6;
|
||||
const RAIN_ROW_HEIGHT = 40;
|
||||
const RAIN_EVERY_FRAMES = 6;
|
||||
const RAIN_OPACITY = 0.03;
|
||||
|
||||
/** Slowly changing field of digits behind everything, as quiet texture. Fills its box at any format. */
|
||||
const BitRain: FC<{ inset: number }> = ({ inset }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { width, height } = useVideoConfig();
|
||||
const columns = Math.ceil((width - inset * 2) / RAIN_COLUMN_WIDTH);
|
||||
const tick = Math.floor(frame / RAIN_EVERY_FRAMES);
|
||||
const rowCount = Math.ceil((height - inset * 2) / RAIN_ROW_HEIGHT);
|
||||
const rows = Array.from({ length: rowCount }, (_, row) =>
|
||||
bitString(columns, tick + row * 7),
|
||||
);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: inset,
|
||||
left: inset,
|
||||
right: inset,
|
||||
bottom: inset,
|
||||
fontFamily: FONT.mono,
|
||||
fontWeight: 500,
|
||||
fontSize: 26,
|
||||
lineHeight: `${RAIN_ROW_HEIGHT}px`,
|
||||
letterSpacing: "0.5em",
|
||||
color: COLOR.ink,
|
||||
opacity: RAIN_OPACITY,
|
||||
overflow: "hidden",
|
||||
whiteSpace: "pre",
|
||||
padding: "0 24px",
|
||||
}}
|
||||
>
|
||||
{rows.join("\n")}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** Space kept free at the sides, the top and (for the footer) the bottom of the frame, in px. */
|
||||
export const FRAME = { side: 48, top: 48, bottom: 104 } as const;
|
||||
|
||||
/**
|
||||
* What surrounds the drawing: paper, a quiet digit texture, and one small line at the bottom with
|
||||
* the logo and the address. Deliberately little (owner feedback): the drawing is what matters.
|
||||
*/
|
||||
export const BrandFrame: FC<{ children: ReactNode }> = ({ children }) => (
|
||||
<AbsoluteFill style={{ backgroundColor: COLOR.paper }}>
|
||||
<BitRain inset={0} />
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: FRAME.side,
|
||||
right: FRAME.side,
|
||||
bottom: 36,
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Img src={staticFile("logo.svg")} style={{ width: LOGO_SIZE, height: LOGO_SIZE, opacity: 0.55 }} />
|
||||
<span
|
||||
style={{
|
||||
fontFamily: FONT.mono,
|
||||
fontWeight: 500,
|
||||
fontSize: 20,
|
||||
letterSpacing: "0.2em",
|
||||
textTransform: "uppercase",
|
||||
color: COLOR.muted,
|
||||
}}
|
||||
>
|
||||
www.mintel.me
|
||||
</span>
|
||||
</div>
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
padding: `${FRAME.top}px ${FRAME.side}px ${FRAME.bottom}px`,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AbsoluteFill>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { CSSProperties, FC, ReactNode } from "react";
|
||||
import { useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import { cometAt, outlinePoint, sparkAt } from "../lib/aura";
|
||||
import { FONT } from "./fonts";
|
||||
import { COLOR, DRAW } from "./tokens";
|
||||
|
||||
const RADIUS = 40;
|
||||
const SPARKS = 150;
|
||||
const DOT_GRID = 28;
|
||||
const LIGHT_REACH = 360;
|
||||
/** How far the light on the edge reaches into the panel and along the edge, in px. */
|
||||
const EDGE_HAZE = 300;
|
||||
const EDGE_LINE = 380;
|
||||
const EDGE_WIDTH = 3;
|
||||
/** Two lights travel the edge, opposite each other; the second is fainter. */
|
||||
const COMETS = [
|
||||
{ offset: 0, power: 1 },
|
||||
{ offset: 0.5, power: 0.6 },
|
||||
] as const;
|
||||
|
||||
/** Shows only a ring of the given thickness along the edge of its box. */
|
||||
const ring = (thickness: number): CSSProperties => ({
|
||||
padding: thickness,
|
||||
WebkitMask: "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)",
|
||||
WebkitMaskComposite: "xor",
|
||||
mask: "linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0)",
|
||||
});
|
||||
|
||||
const fill: CSSProperties = { position: "absolute", inset: 0 };
|
||||
|
||||
/**
|
||||
* The dark panel the drawings sit on, with the website's effects (BitAura, BitGlow, bit-aurora):
|
||||
* light travelling round its edge, binary digits drifting off it, a soft light wandering over
|
||||
* the dot grid, and slow indigo light drifting behind the drawing.
|
||||
*
|
||||
* All light is drawn with plain gradients that fade to transparent: no blur filters, blend modes
|
||||
* or dashed strokes, which render with hard edges frame by frame. Everything repeats exactly when
|
||||
* the video loops. Must be rendered outside any Sequence, so that it sees the frame of the whole video.
|
||||
*/
|
||||
export const Panel: FC<{
|
||||
width: number;
|
||||
height: number;
|
||||
/** Corner radius in px; 0 for a panel that fills the whole frame. */
|
||||
radius?: number;
|
||||
children: ReactNode;
|
||||
}> = ({ width, height, radius = RADIUS, children }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { durationInFrames } = useVideoConfig();
|
||||
const box = { width, height };
|
||||
const turn = (frame / durationInFrames) * Math.PI * 2;
|
||||
const lightX = width * (0.5 + 0.34 * Math.sin(turn * 2));
|
||||
const lightY = height * (0.5 + 0.3 * Math.cos(turn * 3));
|
||||
const comets = COMETS.map((comet) => ({
|
||||
...outlinePoint(cometAt(frame, durationInFrames) + comet.offset, box),
|
||||
power: comet.power,
|
||||
}));
|
||||
const sparks = Array.from({ length: SPARKS }, (_, index) => sparkAt(index, frame, durationInFrames, box));
|
||||
const auroraX = 38 + Math.sin(turn) * 8;
|
||||
const auroraY = 42 + Math.cos(turn) * 7;
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative", width, height }}>
|
||||
<div
|
||||
style={{
|
||||
...fill,
|
||||
borderRadius: radius,
|
||||
overflow: "hidden",
|
||||
backgroundColor: DRAW.bg,
|
||||
backgroundImage: `radial-gradient(${DRAW.raised} 2px, transparent 2px)`,
|
||||
backgroundSize: `${DOT_GRID}px ${DOT_GRID}px`,
|
||||
boxShadow: "0 40px 80px -30px rgba(15, 23, 42, 0.55)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
...fill,
|
||||
background: `radial-gradient(55% 65% at ${auroraX}% ${auroraY}%, rgba(${DRAW.glow}, 0.34), transparent 72%), radial-gradient(45% 55% at ${102 - auroraX}% ${112 - auroraY}%, rgba(${DRAW.light}, 0.2), transparent 72%)`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
...fill,
|
||||
backgroundImage: `radial-gradient(${LIGHT_REACH}px circle at ${lightX}px ${lightY}px, rgba(148, 163, 184, 0.2), transparent 70%), radial-gradient(#e2e8f0 1.75px, transparent 1.75px)`,
|
||||
backgroundSize: `auto, ${DOT_GRID}px ${DOT_GRID}px`,
|
||||
maskImage: `radial-gradient(${LIGHT_REACH}px circle at ${lightX}px ${lightY}px, #000, transparent 75%)`,
|
||||
WebkitMaskImage: `radial-gradient(${LIGHT_REACH}px circle at ${lightX}px ${lightY}px, #000, transparent 75%)`,
|
||||
}}
|
||||
/>
|
||||
{comets.map((comet) => (
|
||||
<div
|
||||
key={comet.power}
|
||||
style={{
|
||||
...fill,
|
||||
background: `radial-gradient(${EDGE_HAZE}px circle at ${comet.x}px ${comet.y}px, rgba(${DRAW.light}, ${0.5 * comet.power}), rgba(${DRAW.glow}, ${0.22 * comet.power}) 40%, transparent 72%)`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<div style={fill}>{children}</div>
|
||||
{comets.map((comet) => (
|
||||
<div
|
||||
key={comet.power}
|
||||
style={{
|
||||
...fill,
|
||||
borderRadius: radius,
|
||||
...ring(EDGE_WIDTH),
|
||||
background: `radial-gradient(${EDGE_LINE}px circle at ${comet.x}px ${comet.y}px, rgba(${DRAW.core}, ${comet.power}), rgba(${DRAW.light}, ${0.7 * comet.power}) 35%, transparent 75%)`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<svg width={width} height={height} style={{ ...fill, overflow: "visible" }}>
|
||||
{sparks.map((spark, index) =>
|
||||
spark === null ? null : (
|
||||
<text
|
||||
key={index}
|
||||
x={spark.x}
|
||||
y={spark.y}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontFamily={FONT.mono}
|
||||
fontWeight={spark.large ? 700 : 500}
|
||||
fontSize={spark.large ? 24 : 18}
|
||||
fill={COLOR.ink}
|
||||
opacity={spark.opacity * 0.8}
|
||||
>
|
||||
{spark.digit}
|
||||
</text>
|
||||
),
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { loadFont as loadInter } from "@remotion/google-fonts/Inter";
|
||||
import { loadFont as loadMono } from "@remotion/google-fonts/JetBrainsMono";
|
||||
import { loadFont as loadSerif } from "@remotion/google-fonts/Newsreader";
|
||||
|
||||
const inter = loadInter("normal", { weights: ["400", "600", "700", "900"], subsets: ["latin"] });
|
||||
const mono = loadMono("normal", { weights: ["500", "700"], subsets: ["latin"] });
|
||||
const serif = loadSerif("normal", { weights: ["400"], subsets: ["latin"] });
|
||||
|
||||
/** Same three families as the website: Inter for headlines, JetBrains Mono for labels, Newsreader for reading text. */
|
||||
export const FONT = {
|
||||
sans: inter.fontFamily,
|
||||
mono: mono.fontFamily,
|
||||
serif: serif.fontFamily,
|
||||
} as const;
|
||||
@@ -0,0 +1,42 @@
|
||||
/** Brand tokens, mirrored from apps/web (Tailwind slate scale, white paper, mono labels). */
|
||||
export const COLOR = {
|
||||
paper: "#ffffff",
|
||||
ink: "#0f172a", // slate-900
|
||||
inkSoft: "#1e293b", // slate-800
|
||||
body: "#475569", // slate-600
|
||||
muted: "#64748b", // slate-500
|
||||
faint: "#94a3b8", // slate-400
|
||||
line: "#e2e8f0", // slate-200
|
||||
wash: "#f8fafc", // slate-50
|
||||
active: "#fbbf24", // amber-400: work in progress
|
||||
done: "#10b981", // emerald-500: finished
|
||||
} as const;
|
||||
|
||||
/** Colours for drawings, which sit on a dark panel like the animated panels of the website. */
|
||||
export const DRAW = {
|
||||
bg: "#0f172a", // slate-900: the panel
|
||||
raised: "#1e293b", // slate-800: quiet surfaces on the panel
|
||||
soft: "#334155", // slate-700: placeholder lines and blocks
|
||||
label: "#94a3b8", // slate-400: mono labels
|
||||
fg: "#f8fafc", // slate-50: lines, tiles, text
|
||||
rim: "#64748b", // slate-500: fine outlines of quiet things
|
||||
tint: "rgba(248, 250, 252, 0.14)", // a lit surface: a light tint instead of a solid block
|
||||
glass: "rgba(15, 23, 42, 0.55)", // windows and cards: the dot grid shows through
|
||||
mark: "#fde68a", // amber-200: work that is travelling
|
||||
glow: "99, 102, 241", // indigo-500 as r, g, b: the light along the edge
|
||||
light: "129, 140, 248", // indigo-400 as r, g, b: the light on buttons and nodes
|
||||
core: "224, 231, 255", // indigo-100 as r, g, b: its bright centre
|
||||
} as const;
|
||||
|
||||
/** Output formats. LinkedIn feed default is 4:5; the others are variants of the same video. */
|
||||
export const FORMATS = {
|
||||
feed: { width: 1080, height: 1350 },
|
||||
square: { width: 1080, height: 1080 },
|
||||
story: { width: 1080, height: 1920 },
|
||||
/** 16:9 for YouTube and Google Ads video campaigns. */
|
||||
wide: { width: 1920, height: 1080 },
|
||||
} as const;
|
||||
|
||||
export type FormatName = keyof typeof FORMATS;
|
||||
|
||||
export const FPS = 30;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { AppDrawing, AppFilm } from "../films/AppFilm";
|
||||
import { SHOT_FRAMES } from "../films/shot";
|
||||
import type { VideoEntry } from "../slides/ServiceVideo";
|
||||
|
||||
export const appRescue: VideoEntry = {
|
||||
id: "AppRescue",
|
||||
Film: AppFilm,
|
||||
Drawing: AppDrawing,
|
||||
durationInFrames: SHOT_FRAMES,
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { DocumentsDrawing, DocumentsFilm } from "../films/DocumentsFilm";
|
||||
import { SHOT_FRAMES } from "../films/shot";
|
||||
import type { VideoEntry } from "../slides/ServiceVideo";
|
||||
|
||||
export const automationDocuments: VideoEntry = {
|
||||
id: "AutomationDocuments",
|
||||
Film: DocumentsFilm,
|
||||
Drawing: DocumentsDrawing,
|
||||
durationInFrames: SHOT_FRAMES,
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { EnquiryDrawing, EnquiryFilm } from "../films/EnquiryFilm";
|
||||
import { SHOT_FRAMES } from "../films/shot";
|
||||
import type { VideoEntry } from "../slides/ServiceVideo";
|
||||
|
||||
export const automationEnquiry: VideoEntry = {
|
||||
id: "AutomationEnquiry",
|
||||
Film: EnquiryFilm,
|
||||
Drawing: EnquiryDrawing,
|
||||
durationInFrames: SHOT_FRAMES,
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ORDER_FILM_FRAMES, OrderDrawing, OrderFilm } from "../films/OrderFilm";
|
||||
import type { VideoEntry } from "../slides/ServiceVideo";
|
||||
|
||||
export const automationOrder: VideoEntry = {
|
||||
id: "AutomationOrder",
|
||||
Film: OrderFilm,
|
||||
Drawing: OrderDrawing,
|
||||
durationInFrames: ORDER_FILM_FRAMES,
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { DeveloperDrawing, DeveloperFilm } from "../films/DeveloperFilm";
|
||||
import { SHOT_FRAMES } from "../films/shot";
|
||||
import type { VideoEntry } from "../slides/ServiceVideo";
|
||||
|
||||
export const productDeveloper: VideoEntry = {
|
||||
id: "ProductDeveloper",
|
||||
Film: DeveloperFilm,
|
||||
Drawing: DeveloperDrawing,
|
||||
durationInFrames: SHOT_FRAMES,
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { WebsiteDrawing, WebsiteFilm } from "../films/WebsiteFilm";
|
||||
import { SHOT_FRAMES } from "../films/shot";
|
||||
import type { VideoEntry } from "../slides/ServiceVideo";
|
||||
|
||||
export const websiteDone: VideoEntry = {
|
||||
id: "WebsiteDone",
|
||||
Film: WebsiteFilm,
|
||||
Drawing: WebsiteDrawing,
|
||||
durationInFrames: SHOT_FRAMES,
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import { translate, type Lang, type VideoCopy } from "./videos";
|
||||
|
||||
type CopyValue = { readonly lang: Lang; readonly copy: VideoCopy };
|
||||
|
||||
const CopyContext = createContext<CopyValue | null>(null);
|
||||
|
||||
export const CopyProvider = CopyContext.Provider;
|
||||
|
||||
const useCopyValue = (): CopyValue => {
|
||||
const value = useContext(CopyContext);
|
||||
if (!value) throw new Error("This component must be rendered inside a ServiceVideo");
|
||||
return value;
|
||||
};
|
||||
|
||||
/** The texts of the video that is being rendered, in its language. */
|
||||
export const useCopy = (): VideoCopy => useCopyValue().copy;
|
||||
|
||||
/** Translates a label that is written in English in the drawing code. */
|
||||
export const useT = (): ((text: string) => string) => {
|
||||
const { lang } = useCopyValue();
|
||||
return (text) => translate(lang, text);
|
||||
};
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Image posts: every word, in English and German, and for posters which film and which frame they
|
||||
* show. Two kinds:
|
||||
*
|
||||
* - a poster is one picture of a film (the composition is rendered at that frame) with the film's idea in two lines
|
||||
* - a card answers one question a client asks before the first call, in large type
|
||||
*
|
||||
* Everything said here is on the website (`apps/web/src/content/de.ts`, questions and principles).
|
||||
* This file imports only types, so the export script can read it with plain Node.
|
||||
*/
|
||||
import type { Lang, VideoId } from "./videos.ts";
|
||||
|
||||
export type ImageCopy = {
|
||||
/** What the image is about, for the overview page. */
|
||||
readonly title: string;
|
||||
/** Cards only: the question, set small above the answer. */
|
||||
readonly label?: string;
|
||||
/** The large lines: at most three, as short as possible. */
|
||||
readonly lines: readonly string[];
|
||||
/** Text to post with the image. */
|
||||
readonly post: string;
|
||||
};
|
||||
|
||||
export type ImagePost = {
|
||||
/** Posters only: the film whose picture is shown, and at which frame. */
|
||||
readonly poster?: { readonly video: VideoId; readonly frame: number };
|
||||
readonly copy: Readonly<Record<Lang, ImageCopy>>;
|
||||
};
|
||||
|
||||
export const IMAGE_IDS = [
|
||||
"PosterOrder",
|
||||
"PosterDocuments",
|
||||
"PosterEnquiry",
|
||||
"PosterWebsite",
|
||||
"PosterApp",
|
||||
"PosterDeveloper",
|
||||
"CardNotTechnical",
|
||||
"CardOneContact",
|
||||
"CardOwnership",
|
||||
"CardHours",
|
||||
"CardNotice",
|
||||
"CardNotMyField",
|
||||
] as const;
|
||||
export type ImageId = (typeof IMAGE_IDS)[number];
|
||||
|
||||
const CALL_EN = "A first call takes about 20 minutes.\n\nwww.mintel.me";
|
||||
const CALL_DE = "Ein erstes Gespräch dauert etwa 20 Minuten.\n\nwww.mintel.me";
|
||||
|
||||
export const IMAGES: Readonly<Record<ImageId, ImagePost>> = {
|
||||
PosterOrder: {
|
||||
poster: { video: "AutomationOrder", frame: 285 },
|
||||
copy: {
|
||||
en: {
|
||||
title: "Poster: ping-pong",
|
||||
lines: ["Not ping-pong.", "Your office."],
|
||||
post: `Every dot is someone carrying an order from one tool to the next. By hand.\n\nThe tools could pass it on themselves. I connect them.\n\n${CALL_EN}\n\n#automation #processautomation`,
|
||||
},
|
||||
de: {
|
||||
title: "Poster: Pingpong",
|
||||
lines: ["Kein Pingpong.", "Ihr Büro."],
|
||||
post: `Jeder Punkt ist jemand, der eine Bestellung von einem Tool ins nächste trägt. Von Hand.\n\nDie Tools könnten das unter sich ausmachen. Ich verbinde sie.\n\n${CALL_DE}\n\n#automatisierung #prozessautomatisierung`,
|
||||
},
|
||||
},
|
||||
},
|
||||
PosterDocuments: {
|
||||
poster: { video: "AutomationDocuments", frame: 300 },
|
||||
copy: {
|
||||
en: {
|
||||
title: "Poster: skyscraper",
|
||||
lines: ["Not a skyscraper.", "Your inbox."],
|
||||
post: `Every floor is an invoice waiting to be typed in.\n\nIt can be read automatically. Clear cases are booked directly, unclear ones go to a person.\n\n${CALL_EN}\n\n#automation #accounting`,
|
||||
},
|
||||
de: {
|
||||
title: "Poster: Hochhaus",
|
||||
lines: ["Kein Hochhaus.", "Ihr Posteingang."],
|
||||
post: `Jedes Stockwerk ist eine Rechnung, die noch jemand abtippen muss.\n\nDas lässt sich automatisch auslesen. Klare Fälle werden direkt gebucht, unklare gehen an einen Menschen.\n\n${CALL_DE}\n\n#automatisierung #buchhaltung`,
|
||||
},
|
||||
},
|
||||
},
|
||||
PosterEnquiry: {
|
||||
poster: { video: "AutomationEnquiry", frame: 250 },
|
||||
copy: {
|
||||
en: {
|
||||
title: "Poster: stars",
|
||||
lines: ["Not a night sky.", "Your enquiries."],
|
||||
post: `The faint ones are the enquiries nobody followed up on.\n\nThe steps can be connected: the contact is created, the right person is told, and a reminder appears when nobody has replied.\n\n${CALL_EN}\n\n#automation #sales`,
|
||||
},
|
||||
de: {
|
||||
title: "Poster: Sterne",
|
||||
lines: ["Kein Sternenhimmel.", "Ihre Anfragen."],
|
||||
post: `Die blassen sind die Anfragen, bei denen niemand nachgefasst hat.\n\nDie Schritte lassen sich verbinden: Der Kontakt wird angelegt, die zuständige Person erfährt davon, und wenn niemand geantwortet hat, erscheint eine Erinnerung.\n\n${CALL_DE}\n\n#automatisierung #vertrieb`,
|
||||
},
|
||||
},
|
||||
},
|
||||
PosterWebsite: {
|
||||
poster: { video: "WebsiteDone", frame: 260 },
|
||||
copy: {
|
||||
en: {
|
||||
title: "Poster: empty page",
|
||||
lines: ["Not a beginning.", "Your website."],
|
||||
post: `The website that has been on the list since last year.\n\nYou tell me what your business does. I design it, build it and put it online. Changes afterwards: you send me an email.\n\n${CALL_EN}\n\n#website #webdesign`,
|
||||
},
|
||||
de: {
|
||||
title: "Poster: leere Seite",
|
||||
lines: ["Kein Anfang.", "Ihre Website."],
|
||||
post: `Die Website, die seit letztem Jahr auf der Liste steht.\n\nSie erzählen mir, was Ihr Unternehmen macht. Ich gestalte sie, baue sie und bringe sie online. Änderungen danach: Sie schreiben mir eine E-Mail.\n\n${CALL_DE}\n\n#website #webdesign`,
|
||||
},
|
||||
},
|
||||
},
|
||||
PosterApp: {
|
||||
poster: { video: "AppRescue", frame: 285 },
|
||||
copy: {
|
||||
en: {
|
||||
title: "Poster: Jenga",
|
||||
lines: ["Not Jenga.", "Your app."],
|
||||
post: `It works. But bugs come back, and small changes take longer than they should.\n\nI take a week to go through the code, fix what is most urgent and write down what I would do next. In plain language.\n\n${CALL_EN}\n\n#webdevelopment #softwarequality`,
|
||||
},
|
||||
de: {
|
||||
title: "Poster: Jenga",
|
||||
lines: ["Kein Jenga.", "Ihre App."],
|
||||
post: `Sie läuft. Aber Fehler kommen wieder, und kleine Änderungen dauern länger, als sie sollten.\n\nIch nehme mir eine Woche, gehe den Code durch, behebe das Dringendste und schreibe auf, was ich als Nächstes tun würde. In klarer Sprache.\n\n${CALL_DE}\n\n#webentwicklung #softwarequalität`,
|
||||
},
|
||||
},
|
||||
},
|
||||
PosterDeveloper: {
|
||||
poster: { video: "ProductDeveloper", frame: 300 },
|
||||
copy: {
|
||||
en: {
|
||||
title: "Poster: loading bars",
|
||||
lines: ["Not loading bars.", "Your backlog."],
|
||||
post: `More work than your team can handle.\n\nI join on agreed days of the week and work on what is next on your list. No package to buy, no minimum term.\n\n${CALL_EN}\n\n#webdevelopment #freelance`,
|
||||
},
|
||||
de: {
|
||||
title: "Poster: Ladebalken",
|
||||
lines: ["Keine Ladebalken.", "Ihr Backlog."],
|
||||
post: `Mehr Arbeit, als Ihr Team schafft.\n\nIch komme an vereinbarten Wochentagen dazu und arbeite an dem, was als Nächstes auf Ihrer Liste steht. Kein Paket, keine Mindestlaufzeit.\n\n${CALL_DE}\n\n#webentwicklung #freelancer`,
|
||||
},
|
||||
},
|
||||
},
|
||||
CardNotTechnical: {
|
||||
copy: {
|
||||
en: {
|
||||
title: "Card: not technical",
|
||||
label: "Not a technical person?",
|
||||
lines: ["Doesn't matter.", "You never have", "to read code."],
|
||||
post: `I explain what I am doing in plain language, and you can ask at any time.\n\n${CALL_EN}`,
|
||||
},
|
||||
de: {
|
||||
title: "Karte: kein Technikmensch",
|
||||
label: "Kein Technikmensch?",
|
||||
lines: ["Macht nichts.", "Code müssen Sie", "nicht lesen."],
|
||||
post: `Ich erkläre in verständlicher Sprache, was ich tue, und Sie können jederzeit nachfragen.\n\n${CALL_DE}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
CardOneContact: {
|
||||
copy: {
|
||||
en: {
|
||||
title: "Card: one contact",
|
||||
label: "Who does the work?",
|
||||
lines: ["The person", "you talk to."],
|
||||
post: `Often a request passes through several people before it reaches the developer. With me there is one direct line between you and the person who does the work.\n\n${CALL_EN}`,
|
||||
},
|
||||
de: {
|
||||
title: "Karte: ein Ansprechpartner",
|
||||
label: "Wer macht die Arbeit?",
|
||||
lines: ["Der, mit dem", "Sie sprechen."],
|
||||
post: `Oft geht eine Anfrage durch mehrere Hände, bevor sie beim Entwickler ankommt. Bei mir gibt es eine direkte Leitung zwischen Ihnen und dem, der die Arbeit macht.\n\n${CALL_DE}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
CardOwnership: {
|
||||
copy: {
|
||||
en: {
|
||||
title: "Card: ownership",
|
||||
label: "Who owns the code?",
|
||||
lines: ["You do.", "The design too."],
|
||||
post: `Code and design belong to you, and you can hand both to another developer.\n\n${CALL_EN}`,
|
||||
},
|
||||
de: {
|
||||
title: "Karte: Eigentum",
|
||||
label: "Wem gehört der Code?",
|
||||
lines: ["Ihnen.", "Das Design auch."],
|
||||
post: `Code und Design gehören Ihnen, und Sie können beides an einen anderen Entwickler übergeben.\n\n${CALL_DE}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
CardHours: {
|
||||
copy: {
|
||||
en: {
|
||||
title: "Card: hours",
|
||||
label: "What do you pay for?",
|
||||
lines: ["Hours you", "can check."],
|
||||
post: `I track my time. The monthly invoice shows what the hours were spent on.\n\n${CALL_EN}`,
|
||||
},
|
||||
de: {
|
||||
title: "Karte: Stunden",
|
||||
label: "Wofür zahlen Sie?",
|
||||
lines: ["Für Stunden, die Sie", "nachprüfen können."],
|
||||
post: `Ich erfasse meine Zeit. Die Monatsrechnung zeigt, wofür die Stunden angefallen sind.\n\n${CALL_DE}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
CardNotice: {
|
||||
copy: {
|
||||
en: {
|
||||
title: "Card: notice",
|
||||
label: "How long are you committed?",
|
||||
lines: ["Two weeks", "notice."],
|
||||
post: `Project work is billed by effort. Either side can end it with two weeks notice to the end of a month.\n\n${CALL_EN}`,
|
||||
},
|
||||
de: {
|
||||
title: "Karte: Kündigungsfrist",
|
||||
label: "Wie lange sind Sie gebunden?",
|
||||
lines: ["Zwei Wochen", "Kündigungsfrist."],
|
||||
post: `Projektarbeit wird nach Aufwand abgerechnet. Beide Seiten können mit zwei Wochen Frist zum Monatsende kündigen.\n\n${CALL_DE}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
CardNotMyField: {
|
||||
copy: {
|
||||
en: {
|
||||
title: "Card: not my field",
|
||||
label: "What I do not do",
|
||||
lines: ["Mobile apps.", "Windows software.", "Running servers."],
|
||||
post: `If your project needs those, I say so in the first call.\n\nWhat I do: web apps, interfaces, websites, and connecting systems.\n\nwww.mintel.me`,
|
||||
},
|
||||
de: {
|
||||
title: "Karte: nicht mein Feld",
|
||||
label: "Was ich nicht mache",
|
||||
lines: ["Mobile Apps.", "Windows-Software.", "Serverbetrieb."],
|
||||
post: `Wenn Ihr Projekt das braucht, sage ich das im ersten Gespräch.\n\nWas ich mache: Web-Apps, Oberflächen, Websites und das Verbinden von Systemen.\n\nwww.mintel.me`,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { IMAGES, IMAGE_IDS } from "./images";
|
||||
import { PLAN } from "./plan";
|
||||
import { COPY, LANGS, VIDEO_IDS } from "./videos";
|
||||
|
||||
/** Longer lines get too small to read on a phone. */
|
||||
const MAX_IMAGE_LINE = 28;
|
||||
const MAX_IMAGE_LINES = 3;
|
||||
|
||||
describe("image posts", () => {
|
||||
it.each(IMAGE_IDS)("%s has at most three short lines in every language", (id) => {
|
||||
for (const lang of LANGS) {
|
||||
const { lines } = IMAGES[id].copy[lang];
|
||||
expect(lines.length, `${id} ${lang}`).toBeGreaterThan(0);
|
||||
expect(lines.length, `${id} ${lang}`).toBeLessThanOrEqual(MAX_IMAGE_LINES);
|
||||
for (const line of lines) expect(line.length, `${id} ${lang}: "${line}"`).toBeLessThanOrEqual(MAX_IMAGE_LINE);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(IMAGE_IDS)("%s has a post that names the website and never an email address", (id) => {
|
||||
for (const lang of LANGS) {
|
||||
const { post } = IMAGES[id].copy[lang];
|
||||
expect(post).toContain("www.mintel.me");
|
||||
expect(post).not.toContain("@");
|
||||
}
|
||||
});
|
||||
|
||||
it.each(IMAGE_IDS)("%s is a poster of an existing film, or a card with a question", (id) => {
|
||||
const image = IMAGES[id];
|
||||
if (image.poster) {
|
||||
expect(VIDEO_IDS).toContain(image.poster.video);
|
||||
expect(image.poster.frame).toBeGreaterThanOrEqual(0);
|
||||
} else {
|
||||
for (const lang of LANGS) expect(image.copy[lang].label, `${id} ${lang}`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("video posts", () => {
|
||||
it.each(VIDEO_IDS)("%s opens its post with the hook of the film", (id) => {
|
||||
for (const lang of LANGS) {
|
||||
const { post, slides } = COPY[id][lang];
|
||||
const lastHookWord = (slides.hook ?? []).join(" ").split(" ").pop()?.replace(/[.?!]/g, "") ?? "";
|
||||
expect(post.split("\n")[0], `${id} ${lang}`).toContain(lastHookWord);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("posting plan", () => {
|
||||
const planned = PLAN.flatMap((week) => week.posts);
|
||||
|
||||
it("uses every film and every image exactly once", () => {
|
||||
const videos = planned.filter((post) => post.kind === "video").map((post) => post.id);
|
||||
const images = planned.filter((post) => post.kind === "image").map((post) => post.id);
|
||||
expect([...videos].sort()).toEqual([...VIDEO_IDS].sort());
|
||||
expect([...images].sort()).toEqual([...IMAGE_IDS].sort());
|
||||
});
|
||||
|
||||
it("opens every week with its film", () => {
|
||||
for (const week of PLAN) expect(week.posts[0].kind, week.topic).toBe("video");
|
||||
});
|
||||
|
||||
it("pairs the film of a week with the poster of the same film", () => {
|
||||
for (const week of PLAN) {
|
||||
const film = week.posts[0].id;
|
||||
const posters = week.posts.filter((post) => post.kind === "image" && IMAGES[post.id].poster?.video === film);
|
||||
expect(posters, week.topic).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* The posting plan: which film or image goes out in which week, in which order.
|
||||
* The reasoning is in `apps/web/plans/content-strategy.md`. Each week has one topic and three
|
||||
* posts: the film, the poster of the same idea, and a card that answers a question clients ask.
|
||||
*
|
||||
* This file imports only types, so the export script can read it with plain Node.
|
||||
*/
|
||||
import type { ImageId } from "./images.ts";
|
||||
import type { VideoId } from "./videos.ts";
|
||||
|
||||
export type PlannedPost =
|
||||
| { readonly kind: "video"; readonly id: VideoId }
|
||||
| { readonly kind: "image"; readonly id: ImageId };
|
||||
|
||||
export type PlannedWeek = {
|
||||
readonly topic: string;
|
||||
/** In posting order: Tuesday, Thursday, Saturday. */
|
||||
readonly posts: readonly [PlannedPost, PlannedPost, PlannedPost];
|
||||
};
|
||||
|
||||
export const POSTING_DAYS = ["Tuesday", "Thursday", "Saturday"] as const;
|
||||
|
||||
export const PLAN: readonly PlannedWeek[] = [
|
||||
{
|
||||
topic: "Automation: orders",
|
||||
posts: [
|
||||
{ kind: "video", id: "AutomationOrder" },
|
||||
{ kind: "image", id: "CardNotTechnical" },
|
||||
{ kind: "image", id: "PosterOrder" },
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: "Websites",
|
||||
posts: [
|
||||
{ kind: "video", id: "WebsiteDone" },
|
||||
{ kind: "image", id: "CardOneContact" },
|
||||
{ kind: "image", id: "PosterWebsite" },
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: "Automation: invoices",
|
||||
posts: [
|
||||
{ kind: "video", id: "AutomationDocuments" },
|
||||
{ kind: "image", id: "CardHours" },
|
||||
{ kind: "image", id: "PosterDocuments" },
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: "Reliable apps",
|
||||
posts: [
|
||||
{ kind: "video", id: "AppRescue" },
|
||||
{ kind: "image", id: "CardOwnership" },
|
||||
{ kind: "image", id: "PosterApp" },
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: "Automation: enquiries",
|
||||
posts: [
|
||||
{ kind: "video", id: "AutomationEnquiry" },
|
||||
{ kind: "image", id: "CardNotMyField" },
|
||||
{ kind: "image", id: "PosterEnquiry" },
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: "A developer for your product",
|
||||
posts: [
|
||||
{ kind: "video", id: "ProductDeveloper" },
|
||||
{ kind: "image", id: "CardNotice" },
|
||||
{ kind: "image", id: "PosterDeveloper" },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { COPY, LANGS, VIDEO_IDS, translate } from "./videos";
|
||||
|
||||
const MAX_LINES = 2;
|
||||
/** Longer lines get too small to read on a phone. */
|
||||
const MAX_LINE_LENGTH = 24;
|
||||
|
||||
describe("video copy", () => {
|
||||
it.each(VIDEO_IDS)("%s has the same slides in every language", (id) => {
|
||||
const [first, ...others] = LANGS.map((lang) => Object.keys(COPY[id][lang].slides).sort());
|
||||
for (const slides of others) expect(slides).toEqual(first);
|
||||
});
|
||||
|
||||
it.each(VIDEO_IDS)("%s keeps every headline to two short lines", (id) => {
|
||||
for (const lang of LANGS) {
|
||||
for (const [slide, lines] of Object.entries(COPY[id][lang].slides)) {
|
||||
expect(lines.length, `${id} ${lang} ${slide}`).toBeGreaterThan(0);
|
||||
expect(lines.length, `${id} ${lang} ${slide}`).toBeLessThanOrEqual(MAX_LINES);
|
||||
for (const line of lines) {
|
||||
expect(line.length, `${id} ${lang} ${slide}: "${line}"`).toBeLessThanOrEqual(MAX_LINE_LENGTH);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it.each(VIDEO_IDS)("%s ends with a call to action slide", (id) => {
|
||||
for (const lang of LANGS) expect(COPY[id][lang].slides).toHaveProperty("cta");
|
||||
});
|
||||
|
||||
it.each(VIDEO_IDS)("%s has a post that names the website and never an email address", (id) => {
|
||||
for (const lang of LANGS) {
|
||||
const { post } = COPY[id][lang];
|
||||
expect(post).toContain("www.mintel.me");
|
||||
expect(post).not.toContain("@");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("translate", () => {
|
||||
it("leaves English labels as they are", () => {
|
||||
expect(translate("en", "Invoice")).toBe("Invoice");
|
||||
});
|
||||
|
||||
it("returns the German label when there is one", () => {
|
||||
expect(translate("de", "Invoice")).toBe("Rechnung");
|
||||
});
|
||||
|
||||
it("falls back to the given text when there is no German label", () => {
|
||||
expect(translate("de", "Shop")).toBe("Shop");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,388 @@
|
||||
/**
|
||||
* Everything that is said in and about the videos: headlines per slide, labels inside the drawings,
|
||||
* and the text to post with each video. English and German.
|
||||
*
|
||||
* This file imports nothing, so the export script can read it with plain Node.
|
||||
* German addresses the viewer formally ("Sie"), as is usual towards business owners.
|
||||
*/
|
||||
|
||||
export const LANGS = ["en", "de"] as const;
|
||||
export type Lang = (typeof LANGS)[number];
|
||||
|
||||
export const VIDEO_IDS = [
|
||||
"AutomationOrder",
|
||||
"AutomationDocuments",
|
||||
"AutomationEnquiry",
|
||||
"WebsiteDone",
|
||||
"AppRescue",
|
||||
"ProductDeveloper",
|
||||
] as const;
|
||||
export type VideoId = (typeof VIDEO_IDS)[number];
|
||||
|
||||
export type VideoCopy = {
|
||||
/** What the video is about, for the overview page. */
|
||||
readonly title: string;
|
||||
/** Headline per slide id: at most two lines of a few words. */
|
||||
readonly slides: Readonly<Record<string, readonly string[]>>;
|
||||
/** Text to post with the video on LinkedIn. */
|
||||
readonly post: string;
|
||||
};
|
||||
|
||||
/** German for every label that appears inside a drawing or on the frame. Missing entries stay English. */
|
||||
export const LABELS_DE: Readonly<Record<string, string>> = {
|
||||
"Freelance developer": "Freelance-Entwickler",
|
||||
"Get in touch": "Kontakt aufnehmen",
|
||||
Invoice: "Rechnung",
|
||||
Customers: "Kunden",
|
||||
Email: "E-Mail",
|
||||
"One person": "Eine Person",
|
||||
Inbox: "Posteingang",
|
||||
Read: "Lesen",
|
||||
Check: "Prüfen",
|
||||
Books: "Buchhaltung",
|
||||
"Invoice.pdf": "Rechnung.pdf",
|
||||
Accounting: "Buchhaltung",
|
||||
Supplier: "Lieferant",
|
||||
Amount: "Betrag",
|
||||
Date: "Datum",
|
||||
"A person": "Ein Mensch",
|
||||
Contacts: "Kontakte",
|
||||
Reminder: "Erinnerung",
|
||||
"Day 1": "Tag 1",
|
||||
"Day 2": "Tag 2",
|
||||
"Day 3": "Tag 3",
|
||||
"Day 4": "Tag 4",
|
||||
"Website builder": "Baukasten",
|
||||
"Your website": "Ihre Website",
|
||||
"Your email": "Ihre E-Mail",
|
||||
"www.your-name.com": "www.ihr-name.de",
|
||||
"Your app": "Ihre App",
|
||||
Mon: "Mo",
|
||||
Tue: "Di",
|
||||
Wed: "Mi",
|
||||
Thu: "Do",
|
||||
Fri: "Fr",
|
||||
"To do": "Offen",
|
||||
Done: "Erledigt",
|
||||
Product: "Produkt",
|
||||
Developer: "Entwicklung",
|
||||
};
|
||||
|
||||
export const translate = (lang: Lang, text: string): string =>
|
||||
lang === "de" ? (LABELS_DE[text] ?? text) : text;
|
||||
|
||||
export const COPY: Readonly<Record<VideoId, Readonly<Record<Lang, VideoCopy>>>> = {
|
||||
AutomationOrder: {
|
||||
en: {
|
||||
title: "Automation: from order to invoice",
|
||||
slides: {
|
||||
hook: ["Looks like", "ping-pong."],
|
||||
reveal: ["It's your", "office."],
|
||||
problem: ["Every order.", "By hand."],
|
||||
turn: ["Or your tools", "talk to each other."],
|
||||
calm: ["And your team", "has time."],
|
||||
cta: ["Done with", "ping-pong?"],
|
||||
},
|
||||
post: `Looks like ping-pong. It's your office.
|
||||
|
||||
An order comes in. Someone opens it in the shop, writes the invoice, updates the customer record, sends the confirmation. Then the next one. And the next.
|
||||
|
||||
Works beautifully, as long as hardly anyone orders.
|
||||
|
||||
The four tools can sort this out among themselves: the order triggers invoice, customer record and email on its own. Your team sees the finished result and looks after the cases that really need a person.
|
||||
|
||||
I connect the tools you already use. A first call takes about 20 minutes.
|
||||
|
||||
www.mintel.me
|
||||
|
||||
#automation #processautomation #smallbusiness`,
|
||||
},
|
||||
de: {
|
||||
title: "Automatisierung: von der Bestellung zur Rechnung",
|
||||
slides: {
|
||||
hook: ["Sieht aus wie", "Pingpong."],
|
||||
reveal: ["Ist aber", "Ihr Büro."],
|
||||
problem: ["Jede Bestellung.", "Von Hand."],
|
||||
turn: ["Oder Ihre Tools", "reden miteinander."],
|
||||
calm: ["Und Ihr Team", "hat Zeit."],
|
||||
cta: ["Schluss mit", "Pingpong?"],
|
||||
},
|
||||
post: `Sieht aus wie Pingpong. Ist aber Ihr Büro.
|
||||
|
||||
Eine Bestellung kommt rein. Jemand öffnet sie im Shop, schreibt die Rechnung, pflegt den Kunden nach, verschickt die Bestätigung. Dann die nächste. Und die nächste.
|
||||
|
||||
Klappt wunderbar, solange kaum jemand bestellt.
|
||||
|
||||
Dabei können die vier Tools das längst unter sich ausmachen: Die Bestellung löst Rechnung, Kundendatensatz und E-Mail selbst aus. Ihr Team sieht das fertige Ergebnis und kümmert sich um die Fälle, die wirklich einen Menschen brauchen.
|
||||
|
||||
Ich verbinde die Tools, die Sie schon haben. Ein erstes Gespräch dauert etwa 20 Minuten.
|
||||
|
||||
www.mintel.me
|
||||
|
||||
#automatisierung #prozessautomatisierung #mittelstand`,
|
||||
},
|
||||
},
|
||||
|
||||
AutomationDocuments: {
|
||||
en: {
|
||||
title: "Automation: incoming invoices",
|
||||
slides: {
|
||||
hook: ["Looks like", "a skyscraper."],
|
||||
reveal: ["It's your", "inbox."],
|
||||
problem: ["Every invoice.", "Typed in."],
|
||||
turn: ["Or it simply", "gets read."],
|
||||
calm: ["Unclear ones go", "to a person."],
|
||||
cta: ["Done typing", "things in?"],
|
||||
},
|
||||
post: `Looks like a skyscraper. It's your inbox.
|
||||
|
||||
Invoices arrive as PDF. Someone opens each one, reads supplier, amount and date, and types them into the accounting system. Floor by floor.
|
||||
|
||||
Nobody has to do that by hand. The document can be read automatically, a fixed rule checks whether the values make sense, and clear cases are booked directly. Anything unclear goes to a person, with the document attached.
|
||||
|
||||
So people decide the exceptions instead of typing the routine.
|
||||
|
||||
A first call takes about 20 minutes.
|
||||
|
||||
www.mintel.me
|
||||
|
||||
#automation #accounting #smallbusiness`,
|
||||
},
|
||||
de: {
|
||||
title: "Automatisierung: Eingangsrechnungen",
|
||||
slides: {
|
||||
hook: ["Sieht aus wie", "ein Hochhaus."],
|
||||
reveal: ["Ist aber Ihr", "Posteingang."],
|
||||
problem: ["Jede Rechnung.", "Abgetippt."],
|
||||
turn: ["Oder sie wird", "einfach gelesen."],
|
||||
calm: ["Unklares geht", "an einen Menschen."],
|
||||
cta: ["Schluss mit", "Abtippen?"],
|
||||
},
|
||||
post: `Sieht aus wie ein Hochhaus. Ist aber Ihr Posteingang.
|
||||
|
||||
Rechnungen kommen als PDF. Jemand öffnet jede einzelne, liest Lieferant, Betrag und Datum ab und tippt alles in die Buchhaltung. Stockwerk für Stockwerk.
|
||||
|
||||
Das muss niemand von Hand machen. Das Dokument lässt sich automatisch auslesen, eine feste Regel prüft, ob die Werte plausibel sind, und klare Fälle werden direkt gebucht. Alles Unklare geht an einen Menschen, mit dem Dokument im Anhang.
|
||||
|
||||
So entscheiden Menschen die Ausnahmen, statt Routine abzutippen.
|
||||
|
||||
Ein erstes Gespräch dauert etwa 20 Minuten.
|
||||
|
||||
www.mintel.me
|
||||
|
||||
#automatisierung #buchhaltung #mittelstand`,
|
||||
},
|
||||
},
|
||||
|
||||
AutomationEnquiry: {
|
||||
en: {
|
||||
title: "Automation: from enquiry to follow-up",
|
||||
slides: {
|
||||
hook: ["Looks like", "a star."],
|
||||
reveal: ["These are your", "enquiries."],
|
||||
problem: ["They burn out.", "Unanswered."],
|
||||
turn: ["Or each one reaches", "the right person."],
|
||||
calm: ["And follow-ups", "happen by themselves."],
|
||||
cta: ["Stop losing", "enquiries?"],
|
||||
},
|
||||
post: `Looks like a star. It's an enquiry, burning out.
|
||||
|
||||
Someone fills in your contact form. The enquiry is copied into the customer list by hand, forwarded by email, and everyone hopes the follow-up is not forgotten.
|
||||
|
||||
Hope is not a process.
|
||||
|
||||
These steps can be connected: the contact is created, the right person is told, and if nobody has replied after three days, a reminder appears.
|
||||
|
||||
A first call takes about 20 minutes.
|
||||
|
||||
www.mintel.me
|
||||
|
||||
#automation #sales #smallbusiness`,
|
||||
},
|
||||
de: {
|
||||
title: "Automatisierung: von der Anfrage zum Nachfassen",
|
||||
slides: {
|
||||
hook: ["Sieht aus wie", "ein Stern."],
|
||||
reveal: ["Sind aber Ihre", "Anfragen."],
|
||||
problem: ["Sie verglühen.", "Unbeantwortet."],
|
||||
turn: ["Oder jede landet", "beim Richtigen."],
|
||||
calm: ["Und Nachfassen", "passiert von selbst."],
|
||||
cta: ["Keine Anfrage", "mehr verlieren?"],
|
||||
},
|
||||
post: `Sieht aus wie ein Stern. Ist aber eine Anfrage, die gerade verglüht.
|
||||
|
||||
Jemand füllt Ihr Kontaktformular aus. Die Anfrage wird von Hand in die Kundenliste kopiert, per E-Mail weitergeleitet, und alle hoffen, dass das Nachfassen nicht vergessen wird.
|
||||
|
||||
Hoffen ist kein Prozess.
|
||||
|
||||
Diese Schritte lassen sich verbinden: Der Kontakt wird angelegt, die zuständige Person erfährt davon, und wenn nach drei Tagen niemand geantwortet hat, erscheint eine Erinnerung.
|
||||
|
||||
Ein erstes Gespräch dauert etwa 20 Minuten.
|
||||
|
||||
www.mintel.me
|
||||
|
||||
#automatisierung #vertrieb #mittelstand`,
|
||||
},
|
||||
},
|
||||
|
||||
WebsiteDone: {
|
||||
en: {
|
||||
title: "A website, built and looked after",
|
||||
slides: {
|
||||
hook: ["Looks like", "a beginning."],
|
||||
reveal: ["It's been your", "website for months."],
|
||||
problem: ["Empty.", "Still."],
|
||||
turn: ["You talk.", "I build."],
|
||||
calm: ["Fits every", "screen."],
|
||||
cta: ["Website", "overdue?"],
|
||||
},
|
||||
post: `Looks like a beginning. It's been your website for months.
|
||||
|
||||
You run a business. Learning a website builder on top of that was never going to happen.
|
||||
|
||||
This is how I do it instead: you tell me what your business does and what the website is for. I design it, build it and put it online. It works on phone, tablet and desktop, and you approve it before it goes live.
|
||||
|
||||
And afterwards? A new photo or a changed price: you send me an email.
|
||||
|
||||
A first call takes about 20 minutes.
|
||||
|
||||
www.mintel.me
|
||||
|
||||
#website #webdesign #smallbusiness`,
|
||||
},
|
||||
de: {
|
||||
title: "Eine Website, gebaut und betreut",
|
||||
slides: {
|
||||
hook: ["Sieht aus wie", "ein Anfang."],
|
||||
reveal: ["Ist seit Monaten", "Ihre Website."],
|
||||
problem: ["Leer.", "Immer noch."],
|
||||
turn: ["Sie erzählen.", "Ich baue."],
|
||||
calm: ["Passt auf", "jeden Bildschirm."],
|
||||
cta: ["Website", "fällig?"],
|
||||
},
|
||||
post: `Sieht aus wie ein Anfang. Ist seit Monaten Ihre Website.
|
||||
|
||||
Sie führen ein Unternehmen. Dass Sie nebenbei noch einen Website-Baukasten lernen, war nie realistisch.
|
||||
|
||||
So mache ich es stattdessen: Sie erzählen mir, was Ihr Unternehmen macht und wofür die Website da ist. Ich gestalte sie, baue sie und bringe sie online. Sie funktioniert auf Handy, Tablet und Desktop, und Sie geben sie frei, bevor sie live geht.
|
||||
|
||||
Und danach? Ein neues Foto oder ein geänderter Preis: Sie schreiben mir eine E-Mail.
|
||||
|
||||
Ein erstes Gespräch dauert etwa 20 Minuten.
|
||||
|
||||
www.mintel.me
|
||||
|
||||
#website #webdesign #mittelstand`,
|
||||
},
|
||||
},
|
||||
|
||||
AppRescue: {
|
||||
en: {
|
||||
title: "Making an app reliable",
|
||||
slides: {
|
||||
hook: ["Looks like", "Jenga."],
|
||||
reveal: ["It's your", "app."],
|
||||
problem: ["Every change", "wobbles."],
|
||||
turn: ["One week.", "I look inside."],
|
||||
calm: ["After that", "it stands."],
|
||||
cta: ["App", "wobbling?"],
|
||||
},
|
||||
post: `Looks like Jenga. It's your app.
|
||||
|
||||
It works. But bugs come back, and every small change feels like pulling out a block.
|
||||
|
||||
That usually happens when an app was built quickly and then kept growing.
|
||||
|
||||
I take a week to go through it: I look at what is solid and what is fragile, fix the most urgent problems as far as a week allows, and write down what I would do next. In plain language, so you can follow it without being a developer.
|
||||
|
||||
After that you decide how to continue: with me, with your own team, or not at all.
|
||||
|
||||
A first call takes about 20 minutes.
|
||||
|
||||
www.mintel.me
|
||||
|
||||
#webdevelopment #softwarequality #startups`,
|
||||
},
|
||||
de: {
|
||||
title: "Eine App zuverlässig machen",
|
||||
slides: {
|
||||
hook: ["Sieht aus wie", "Jenga."],
|
||||
reveal: ["Ist aber", "Ihre App."],
|
||||
problem: ["Jede Änderung", "wackelt."],
|
||||
turn: ["Eine Woche.", "Ich sehe hinein."],
|
||||
calm: ["Danach", "steht sie."],
|
||||
cta: ["App", "wackelt?"],
|
||||
},
|
||||
post: `Sieht aus wie Jenga. Ist aber Ihre App.
|
||||
|
||||
Sie läuft. Aber Fehler kommen wieder, und jede kleine Änderung fühlt sich an, als zöge man einen Stein heraus.
|
||||
|
||||
Das passiert meist, wenn eine App schnell gebaut wurde und dann immer weiter gewachsen ist.
|
||||
|
||||
Ich nehme mir eine Woche dafür: Ich sehe mir an, was solide ist und was wackelt, behebe die dringendsten Probleme, soweit eine Woche reicht, und schreibe auf, was ich als Nächstes tun würde. In klarer Sprache, die Sie auch ohne Entwicklerwissen verstehen.
|
||||
|
||||
Danach entscheiden Sie, wie es weitergeht: mit mir, mit Ihrem eigenen Team oder gar nicht.
|
||||
|
||||
Ein erstes Gespräch dauert etwa 20 Minuten.
|
||||
|
||||
www.mintel.me
|
||||
|
||||
#webentwicklung #softwarequalität #startups`,
|
||||
},
|
||||
},
|
||||
|
||||
ProductDeveloper: {
|
||||
en: {
|
||||
title: "A developer for your product",
|
||||
slides: {
|
||||
hook: ["Looks like", "a loading bar."],
|
||||
reveal: ["It's your", "backlog."],
|
||||
problem: ["Growing faster", "than your team."],
|
||||
turn: ["One more", "developer."],
|
||||
calm: ["Fixed days.", "No minimum term."],
|
||||
cta: ["Backlog", "too long?"],
|
||||
},
|
||||
post: `Looks like a loading bar. It's your backlog.
|
||||
|
||||
You have a product, and the list grows faster than your team gets through it.
|
||||
|
||||
I join on agreed days of the week and work on what is next: new features, the interface, or the parts nobody gets around to.
|
||||
|
||||
Finished parts go to you for review as they are done. I track my hours, and you can see what they were spent on. No package to buy, no minimum term: either side can end it with two weeks notice to the end of a month.
|
||||
|
||||
15 years in web development, in agencies and product teams.
|
||||
|
||||
A first call takes about 20 minutes.
|
||||
|
||||
www.mintel.me
|
||||
|
||||
#webdevelopment #freelance #react`,
|
||||
},
|
||||
de: {
|
||||
title: "Ein Entwickler für Ihr Produkt",
|
||||
slides: {
|
||||
hook: ["Sieht aus wie", "ein Ladebalken."],
|
||||
reveal: ["Ist aber", "Ihr Backlog."],
|
||||
problem: ["Wächst schneller", "als Ihr Team."],
|
||||
turn: ["Ein Entwickler", "mehr."],
|
||||
calm: ["Feste Tage.", "Keine Mindestlaufzeit."],
|
||||
cta: ["Backlog", "zu lang?"],
|
||||
},
|
||||
post: `Sieht aus wie ein Ladebalken. Ist aber Ihr Backlog.
|
||||
|
||||
Sie haben ein Produkt, und die Liste wächst schneller, als Ihr Team sie abarbeitet.
|
||||
|
||||
Ich komme an vereinbarten Wochentagen dazu und arbeite an dem, was als Nächstes ansteht: neue Funktionen, die Oberfläche oder die Teile, zu denen niemand kommt.
|
||||
|
||||
Fertige Teile gehen zur Durchsicht an Sie, sobald sie fertig sind. Ich erfasse meine Stunden, und Sie sehen, wofür sie verwendet wurden. Kein Paket, keine Mindestlaufzeit: Beide Seiten können mit zwei Wochen Frist zum Monatsende kündigen.
|
||||
|
||||
15 Jahre Webentwicklung, in Agenturen und Produktteams.
|
||||
|
||||
Ein erstes Gespräch dauert etwa 20 Minuten.
|
||||
|
||||
www.mintel.me
|
||||
|
||||
#webentwicklung #freelancer #react`,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { FC } from "react";
|
||||
import { useCurrentFrame } from "remotion";
|
||||
import { COLOR, DRAW } from "../brand/tokens";
|
||||
import { inOut, mix, out, progress } from "../illustrations/motion";
|
||||
import { FilmStage } from "./FilmStage";
|
||||
import { CAPTIONS, SHOT_FRAMES, SURFACE, T, cameraAt, glow, untilFold } from "./shot";
|
||||
|
||||
/**
|
||||
* Close up, a block wobbles on another: Jenga. The camera pulls back: it is a whole leaning tower,
|
||||
* the app, swaying with every change. It stops, a light rises through it, the blocks line up one
|
||||
* by one, and two rails keep it straight.
|
||||
*/
|
||||
const BLOCK = { width: 230, height: 44, pitch: 52, base: 290 } as const;
|
||||
/** How far each block sits off centre while the tower is crooked. */
|
||||
const OFFSETS = [0, 14, -10, 22, -18, 26, -8, 30, -22] as const;
|
||||
const TOP = OFFSETS.length - 1;
|
||||
const SWAY_FRAMES = 46;
|
||||
const SWAY_DEGREES = 2.2;
|
||||
const ALIGN_EVERY = 8;
|
||||
const RAIL_GAP = 42;
|
||||
|
||||
const blockY = (index: number): number => BLOCK.base - BLOCK.height / 2 - index * BLOCK.pitch;
|
||||
const alignStart = (index: number): number => T.resolve + index * ALIGN_EVERY;
|
||||
const LAST_ALIGN = alignStart(TOP) + 22;
|
||||
|
||||
/** The picture alone, without stage and statements: also used frozen, as an image post. */
|
||||
export const AppDrawing: FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const camera = cameraAt(frame, 2.4, 1, { x: 0, y: blockY(TOP) + 30 });
|
||||
const { zoom, hair, others } = camera;
|
||||
const folded = frame >= T.fold;
|
||||
/** How much the tower moves: it stops at the freeze and starts again for the loop. */
|
||||
const restless = folded ? progress(frame, T.fold + 16, SHOT_FRAMES) : 1 - progress(frame, T.freeze, T.turn);
|
||||
const wave = Math.sin((frame / SWAY_FRAMES) * Math.PI * 2);
|
||||
const crooked = (index: number): number =>
|
||||
folded ? inOut(progress(frame, T.fold, T.fold + 40)) : 1 - inOut(progress(frame, alignStart(index), alignStart(index) + 22));
|
||||
const scan = progress(frame, T.turn, LAST_ALIGN);
|
||||
const scanShown = Math.min(progress(frame, T.turn, T.turn + 10), 1 - progress(frame, LAST_ALIGN - 10, LAST_ALIGN));
|
||||
const rails = out(progress(frame, T.calm, T.calm + 40)) * untilFold(frame);
|
||||
const top = blockY(TOP) - BLOCK.height / 2 - 20;
|
||||
|
||||
return (
|
||||
<g transform={camera.transform}>
|
||||
<path d={`M-280 ${BLOCK.base}h560`} stroke={DRAW.fg} strokeOpacity={0.5 * others} strokeWidth={hair} strokeLinecap="round" />
|
||||
|
||||
{[-1, 1].map((side) => (
|
||||
<path
|
||||
key={side}
|
||||
d={`M${side * (BLOCK.width / 2 + RAIL_GAP)} ${BLOCK.base}V${mix(BLOCK.base, top, rails)}`}
|
||||
stroke={COLOR.done}
|
||||
strokeWidth={hair * 1.6}
|
||||
strokeLinecap="round"
|
||||
opacity={rails > 0 ? 1 : 0}
|
||||
style={glow(COLOR.done, 10, zoom)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<g transform={`rotate(${wave * SWAY_DEGREES * restless} 0 ${BLOCK.base})`}>
|
||||
{OFFSETS.map((offset, index) => {
|
||||
const off = crooked(index);
|
||||
// The top block wobbles on its own: that is the opening picture.
|
||||
const wobble = index === TOP ? Math.sin((frame / (SWAY_FRAMES / 2)) * Math.PI * 2) * 3.5 * restless : 0;
|
||||
const justAligned = folded ? 0 : Math.sin(Math.PI * progress(frame, alignStart(index), alignStart(index) + 30));
|
||||
const y = blockY(index);
|
||||
return (
|
||||
<g
|
||||
key={index}
|
||||
opacity={index >= TOP - 1 ? 1 : others}
|
||||
transform={`translate(${offset * off} 0) rotate(${wobble} ${-BLOCK.width / 2} ${y + BLOCK.height / 2})`}
|
||||
>
|
||||
<rect x={-BLOCK.width / 2} y={y - BLOCK.height / 2} width={BLOCK.width} height={BLOCK.height} rx={13} fill={DRAW.bg} />
|
||||
<rect
|
||||
x={-BLOCK.width / 2}
|
||||
y={y - BLOCK.height / 2}
|
||||
width={BLOCK.width}
|
||||
height={BLOCK.height}
|
||||
rx={13}
|
||||
fill={`rgba(248, 250, 252, ${0.1 + justAligned * 0.25})`}
|
||||
stroke={justAligned > 0.1 ? DRAW.mark : DRAW.fg}
|
||||
strokeOpacity={SURFACE.strokeOpacity + justAligned * 0.25}
|
||||
strokeWidth={hair * (1 + justAligned * 0.6)}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
|
||||
{scanShown > 0 && (
|
||||
<path
|
||||
d={`M-190 ${mix(BLOCK.base, top, scan)}h380`}
|
||||
stroke={DRAW.fg}
|
||||
strokeWidth={hair * 1.4}
|
||||
strokeLinecap="round"
|
||||
opacity={scanShown}
|
||||
style={glow(`rgba(${DRAW.light}, 1)`, 10, zoom)}
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
/** Making an app reliable: from a leaning tower that sways with every change to one that stands. */
|
||||
export const AppFilm: FC = () => (
|
||||
<FilmStage captions={CAPTIONS}>
|
||||
<AppDrawing />
|
||||
</FilmStage>
|
||||
);
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { FC } from "react";
|
||||
import { useCurrentFrame } from "remotion";
|
||||
import { COLOR, DRAW } from "../brand/tokens";
|
||||
import { mix, out, progress } from "../illustrations/motion";
|
||||
import { FilmStage } from "./FilmStage";
|
||||
import { CAPTIONS, SURFACE, T, cameraAt, glow } from "./shot";
|
||||
|
||||
/**
|
||||
* Close up, a bar fills slowly: a loading bar. The camera pulls back: it is the first of a long
|
||||
* list, the backlog, and new entries arrive faster than the team gets through them. Then one more
|
||||
* developer joins, and the list is worked off, entry after entry.
|
||||
*/
|
||||
const ROW = { x: -215, width: 470, height: 26, pitch: 44, top: -270 } as const;
|
||||
const MAX_ROWS = 13;
|
||||
const OPENING_ROWS = 4;
|
||||
const ROW_EVERY = 26;
|
||||
const TEAM_SIZE = 3;
|
||||
const WORKER_X = ROW.x - 42;
|
||||
const DONE_EVERY = 13;
|
||||
const OPENING_FILL = 0.15;
|
||||
|
||||
const rowY = (index: number): number => ROW.top + index * ROW.pitch;
|
||||
const doneStart = (index: number): number => T.resolve + index * DONE_EVERY;
|
||||
|
||||
/** The picture alone, without stage and statements: also used frozen, as an image post. */
|
||||
export const DeveloperDrawing: FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const camera = cameraAt(frame, 1.7, 1, { x: 0, y: rowY(0) });
|
||||
const { zoom, hair, others } = camera;
|
||||
const folded = frame >= T.fold;
|
||||
const rows = folded ? 1 : Math.min(MAX_ROWS, OPENING_ROWS + Math.max(0, frame - T.reveal) / ROW_EVERY);
|
||||
const dot = 12 / Math.sqrt(zoom);
|
||||
const joined = out(progress(frame, T.turn, T.turn + 28));
|
||||
|
||||
return (
|
||||
<g transform={camera.transform}>
|
||||
{Array.from({ length: MAX_ROWS }, (_, index) => {
|
||||
if (index >= rows) return null;
|
||||
const y = rowY(index);
|
||||
const arrived = Math.min(1, rows - index);
|
||||
const worked = index === 0 ? mix(OPENING_FILL, 0.6, progress(frame, 0, T.turn)) : index < TEAM_SIZE ? mix(0, 0.4, progress(frame, T.reveal, T.turn)) : 0;
|
||||
const finishing = folded ? 0 : out(progress(frame, doneStart(index), doneStart(index) + 16));
|
||||
const leaving = folded ? 0 : progress(frame, doneStart(index) + 18, doneStart(index) + 32);
|
||||
const fill = folded ? OPENING_FILL : mix(worked, 1, finishing);
|
||||
const shown = folded ? progress(frame, T.fold + 16, T.fold + 44) : arrived * (1 - leaving) * (index === 0 ? 1 : others);
|
||||
if (shown === 0) return null;
|
||||
return (
|
||||
<g key={index} opacity={shown} transform={`translate(${-36 * leaving} ${(1 - out(arrived)) * 26})`}>
|
||||
<rect
|
||||
x={ROW.x}
|
||||
y={y - ROW.height / 2}
|
||||
width={ROW.width}
|
||||
height={ROW.height}
|
||||
rx={ROW.height / 2}
|
||||
fill={SURFACE.fill}
|
||||
stroke={DRAW.fg}
|
||||
strokeOpacity={0.5}
|
||||
strokeWidth={hair}
|
||||
/>
|
||||
{fill > 0 && (
|
||||
<rect
|
||||
x={ROW.x}
|
||||
y={y - ROW.height / 2}
|
||||
width={Math.max(ROW.height, ROW.width * fill)}
|
||||
height={ROW.height}
|
||||
rx={ROW.height / 2}
|
||||
fill={finishing === 1 ? COLOR.done : DRAW.fg}
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{Array.from({ length: TEAM_SIZE }, (_, index) => (
|
||||
<circle key={index} cx={WORKER_X} cy={rowY(index)} r={dot} fill={DRAW.fg} opacity={index === 0 ? 1 : others} />
|
||||
))}
|
||||
<circle
|
||||
cx={mix(WORKER_X - 150, WORKER_X, joined)}
|
||||
cy={rowY(TEAM_SIZE)}
|
||||
r={dot}
|
||||
fill={DRAW.mark}
|
||||
opacity={joined * others}
|
||||
style={glow(DRAW.mark, 14, zoom)}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
/** A developer for a product: from a backlog that grows faster than the team to a list worked off. */
|
||||
export const DeveloperFilm: FC = () => (
|
||||
<FilmStage captions={CAPTIONS}>
|
||||
<DeveloperDrawing />
|
||||
</FilmStage>
|
||||
);
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { FC } from "react";
|
||||
import { useCurrentFrame } from "remotion";
|
||||
import { DRAW } from "../brand/tokens";
|
||||
import { inOut, mix, out, progress } from "../illustrations/motion";
|
||||
import { FilmStage } from "./FilmStage";
|
||||
import { CAPTIONS, SHOT_FRAMES, SURFACE, T, cameraAt, glow, untilFold } from "./shot";
|
||||
|
||||
/**
|
||||
* Close up, bars drop onto a stack: a skyscraper going up. The camera pulls back: it is the pile of
|
||||
* invoices waiting to be typed in, and it keeps growing. Then a light runs down the pile and the
|
||||
* sheets leave by themselves: most into the books, the few unclear ones to a person.
|
||||
*/
|
||||
const BAR = { x: -170, width: 210, height: 16, pitch: 25, base: 290 } as const;
|
||||
const MAX_BARS = 24;
|
||||
const BOOKS = { x: 250, y: -70, size: 130 } as const;
|
||||
const PERSON = { x: 250, y: 170, r: 44 } as const;
|
||||
const OPENING_BARS = 3;
|
||||
const CLOSE_EVERY = 25;
|
||||
/** The film starts with a bar in mid-fall: the first frame is the preview image. */
|
||||
const CLOSE_PHASE = 12;
|
||||
const WIDE_EVERY = 9;
|
||||
const LEAVE_EVERY = 5;
|
||||
const LEAVE_FRAMES = 24;
|
||||
/** Every sixth sheet is unclear and goes to a person. */
|
||||
const isUnclear = (index: number): boolean => index % 6 === 2;
|
||||
|
||||
const barY = (index: number): number => BAR.base - BAR.height / 2 - index * BAR.pitch;
|
||||
|
||||
/** How many bars have arrived, as a number with a fraction: the fraction is the one still falling. */
|
||||
const barsAt = (frame: number): number => {
|
||||
if (frame >= T.fold) return OPENING_BARS + (frame - SHOT_FRAMES + CLOSE_PHASE) / CLOSE_EVERY;
|
||||
const close = OPENING_BARS + (Math.min(frame, T.reveal) + CLOSE_PHASE) / CLOSE_EVERY;
|
||||
return Math.min(MAX_BARS, close + Math.max(0, frame - T.reveal) / WIDE_EVERY);
|
||||
};
|
||||
|
||||
const leaveStart = (index: number): number => T.resolve + (MAX_BARS - 1 - index) * LEAVE_EVERY;
|
||||
const LAST_LEAVE = T.resolve + MAX_BARS * LEAVE_EVERY;
|
||||
const UNCLEAR = Array.from({ length: MAX_BARS }, (_, index) => index).filter(isUnclear);
|
||||
|
||||
/** Where an unclear sheet waits next to the person. */
|
||||
const waitingSpot = (index: number): { x: number; y: number } => {
|
||||
const angle = UNCLEAR.indexOf(index) * 0.62 + 2.2;
|
||||
return { x: PERSON.x + Math.cos(angle) * 78, y: PERSON.y + Math.sin(angle) * 78 };
|
||||
};
|
||||
|
||||
/** The picture alone, without stage and statements: also used frozen, as an image post. */
|
||||
export const DocumentsDrawing: FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const camera = cameraAt(frame, 2.6, 0.95, { x: BAR.x, y: barY(OPENING_BARS) - 20 });
|
||||
const { zoom, hair, others } = camera;
|
||||
const bars = barsAt(frame);
|
||||
const leaving = frame < T.fold;
|
||||
const scan = progress(frame, T.turn, LAST_LEAVE);
|
||||
const scanShown = Math.min(progress(frame, T.turn, T.turn + 10), 1 - progress(frame, LAST_LEAVE - 10, LAST_LEAVE));
|
||||
const dot = 11 / Math.sqrt(zoom);
|
||||
|
||||
return (
|
||||
<g transform={camera.transform}>
|
||||
<path d={`M${BAR.x - 170} ${BAR.base}h340`} stroke={DRAW.fg} strokeOpacity={0.5} strokeWidth={hair} strokeLinecap="round" />
|
||||
|
||||
<g opacity={others}>
|
||||
<rect
|
||||
x={BOOKS.x - BOOKS.size / 2}
|
||||
y={BOOKS.y - BOOKS.size / 2}
|
||||
width={BOOKS.size}
|
||||
height={BOOKS.size}
|
||||
rx={BOOKS.size * 0.24}
|
||||
fill={SURFACE.fill}
|
||||
stroke={DRAW.fg}
|
||||
strokeOpacity={SURFACE.strokeOpacity}
|
||||
strokeWidth={hair}
|
||||
/>
|
||||
<circle cx={PERSON.x} cy={PERSON.y} r={PERSON.r} fill={SURFACE.fill} stroke={DRAW.fg} strokeOpacity={SURFACE.strokeOpacity} strokeWidth={hair} />
|
||||
</g>
|
||||
|
||||
{Array.from({ length: MAX_BARS }, (_, index) => {
|
||||
if (index >= bars) return null;
|
||||
const arrived = Math.min(1, bars - index);
|
||||
const fadeIn = leaving ? 1 : progress(frame, T.fold + 8, T.fold + 30);
|
||||
const gone = leaving ? inOut(progress(frame, leaveStart(index), leaveStart(index) + LEAVE_FRAMES)) : 0;
|
||||
const unclear = isUnclear(index);
|
||||
const target = unclear ? waitingSpot(index) : BOOKS;
|
||||
const width = mix(BAR.width, dot * 2, gone);
|
||||
const height = mix(BAR.height, dot * 2, gone);
|
||||
const x = mix(BAR.x, target.x, gone);
|
||||
const y = mix(mix(barY(index) - 150 / zoom, barY(index), out(arrived)), target.y, gone) - Math.sin(Math.PI * gone) * 70;
|
||||
const opacity = arrived * fadeIn * (unclear ? 1 : 1 - progress(gone, 0.82, 1));
|
||||
if (opacity === 0) return null;
|
||||
return (
|
||||
<rect
|
||||
key={index}
|
||||
x={x - width / 2}
|
||||
y={y - height / 2}
|
||||
width={width}
|
||||
height={height}
|
||||
rx={height / 2}
|
||||
fill={unclear && gone > 0 ? DRAW.mark : DRAW.fg}
|
||||
opacity={opacity * (gone > 0 ? 1 : 0.9)}
|
||||
style={unclear && gone > 0.5 ? glow(DRAW.mark, 10, zoom) : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{!leaving &&
|
||||
UNCLEAR.map((index) => {
|
||||
const spot = waitingSpot(index);
|
||||
return <circle key={index} cx={spot.x} cy={spot.y} r={dot} fill={DRAW.mark} opacity={untilFold(frame) * others} />;
|
||||
})}
|
||||
|
||||
{scanShown > 0 && (
|
||||
<path
|
||||
d={`M${BAR.x - 150} ${mix(barY(MAX_BARS - 1), BAR.base, scan)}h300`}
|
||||
stroke={DRAW.fg}
|
||||
strokeWidth={hair * 1.4}
|
||||
strokeLinecap="round"
|
||||
opacity={scanShown}
|
||||
style={glow(`rgba(${DRAW.light}, 1)`, 10, zoom)}
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
/** Incoming invoices: from a growing pile to documents that are read and booked by themselves. */
|
||||
export const DocumentsFilm: FC = () => (
|
||||
<FilmStage captions={CAPTIONS}>
|
||||
<DocumentsDrawing />
|
||||
</FilmStage>
|
||||
);
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { FC } from "react";
|
||||
import { useCurrentFrame } from "remotion";
|
||||
import { DRAW } from "../brand/tokens";
|
||||
import { mix, out, progress } from "../illustrations/motion";
|
||||
import type { Point } from "../lib/hop";
|
||||
import { FilmStage } from "./FilmStage";
|
||||
import { CAPTIONS, SURFACE, T, cameraAt, glow, untilFold } from "./shot";
|
||||
|
||||
/**
|
||||
* Close up, one point of light: a star. The camera pulls back: a whole sky of them, each one an
|
||||
* enquiry, and one after the other they burn out unanswered. Then a line reaches every one of them
|
||||
* from the team in the middle, they light up again, and a quiet ring keeps reminding.
|
||||
*/
|
||||
const TEAM = { x: 0, y: 10, size: 120 } as const;
|
||||
/** The first one is the star of the opening picture. */
|
||||
const STARS: readonly Point[] = [
|
||||
{ x: -190, y: -90 },
|
||||
{ x: -380, y: -210 },
|
||||
{ x: -60, y: -260 },
|
||||
{ x: 210, y: -220 },
|
||||
{ x: 400, y: -130 },
|
||||
{ x: 320, y: 50 },
|
||||
{ x: 420, y: 230 },
|
||||
{ x: 170, y: 250 },
|
||||
{ x: -90, y: 230 },
|
||||
{ x: -300, y: 250 },
|
||||
{ x: -430, y: 50 },
|
||||
{ x: -250, y: 110 },
|
||||
{ x: 150, y: -90 },
|
||||
];
|
||||
const HERO: Point = STARS[0] ?? { x: 0, y: 0 };
|
||||
const TWINKLE_FRAMES = 23;
|
||||
const RING_FRAMES = 60;
|
||||
|
||||
const lineStart = (index: number): number => T.resolve + index * 6;
|
||||
|
||||
/** How brightly an enquiry still shines: it fades while nobody answers and returns once it is reached. */
|
||||
const brightness = (index: number, frame: number): number => {
|
||||
if (frame >= T.fold) return 1;
|
||||
const fadeFrom = index === 0 ? 0 : 60 + index * 17;
|
||||
const faded = progress(frame, fadeFrom, fadeFrom + (index === 0 ? 170 : 90));
|
||||
const reached = out(progress(frame, lineStart(index) + 18, lineStart(index) + 40));
|
||||
return mix(1 - 0.88 * faded, 1, reached);
|
||||
};
|
||||
|
||||
/** The picture alone, without stage and statements: also used frozen, as an image post. */
|
||||
export const EnquiryDrawing: FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const camera = cameraAt(frame, 3, 0.95, HERO);
|
||||
const { zoom, hair, others } = camera;
|
||||
const radius = 11 / Math.sqrt(zoom);
|
||||
const sinceCalm = frame - T.calm;
|
||||
|
||||
return (
|
||||
<g transform={camera.transform}>
|
||||
{STARS.map((star, index) => {
|
||||
const drawn = out(progress(frame, lineStart(index), lineStart(index) + 30)) * untilFold(frame);
|
||||
return drawn === 0 ? null : (
|
||||
<path
|
||||
key={index}
|
||||
d={`M${TEAM.x} ${TEAM.y}L${mix(TEAM.x, star.x, drawn)} ${mix(TEAM.y, star.y, drawn)}`}
|
||||
stroke={DRAW.fg}
|
||||
strokeOpacity={0.6}
|
||||
strokeWidth={hair}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{sinceCalm > 0 &&
|
||||
[0, RING_FRAMES / 2].map((delay) => {
|
||||
const t = ((sinceCalm + delay) % RING_FRAMES) / RING_FRAMES;
|
||||
return (
|
||||
<circle
|
||||
key={delay}
|
||||
cx={TEAM.x}
|
||||
cy={TEAM.y}
|
||||
r={mix(TEAM.size * 0.6, TEAM.size * 1.5, t)}
|
||||
fill="none"
|
||||
stroke={`rgba(${DRAW.light}, 1)`}
|
||||
strokeWidth={hair}
|
||||
opacity={(1 - t) * 0.7 * untilFold(frame) * progress(sinceCalm, 0, 20)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<g opacity={others}>
|
||||
<rect x={TEAM.x - TEAM.size / 2} y={TEAM.y - TEAM.size / 2} width={TEAM.size} height={TEAM.size} rx={TEAM.size * 0.24} fill={DRAW.bg} />
|
||||
<rect
|
||||
x={TEAM.x - TEAM.size / 2}
|
||||
y={TEAM.y - TEAM.size / 2}
|
||||
width={TEAM.size}
|
||||
height={TEAM.size}
|
||||
rx={TEAM.size * 0.24}
|
||||
fill={SURFACE.fill}
|
||||
stroke={DRAW.fg}
|
||||
strokeOpacity={SURFACE.strokeOpacity}
|
||||
strokeWidth={hair}
|
||||
/>
|
||||
</g>
|
||||
|
||||
{STARS.map((star, index) => {
|
||||
const twinkle = 0.92 + 0.08 * Math.sin((frame / TWINKLE_FRAMES) * Math.PI * 2 + index);
|
||||
const shine = brightness(index, frame) * twinkle;
|
||||
return (
|
||||
<circle
|
||||
key={index}
|
||||
cx={star.x}
|
||||
cy={star.y}
|
||||
r={radius}
|
||||
fill={DRAW.mark}
|
||||
opacity={shine * (index === 0 ? 1 : others)}
|
||||
style={glow(DRAW.mark, 16 * shine, zoom)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
/** Enquiries: from stars that burn out unanswered to every one reaching the right person. */
|
||||
export const EnquiryFilm: FC = () => (
|
||||
<FilmStage captions={CAPTIONS}>
|
||||
<EnquiryDrawing />
|
||||
</FilmStage>
|
||||
);
|
||||
@@ -0,0 +1,200 @@
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { Easing, Img, interpolate, staticFile, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import { BrandButton } from "../brand/BrandButton";
|
||||
import { FONT } from "../brand/fonts";
|
||||
import { DRAW } from "../brand/tokens";
|
||||
import { useCopy } from "../content/CopyContext";
|
||||
import { headlineSize } from "../slides/SlideDeck";
|
||||
|
||||
/** Films are drawn in this coordinate system, with (0, 0) in the centre; the stage is scaled to fit. */
|
||||
export const STAGE = { width: 1000, height: 700 } as const;
|
||||
|
||||
/** One statement: which text (key in the copy) and while which frames. */
|
||||
export type Caption = {
|
||||
readonly id: string;
|
||||
readonly from: number;
|
||||
readonly to: number;
|
||||
/** Show the website's button below this statement. */
|
||||
readonly button?: boolean;
|
||||
};
|
||||
|
||||
export const SIDE = 60;
|
||||
const FOOTER_HEIGHT = 130;
|
||||
const MARK_HEIGHT = 76;
|
||||
const GAP = 44;
|
||||
/** Room for two lines and the button below the stage, in px. */
|
||||
const STATEMENT_HEIGHT = 330;
|
||||
const MAX_WIDE_FONT = 88;
|
||||
const IN_FRAMES = 18;
|
||||
const OUT_FRAMES = 10;
|
||||
const RISE_PX = 18;
|
||||
const EDGE_FADE =
|
||||
"linear-gradient(to bottom, transparent, #000 9%, #000 91%, transparent), linear-gradient(to right, transparent, #000 6%, #000 94%, transparent)";
|
||||
|
||||
const clampOpts = { extrapolateLeft: "clamp", extrapolateRight: "clamp" } as const;
|
||||
/** Fast start, long soft landing: the easing of a calm presentation. */
|
||||
const settle = Easing.bezier(0.16, 1, 0.3, 1);
|
||||
|
||||
/** Two or three lines in the headline type: the first ones light, the last one quieter. */
|
||||
export const Lines: FC<{ lines: readonly string[]; fontSize: number }> = ({ lines, fontSize }) => (
|
||||
<>
|
||||
{lines.map((line, index) => (
|
||||
<div
|
||||
key={line}
|
||||
style={{
|
||||
fontFamily: FONT.sans,
|
||||
fontWeight: 900,
|
||||
fontSize,
|
||||
lineHeight: 1.06,
|
||||
letterSpacing: "-0.04em",
|
||||
wordSpacing: "0.08em",
|
||||
color: index > 0 && index === lines.length - 1 ? DRAW.label : DRAW.fg,
|
||||
}}
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
const Statement: FC<{ caption: Caption; first: boolean; fontSize: number; total: number }> = ({
|
||||
caption,
|
||||
first,
|
||||
fontSize,
|
||||
total,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const lines = useCopy().slides[caption.id] ?? [];
|
||||
// The first statement stands on frame 0 (the preview image) and returns at the very end.
|
||||
const back = first ? interpolate(frame, [total - OUT_FRAMES, total - 1], [0, 1], clampOpts) : 0;
|
||||
const arrive = first ? 1 : settle(interpolate(frame, [caption.from, caption.from + IN_FRAMES], [0, 1], clampOpts));
|
||||
const leave = interpolate(frame, [caption.to - OUT_FRAMES, caption.to - 1], [1, 0], clampOpts);
|
||||
const opacity = Math.max(frame < caption.to ? Math.min(arrive, leave) : 0, back);
|
||||
if (opacity === 0) return null;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
textAlign: "center",
|
||||
opacity,
|
||||
transform: `translateY(${(1 - arrive) * RISE_PX}px)`,
|
||||
}}
|
||||
>
|
||||
<Lines lines={lines} fontSize={fontSize} />
|
||||
{caption.button && (
|
||||
<div style={{ marginTop: fontSize * 0.55 }}>
|
||||
<BrandButton label="Get in touch" size={Math.round(fontSize * 0.3)} tone="light" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* The ground every film and image stands on: a quiet dark surface with one soft light, the
|
||||
* signature very large and faint behind everything, and a small line with signature and address.
|
||||
*/
|
||||
export const Backdrop: FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { width, height, durationInFrames } = useVideoConfig();
|
||||
const wide = width > height;
|
||||
const turn = (frame / durationInFrames) * Math.PI * 2;
|
||||
return (
|
||||
<div style={{ position: "absolute", inset: 0, overflow: "hidden", backgroundColor: DRAW.bg }}>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
background: `radial-gradient(60% 50% at ${50 + Math.sin(turn) * 10}% ${38 + Math.cos(turn) * 6}%, rgba(${DRAW.glow}, 0.3), transparent 75%)`,
|
||||
}}
|
||||
/>
|
||||
<Img
|
||||
src={staticFile("logo-mark.svg")}
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: -width * 0.12,
|
||||
bottom: -width * 0.06,
|
||||
width: width * (wide ? 0.5 : 0.95),
|
||||
opacity: 0.04,
|
||||
}}
|
||||
/>
|
||||
{children}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: SIDE,
|
||||
right: SIDE,
|
||||
bottom: 44,
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-end",
|
||||
}}
|
||||
>
|
||||
<Img src={staticFile("logo-mark.svg")} style={{ height: MARK_HEIGHT, opacity: 0.92 }} />
|
||||
<span
|
||||
style={{
|
||||
fontFamily: FONT.mono,
|
||||
fontWeight: 500,
|
||||
fontSize: 22,
|
||||
letterSpacing: "0.2em",
|
||||
textTransform: "uppercase",
|
||||
color: DRAW.label,
|
||||
}}
|
||||
>
|
||||
www.mintel.me
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* What every film shares: the backdrop, the drawing in the middle, and one statement at a time
|
||||
* centred below it. Deliberately little (owner feedback: minimal, calm, one idea at a time).
|
||||
* The film itself is drawn by `children` as SVG content in STAGE coordinates.
|
||||
*/
|
||||
export const FilmStage: FC<{ captions: readonly Caption[]; children: ReactNode }> = ({ captions, children }) => {
|
||||
const { width, height, durationInFrames } = useVideoConfig();
|
||||
const copy = useCopy();
|
||||
const wide = width > height;
|
||||
const scale = Math.min(
|
||||
(width - SIDE * 2) / STAGE.width,
|
||||
(height - FOOTER_HEIGHT - GAP - STATEMENT_HEIGHT - 50) / STAGE.height,
|
||||
);
|
||||
const stageHeight = STAGE.height * scale;
|
||||
const stageTop = (height - FOOTER_HEIGHT - stageHeight - GAP - STATEMENT_HEIGHT) / 2 + 20;
|
||||
const textWidth = width - SIDE * 2;
|
||||
// One size for the whole film: the largest at which its longest line still fits.
|
||||
const fontSize = Math.min(
|
||||
wide ? MAX_WIDE_FONT : Infinity,
|
||||
headlineSize(captions.flatMap((caption) => copy.slides[caption.id] ?? []), textWidth),
|
||||
);
|
||||
|
||||
return (
|
||||
<Backdrop>
|
||||
<svg
|
||||
width={STAGE.width * scale}
|
||||
height={stageHeight}
|
||||
viewBox={`${-STAGE.width / 2} ${-STAGE.height / 2} ${STAGE.width} ${STAGE.height}`}
|
||||
// While the camera moves, the drawing is larger than the stage: it fades out at the stage's edges.
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: (width - STAGE.width * scale) / 2,
|
||||
top: stageTop,
|
||||
maskImage: EDGE_FADE,
|
||||
WebkitMaskImage: EDGE_FADE,
|
||||
maskComposite: "intersect",
|
||||
WebkitMaskComposite: "source-in",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
<div style={{ position: "absolute", left: SIDE, width: textWidth, top: stageTop + stageHeight + GAP, height: STATEMENT_HEIGHT }}>
|
||||
{captions.map((caption, index) => (
|
||||
<Statement key={caption.id} caption={caption} first={index === 0} fontSize={fontSize} total={durationInFrames} />
|
||||
))}
|
||||
</div>
|
||||
</Backdrop>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,196 @@
|
||||
import type { FC } from "react";
|
||||
import { useCurrentFrame } from "remotion";
|
||||
import { DRAW } from "../brand/tokens";
|
||||
import { inOut, mix, out, progress } from "../illustrations/motion";
|
||||
import { hopAt, type Point } from "../lib/hop";
|
||||
import { FilmStage, type Caption } from "./FilmStage";
|
||||
|
||||
export const ORDER_FILM_FRAMES = 690;
|
||||
|
||||
/**
|
||||
* One continuous shot, told by the camera. Close up, a dot drifts between two squares: ping-pong.
|
||||
* The camera pulls back: it is one of many, a whole office carrying things from tool to tool.
|
||||
* Everything stops, the dots sink onto a line, and the lines carry the work by themselves.
|
||||
* The camera returns to the two squares, so the film loops.
|
||||
*/
|
||||
const T = {
|
||||
reveal: 75,
|
||||
wide: 195,
|
||||
freeze: 310,
|
||||
sink: 345,
|
||||
lines: 365,
|
||||
flow: 440,
|
||||
cta: 560,
|
||||
fold: 630,
|
||||
} as const;
|
||||
|
||||
const CAPTIONS: readonly Caption[] = [
|
||||
{ id: "hook", from: 0, to: T.reveal + 20 },
|
||||
{ id: "reveal", from: T.reveal + 30, to: T.wide + 30 },
|
||||
{ id: "problem", from: T.wide + 30, to: T.freeze + 20 },
|
||||
{ id: "turn", from: T.sink, to: T.flow + 20 },
|
||||
{ id: "calm", from: T.flow + 20, to: T.cta },
|
||||
{ id: "cta", from: T.cta, to: ORDER_FILM_FRAMES, button: true },
|
||||
];
|
||||
|
||||
const COLS = 4;
|
||||
const HERO_ROW = 2;
|
||||
const PITCH = { x: 220, y: 190 } as const;
|
||||
const TILE = 96;
|
||||
const ZOOM_IN = 2.6;
|
||||
const ZOOM_OUT = 0.8;
|
||||
/** Height of a hop as a share of its length. */
|
||||
const ARC = 0.22;
|
||||
const CLOSE_HOP_FRAMES = 30;
|
||||
/** The film starts in mid-flight: the first frame is the preview image. */
|
||||
const CLOSE_PHASE = 15;
|
||||
|
||||
const colX = (col: number): number => (col - (COLS - 1) / 2) * PITCH.x;
|
||||
const rowY = (row: number): number => (row - HERO_ROW) * PITCH.y;
|
||||
const stop = (row: number, col: number): Point => ({ x: colX(col), y: rowY(row) });
|
||||
|
||||
/** Each row has someone carrying things round its four tools, in their own order and pace. */
|
||||
const WALKERS = [
|
||||
{ route: [0, 1, 2, 3], hopFrames: 27, offset: 7 },
|
||||
{ route: [2, 0, 3, 1], hopFrames: 32, offset: 40 },
|
||||
{ route: [2, 3, 0, 1], hopFrames: 29, offset: 0 },
|
||||
{ route: [3, 2, 1, 0], hopFrames: 25, offset: 19 },
|
||||
{ route: [1, 3, 0, 2], hopFrames: 30, offset: 55 },
|
||||
] as const;
|
||||
|
||||
/** 0 while the camera is close, 1 when it has pulled back. */
|
||||
const pulledBack = (frame: number): number =>
|
||||
inOut(progress(frame, T.reveal, T.wide)) * (1 - inOut(progress(frame, T.fold, ORDER_FILM_FRAMES)));
|
||||
|
||||
/** Time as the walkers feel it: it runs, then slows to a halt at the freeze. */
|
||||
const walkTime = (frame: number): number => {
|
||||
if (frame < T.freeze) return frame;
|
||||
const u = progress(frame, T.freeze, T.freeze + 30);
|
||||
return T.freeze + 15 * (1 - (1 - u) ** 2);
|
||||
};
|
||||
|
||||
const walkerAt = (row: number, frame: number): Point => {
|
||||
const walker = WALKERS[row];
|
||||
if (!walker) return stop(row, 0);
|
||||
const time = walkTime(frame);
|
||||
// The one in the middle row plays ping-pong between the two inner squares while the camera is close.
|
||||
if (row === HERO_ROW && (time < T.reveal || frame >= T.fold)) {
|
||||
const loopTime = frame >= T.fold ? frame - ORDER_FILM_FRAMES : time;
|
||||
return hopAt(loopTime + CLOSE_PHASE, [stop(row, 1), stop(row, 2)], CLOSE_HOP_FRAMES, ARC * 1.4);
|
||||
}
|
||||
const start = row === HERO_ROW ? T.reveal : -walker.offset;
|
||||
return hopAt(time - start, walker.route.map((col) => stop(row, col)), walker.hopFrames, ARC);
|
||||
};
|
||||
|
||||
/** The picture alone, without stage and statements: also used frozen, as an image post. */
|
||||
export const OrderDrawing: FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const back = pulledBack(frame);
|
||||
const zoom = ZOOM_IN * (ZOOM_OUT / ZOOM_IN) ** back;
|
||||
/** Everything outside the opening picture fades in as the camera pulls back. */
|
||||
const others = progress(back, 0.05, 0.5);
|
||||
const hair = 2.2 / zoom;
|
||||
// Back in the air for the return to the opening picture.
|
||||
const sink = frame >= T.fold ? 0 : inOut(progress(frame, T.sink, T.sink + 30));
|
||||
const folding = 1 - progress(frame, T.fold, T.fold + 24);
|
||||
|
||||
return (
|
||||
<g transform={`scale(${zoom})`}>
|
||||
{WALKERS.map((_, row) => {
|
||||
const hero = row === HERO_ROW;
|
||||
const y = rowY(row);
|
||||
const away = Math.abs(row - HERO_ROW);
|
||||
|
||||
// The line that replaces the walking.
|
||||
const lineStart = T.lines + away * 12;
|
||||
const drawn = out(progress(frame, lineStart, lineStart + 45)) * folding;
|
||||
const lineEnd = mix(colX(0), colX(COLS - 1), drawn);
|
||||
|
||||
// Work travelling along the line by itself.
|
||||
const sincePulse = frame - (T.flow + away * 10);
|
||||
const pulseT = sincePulse < 0 ? -1 : (sincePulse % 80) / 55;
|
||||
const pulseX = pulseT >= 0 && pulseT <= 1 ? mix(colX(0), colX(COLS - 1), inOut(pulseT)) : null;
|
||||
|
||||
// The walker, and the light it trails.
|
||||
const walkerShown =
|
||||
(hero ? 1 : others) * (1 - progress(frame, lineStart, lineStart + 18)) +
|
||||
(hero ? progress(frame, T.fold + 28, ORDER_FILM_FRAMES - 6) : 0);
|
||||
const place = (at: number): Point => {
|
||||
const point = walkerAt(row, at);
|
||||
return { x: point.x, y: mix(point.y, y, sink) };
|
||||
};
|
||||
const walker = place(frame);
|
||||
const radius = 12 / Math.sqrt(zoom);
|
||||
|
||||
return (
|
||||
<g key={row}>
|
||||
{drawn > 0 && (
|
||||
<path
|
||||
d={`M${colX(0)} ${y}H${lineEnd}`}
|
||||
stroke={DRAW.fg}
|
||||
strokeWidth={hair * 1.3}
|
||||
strokeLinecap="round"
|
||||
opacity={hero ? 1 : others}
|
||||
style={{ filter: `drop-shadow(0 0 ${6 / zoom}px rgba(${DRAW.light}, 0.9))` }}
|
||||
/>
|
||||
)}
|
||||
{Array.from({ length: COLS }, (_unused, col) => {
|
||||
const x = colX(col);
|
||||
const opening = hero && (col === 1 || col === 2);
|
||||
const touched = walkerShown > 0.5 && Math.hypot(walker.x - x, walker.y - y) < TILE * 0.42;
|
||||
const lit = pulseX === null ? 0 : Math.max(0, 1 - Math.abs(pulseX - x) / 110) * folding;
|
||||
return (
|
||||
<g key={col} opacity={opening ? 1 : others}>
|
||||
<rect x={x - TILE / 2} y={y - TILE / 2} width={TILE} height={TILE} rx={TILE * 0.24} fill={DRAW.bg} />
|
||||
<rect
|
||||
x={x - TILE / 2}
|
||||
y={y - TILE / 2}
|
||||
width={TILE}
|
||||
height={TILE}
|
||||
rx={TILE * 0.24}
|
||||
fill={`rgba(248, 250, 252, ${0.1 + lit * 0.22})`}
|
||||
stroke={touched ? DRAW.mark : DRAW.fg}
|
||||
strokeOpacity={touched ? 1 : 0.75 + lit * 0.25}
|
||||
strokeWidth={touched ? hair * 1.6 : hair}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{pulseX !== null && folding > 0 && (
|
||||
<circle
|
||||
cx={pulseX}
|
||||
cy={y}
|
||||
r={radius * 0.8}
|
||||
fill={DRAW.mark}
|
||||
opacity={(hero ? 1 : others) * folding}
|
||||
style={{ filter: `drop-shadow(0 0 ${10 / zoom}px ${DRAW.mark})` }}
|
||||
/>
|
||||
)}
|
||||
{walkerShown > 0 && (
|
||||
<g opacity={Math.min(1, walkerShown)}>
|
||||
{[1, 2, 3, 4, 5].map((k) => {
|
||||
const ghost = place(frame - k * 2.2);
|
||||
return <circle key={k} cx={ghost.x} cy={ghost.y} r={radius * (1 - k * 0.13)} fill={DRAW.mark} opacity={0.3 - k * 0.05} />;
|
||||
})}
|
||||
<circle
|
||||
cx={walker.x}
|
||||
cy={walker.y}
|
||||
r={radius}
|
||||
fill={DRAW.mark}
|
||||
style={{ filter: `drop-shadow(0 0 ${12 / zoom}px ${DRAW.mark})` }}
|
||||
/>
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
/** Automation, as one continuous shot: from ping-pong to tools that talk to each other. */
|
||||
export const OrderFilm: FC = () => (
|
||||
<FilmStage captions={CAPTIONS}>
|
||||
<OrderDrawing />
|
||||
</FilmStage>
|
||||
);
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { FC } from "react";
|
||||
import { Easing, useCurrentFrame } from "remotion";
|
||||
import { DRAW } from "../brand/tokens";
|
||||
import { inOut, mix, progress } from "../illustrations/motion";
|
||||
import { FilmStage } from "./FilmStage";
|
||||
import { CAPTIONS, SURFACE, T, cameraAt, untilFold } from "./shot";
|
||||
|
||||
/**
|
||||
* Close up, a blinking cursor: a beginning. The camera pulls back: it is an empty page, and it has
|
||||
* been empty for months. Then the page builds itself, block by block, and becomes three screens.
|
||||
*/
|
||||
const WINDOW = { width: 640, height: 440 } as const;
|
||||
const BAR_HEIGHT = 50;
|
||||
const CURSOR = { x: -250, y: -110, width: 9, height: 62 } as const;
|
||||
const BLINK_FRAMES = 30;
|
||||
const settle = Easing.bezier(0.16, 1, 0.3, 1);
|
||||
|
||||
/** The blocks of a simple page, in the coordinates of a screen of the given size. */
|
||||
const Page: FC<{ width: number; height: number; columns: number; shown: (block: number) => number }> = ({
|
||||
width,
|
||||
height,
|
||||
columns,
|
||||
shown,
|
||||
}) => {
|
||||
const pad = width * 0.07;
|
||||
const inner = width - pad * 2;
|
||||
const top = -height / 2 + pad;
|
||||
const heroHeight = height * 0.42;
|
||||
const cardsTop = top + heroHeight + pad * 0.6;
|
||||
const cardHeight = height / 2 - pad - cardsTop;
|
||||
const gap = pad * 0.5;
|
||||
const cardWidth = (inner - gap * (columns - 1)) / columns;
|
||||
const block = (index: number): { opacity: number; transform: string } => ({
|
||||
opacity: shown(index),
|
||||
transform: `translate(0 ${(1 - shown(index)) * 22})`,
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<g {...block(0)}>
|
||||
<rect x={-inner / 2} y={top} width={inner} height={heroHeight} rx={pad * 0.45} fill={DRAW.fg} />
|
||||
<rect x={-inner / 2 + pad} y={top + heroHeight * 0.34} width={inner * 0.5} height={heroHeight * 0.13} rx={heroHeight * 0.065} fill={DRAW.bg} />
|
||||
<rect x={-inner / 2 + pad} y={top + heroHeight * 0.58} width={inner * 0.3} height={heroHeight * 0.13} rx={heroHeight * 0.065} fill={DRAW.bg} opacity={0.45} />
|
||||
</g>
|
||||
{Array.from({ length: columns }, (_, column) => (
|
||||
<rect
|
||||
key={column}
|
||||
{...block(1 + column)}
|
||||
x={-inner / 2 + column * (cardWidth + gap)}
|
||||
y={cardsTop}
|
||||
width={cardWidth}
|
||||
height={cardHeight}
|
||||
rx={pad * 0.45}
|
||||
fill="rgba(248, 250, 252, 0.22)"
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Screen: FC<{ width: number; height: number; hair: number; radius: number }> = ({ width, height, hair, radius }) => (
|
||||
<>
|
||||
<rect x={-width / 2} y={-height / 2} width={width} height={height} rx={radius} fill={DRAW.bg} />
|
||||
<rect
|
||||
x={-width / 2}
|
||||
y={-height / 2}
|
||||
width={width}
|
||||
height={height}
|
||||
rx={radius}
|
||||
fill={SURFACE.fill}
|
||||
stroke={DRAW.fg}
|
||||
strokeOpacity={SURFACE.strokeOpacity}
|
||||
strokeWidth={hair}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
const DEVICES = [
|
||||
{ x: 195, y: 20, width: 190, height: 270, columns: 2 },
|
||||
{ x: 380, y: 50, width: 112, height: 210, columns: 1 },
|
||||
] as const;
|
||||
|
||||
/** The picture alone, without stage and statements: also used frozen, as an image post. */
|
||||
export const WebsiteDrawing: FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const camera = cameraAt(frame, 3, 1, CURSOR);
|
||||
const { hair, others } = camera;
|
||||
const blink = Math.cos((frame / BLINK_FRAMES) * Math.PI * 2) > -0.2 ? 1 : 0.12;
|
||||
const cursorShown = 1 - progress(frame, T.turn, T.turn + 12) + progress(frame, T.fold + 24, T.fold + 44);
|
||||
const built = (block: number): number =>
|
||||
settle(progress(frame, T.resolve - 12 + block * 12, T.resolve + 22 + block * 12)) * untilFold(frame);
|
||||
const split = inOut(progress(frame, T.calm, T.calm + 50)) * (1 - inOut(progress(frame, T.fold, T.fold + 30)));
|
||||
const contentHeight = WINDOW.height - BAR_HEIGHT;
|
||||
|
||||
return (
|
||||
<g transform={camera.transform}>
|
||||
{DEVICES.map((device) => (
|
||||
<g key={device.x} opacity={split} transform={`translate(${mix(-120, device.x, split)} ${device.y})`}>
|
||||
<Screen width={device.width} height={device.height} hair={hair} radius={22} />
|
||||
<Page width={device.width} height={device.height} columns={device.columns} shown={() => 1} />
|
||||
</g>
|
||||
))}
|
||||
|
||||
<g transform={`translate(${mix(0, -170, split)} 0) scale(${mix(1, 0.72, split)})`}>
|
||||
<g opacity={others}>
|
||||
<Screen width={WINDOW.width} height={WINDOW.height} hair={hair} radius={30} />
|
||||
<path d={`M${-WINDOW.width / 2} ${-WINDOW.height / 2 + BAR_HEIGHT}h${WINDOW.width}`} stroke={DRAW.fg} strokeOpacity={0.4} strokeWidth={hair} />
|
||||
{[0, 1, 2].map((n) => (
|
||||
<circle key={n} cx={-WINDOW.width / 2 + 30 + n * 24} cy={-WINDOW.height / 2 + BAR_HEIGHT / 2} r={7} fill={DRAW.fg} opacity={0.3} />
|
||||
))}
|
||||
</g>
|
||||
<g transform={`translate(0 ${BAR_HEIGHT / 2})`}>
|
||||
<Page width={WINDOW.width} height={contentHeight} columns={3} shown={built} />
|
||||
</g>
|
||||
<rect
|
||||
x={CURSOR.x - CURSOR.width / 2}
|
||||
y={CURSOR.y - CURSOR.height / 2}
|
||||
width={CURSOR.width}
|
||||
height={CURSOR.height}
|
||||
rx={CURSOR.width / 2}
|
||||
fill={DRAW.mark}
|
||||
opacity={Math.min(1, cursorShown) * blink}
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
/** A website: from a page that has been empty for months to one that is built and fits every screen. */
|
||||
export const WebsiteFilm: FC = () => (
|
||||
<FilmStage captions={CAPTIONS}>
|
||||
<WebsiteDrawing />
|
||||
</FilmStage>
|
||||
);
|
||||
@@ -0,0 +1,75 @@
|
||||
import { inOut, progress } from "../illustrations/motion";
|
||||
import type { Point } from "../lib/hop";
|
||||
import type { Caption } from "./FilmStage";
|
||||
|
||||
/**
|
||||
* What the films share: one timeline and one camera move. Every film is a single shot:
|
||||
* close on something small, pull back to see what it really is, stop, resolve, rest,
|
||||
* and return to the opening picture so that it loops.
|
||||
*/
|
||||
export const SHOT_FRAMES = 690;
|
||||
|
||||
export const T = {
|
||||
/** The camera starts to pull back. */
|
||||
reveal: 75,
|
||||
/** The whole picture is in view. */
|
||||
wide: 195,
|
||||
/** Everything slows to a halt. */
|
||||
freeze: 310,
|
||||
/** The turn begins. */
|
||||
turn: 345,
|
||||
/** The problem resolves. */
|
||||
resolve: 365,
|
||||
/** The calm result. */
|
||||
calm: 440,
|
||||
cta: 560,
|
||||
/** The picture folds back into its opening. */
|
||||
fold: 630,
|
||||
} as const;
|
||||
|
||||
/** The six statements of a film, in order: hook, reveal, problem, turn, calm, call to action. */
|
||||
export const CAPTIONS: readonly Caption[] = [
|
||||
{ id: "hook", from: 0, to: T.reveal + 20 },
|
||||
{ id: "reveal", from: T.reveal + 30, to: T.wide + 30 },
|
||||
{ id: "problem", from: T.wide + 30, to: T.freeze + 20 },
|
||||
{ id: "turn", from: T.turn, to: T.calm + 20 },
|
||||
{ id: "calm", from: T.calm + 20, to: T.cta },
|
||||
{ id: "cta", from: T.cta, to: SHOT_FRAMES, button: true },
|
||||
];
|
||||
|
||||
/** 0 while the camera is close, 1 when it has pulled back. */
|
||||
export const pulledBack = (frame: number): number =>
|
||||
inOut(progress(frame, T.reveal, T.wide)) * (1 - inOut(progress(frame, T.fold, SHOT_FRAMES)));
|
||||
|
||||
/** 1 until the fold, then quickly 0: for everything that has to be gone before the loop closes. */
|
||||
export const untilFold = (frame: number): number => 1 - progress(frame, T.fold, T.fold + 24);
|
||||
|
||||
export type Camera = {
|
||||
readonly zoom: number;
|
||||
/** Opacity of everything outside the opening picture: it fades in as the camera pulls back. */
|
||||
readonly others: number;
|
||||
/** Width of a hairline that looks the same at every zoom. */
|
||||
readonly hair: number;
|
||||
/** SVG transform for the group that holds the drawing. */
|
||||
readonly transform: string;
|
||||
};
|
||||
|
||||
/** The camera at a frame: close on `focus` at `zoomIn`, then the whole stage at `zoomOut`. */
|
||||
export const cameraAt = (frame: number, zoomIn: number, zoomOut: number, focus: Point): Camera => {
|
||||
const back = pulledBack(frame);
|
||||
const zoom = zoomIn * (zoomOut / zoomIn) ** back;
|
||||
return {
|
||||
zoom,
|
||||
others: progress(back, 0.05, 0.5),
|
||||
hair: 2.2 / zoom,
|
||||
transform: `scale(${zoom}) translate(${-focus.x * (1 - back)} ${-focus.y * (1 - back)})`,
|
||||
};
|
||||
};
|
||||
|
||||
/** A soft light around a shape, the same size on screen at every zoom. */
|
||||
export const glow = (color: string, px: number, zoom: number): { filter: string } => ({
|
||||
filter: `drop-shadow(0 0 ${px / zoom}px ${color})`,
|
||||
});
|
||||
|
||||
/** The quiet look of every surface: a light tint and a fine light outline. */
|
||||
export const SURFACE = { fill: "rgba(248, 250, 252, 0.1)", strokeOpacity: 0.75 } as const;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Easing, interpolate } from "remotion";
|
||||
|
||||
/** Small motion helpers shared by all illustrations. All of them are pure functions of the frame. */
|
||||
|
||||
const CLAMP = { extrapolateLeft: "clamp", extrapolateRight: "clamp" } as const;
|
||||
|
||||
/** 0 before `from`, 1 after `to`, linear in between. */
|
||||
export const progress = (frame: number, from: number, to: number): number =>
|
||||
interpolate(frame, [from, to], [0, 1], CLAMP);
|
||||
|
||||
export const mix = (from: number, to: number, t: number): number => from + (to - from) * t;
|
||||
|
||||
export const inOut = Easing.inOut(Easing.cubic);
|
||||
export const out = Easing.out(Easing.cubic);
|
||||
/** Overshoots slightly, for things that pop in. */
|
||||
export const pop = Easing.out(Easing.back(2.2));
|
||||
|
||||
/** `pop` between two frames: 0 before, 1 after. */
|
||||
export const popAt = (frame: number, from: number, length = 8): number =>
|
||||
pop(progress(frame, from, from + length));
|
||||
@@ -0,0 +1,311 @@
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { FONT } from "../brand/fonts";
|
||||
import { useT } from "../content/CopyContext";
|
||||
import { COLOR, DRAW } from "../brand/tokens";
|
||||
|
||||
/** Shared drawing parts for illustrations. Everything is SVG, drawn in stage coordinates. */
|
||||
|
||||
export type IconName =
|
||||
| "cart"
|
||||
| "invoice"
|
||||
| "people"
|
||||
| "mail"
|
||||
| "check"
|
||||
| "book"
|
||||
| "form"
|
||||
| "chat"
|
||||
| "bell"
|
||||
| "tasks";
|
||||
|
||||
const ICON_PATHS: Readonly<Record<IconName, ReactNode>> = {
|
||||
cart: (
|
||||
<>
|
||||
<path d="M3 4h2l2.4 10.2a1 1 0 0 0 1 .8h8.9a1 1 0 0 0 1-.8L20 7H6.2" />
|
||||
<circle cx="9.5" cy="19" r="1.3" />
|
||||
<circle cx="17" cy="19" r="1.3" />
|
||||
</>
|
||||
),
|
||||
invoice: (
|
||||
<>
|
||||
<path d="M7 3h7l4 4v14H7z" />
|
||||
<path d="M14 3v4h4" />
|
||||
<path d="M10 12h5" />
|
||||
<path d="M10 16h5" />
|
||||
</>
|
||||
),
|
||||
people: (
|
||||
<>
|
||||
<circle cx="12" cy="8" r="3.5" />
|
||||
<path d="M5 20c0-3.6 3.1-6 7-6s7 2.4 7 6" />
|
||||
</>
|
||||
),
|
||||
mail: (
|
||||
<>
|
||||
<rect x="3" y="6" width="18" height="13" rx="2" />
|
||||
<path d="m3.5 7.5 8.5 6 8.5-6" />
|
||||
</>
|
||||
),
|
||||
check: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="m8 12.5 3 3 5-6" />
|
||||
</>
|
||||
),
|
||||
book: (
|
||||
<>
|
||||
<path d="M5 4h11a2 2 0 0 1 2 2v14H7a2 2 0 0 1-2-2z" />
|
||||
<path d="M5 18a2 2 0 0 1 2-2h11" />
|
||||
<path d="M9 8h6" />
|
||||
</>
|
||||
),
|
||||
form: (
|
||||
<>
|
||||
<rect x="4" y="4" width="16" height="16" rx="2" />
|
||||
<path d="M8 9h8" />
|
||||
<path d="M8 13h8" />
|
||||
<path d="M8 17h4" />
|
||||
</>
|
||||
),
|
||||
chat: <path d="M4 5h16v11H10l-4 4v-4H4z" />,
|
||||
bell: (
|
||||
<>
|
||||
<path d="M6 16v-5a6 6 0 0 1 12 0v5l1.5 2h-15z" />
|
||||
<path d="M10 20a2 2 0 0 0 4 0" />
|
||||
</>
|
||||
),
|
||||
tasks: (
|
||||
<>
|
||||
<rect x="4" y="4" width="16" height="16" rx="2" />
|
||||
<path d="m8 12 3 3 5-6" />
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
/** A line icon from a 24 x 24 grid, centred on (cx, cy). */
|
||||
export const Icon: FC<{ name: IconName; cx: number; cy: number; size: number; color: string }> = ({
|
||||
name,
|
||||
cx,
|
||||
cy,
|
||||
size,
|
||||
color,
|
||||
}) => (
|
||||
<g
|
||||
transform={`translate(${cx - size / 2} ${cy - size / 2}) scale(${size / 24})`}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={1.4}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{ICON_PATHS[name]}
|
||||
</g>
|
||||
);
|
||||
|
||||
export const TILE_SIZE = 150;
|
||||
|
||||
/** One tool: a dark rounded tile with an icon and a mono label below. */
|
||||
export const Tile: FC<{
|
||||
x: number;
|
||||
y: number;
|
||||
icon: IconName;
|
||||
label: string;
|
||||
/** Coloured outline, e.g. while someone works in this tool. */
|
||||
ring?: string;
|
||||
scale?: number;
|
||||
opacity?: number;
|
||||
}> = ({ x, y, icon, label, ring, scale = 1, opacity = 1 }) => {
|
||||
const centre = TILE_SIZE / 2;
|
||||
return (
|
||||
<g transform={`translate(${x} ${y})`} opacity={opacity}>
|
||||
<g transform={`translate(${centre} ${centre}) scale(${scale}) translate(${-centre} ${-centre})`}>
|
||||
{ring && (
|
||||
<rect
|
||||
x={-10}
|
||||
y={-10}
|
||||
width={TILE_SIZE + 20}
|
||||
height={TILE_SIZE + 20}
|
||||
rx={42}
|
||||
fill="none"
|
||||
stroke={ring}
|
||||
strokeWidth={4}
|
||||
style={{ filter: `drop-shadow(0 0 12px ${ring})` }}
|
||||
/>
|
||||
)}
|
||||
<rect width={TILE_SIZE} height={TILE_SIZE} rx={34} fill={DRAW.tint} stroke={DRAW.fg} strokeWidth={2.5} />
|
||||
<Icon name={icon} cx={centre} cy={centre} size={78} color={DRAW.fg} />
|
||||
</g>
|
||||
<text
|
||||
x={centre}
|
||||
y={TILE_SIZE + 44}
|
||||
textAnchor="middle"
|
||||
fontFamily={FONT.mono}
|
||||
fontWeight={500}
|
||||
fontSize={19}
|
||||
letterSpacing="0.16em"
|
||||
fill={DRAW.label}
|
||||
>
|
||||
{useT()(label).toUpperCase()}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
/** Round badge with a tick, centred on (cx, cy). */
|
||||
export const DoneBadge: FC<{ cx: number; cy: number; r: number; scale?: number }> = ({ cx, cy, r, scale = 1 }) => (
|
||||
<g transform={`translate(${cx} ${cy}) scale(${scale})`}>
|
||||
<circle r={r} fill={COLOR.done} style={{ filter: `drop-shadow(0 0 ${r * 0.5}px ${COLOR.done})` }} />
|
||||
<path
|
||||
d={`M${-r * 0.42} ${r * 0.04} ${-r * 0.1} ${r * 0.36} ${r * 0.44} ${-r * 0.3}`}
|
||||
fill="none"
|
||||
stroke={DRAW.bg}
|
||||
strokeWidth={r * 0.2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
|
||||
/** Round amber badge with one character: something needs attention. */
|
||||
export const WarnBadge: FC<{ cx: number; cy: number; r: number; scale?: number; char?: string }> = ({
|
||||
cx,
|
||||
cy,
|
||||
r,
|
||||
scale = 1,
|
||||
char = "!",
|
||||
}) => (
|
||||
<g transform={`translate(${cx} ${cy}) scale(${scale})`}>
|
||||
<circle r={r} fill={DRAW.mark} style={{ filter: `drop-shadow(0 0 ${r * 0.5}px ${DRAW.mark})` }} />
|
||||
<text y={r * 0.42} textAnchor="middle" fontFamily={FONT.sans} fontWeight={900} fontSize={r * 1.2} fill={DRAW.bg}>
|
||||
{char}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
|
||||
/** Mono caption inside a drawing. */
|
||||
export const Label: FC<{ x: number; y: number; text: string; anchor?: "start" | "middle" | "end"; color?: string; size?: number }> = ({
|
||||
x,
|
||||
y,
|
||||
text,
|
||||
anchor = "middle",
|
||||
color = DRAW.label,
|
||||
size = 19,
|
||||
}) => (
|
||||
<text
|
||||
x={x}
|
||||
y={y}
|
||||
textAnchor={anchor}
|
||||
fontFamily={FONT.mono}
|
||||
fontWeight={500}
|
||||
fontSize={size}
|
||||
letterSpacing="0.16em"
|
||||
fill={color}
|
||||
>
|
||||
{useT()(text).toUpperCase()}
|
||||
</text>
|
||||
);
|
||||
|
||||
/** Mouse pointer with its tip at (x, y): stands for a person doing the step by hand. */
|
||||
export const Cursor: FC<{ x: number; y: number; scale?: number }> = ({ x, y, scale = 1.5 }) => (
|
||||
<path
|
||||
transform={`translate(${x} ${y}) scale(${scale})`}
|
||||
d="M0 0 0 30 8 23 14 36 19 34 13 21 24 21Z"
|
||||
fill={DRAW.fg}
|
||||
stroke={DRAW.bg}
|
||||
strokeWidth={2.5}
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
);
|
||||
|
||||
/** A piece of data: dark pill with binary digits. */
|
||||
export const DataChip: FC<{ x: number; y: number; width?: number; digits?: string; color?: string }> = ({
|
||||
x,
|
||||
y,
|
||||
width = 150,
|
||||
digits = "0110",
|
||||
color = DRAW.fg,
|
||||
}) => (
|
||||
<g transform={`translate(${x} ${y})`}>
|
||||
<rect width={width} height={44} rx={22} fill={color} />
|
||||
<text
|
||||
x={width / 2}
|
||||
y={30}
|
||||
textAnchor="middle"
|
||||
fontFamily={FONT.mono}
|
||||
fontWeight={700}
|
||||
fontSize={22}
|
||||
letterSpacing="0.2em"
|
||||
fill={DRAW.bg}
|
||||
>
|
||||
{digits}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
|
||||
/** An app window: white card with a title bar. Children are drawn in its coordinates. */
|
||||
export const AppWindow: FC<{
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
title: string;
|
||||
children?: ReactNode;
|
||||
}> = ({ x, y, width, height, title, children }) => (
|
||||
<g transform={`translate(${x} ${y})`}>
|
||||
<rect width={width} height={height} rx={30} fill={DRAW.glass} stroke={DRAW.fg} strokeWidth={2.5} />
|
||||
<path d={`M0 64H${width}`} stroke={DRAW.rim} strokeWidth={2} />
|
||||
{[30, 56, 82].map((cx) => (
|
||||
<circle key={cx} cx={cx} cy={33} r={8} fill={DRAW.soft} />
|
||||
))}
|
||||
<text
|
||||
x={width - 28}
|
||||
y={41}
|
||||
textAnchor="end"
|
||||
fontFamily={FONT.mono}
|
||||
fontWeight={500}
|
||||
fontSize={19}
|
||||
letterSpacing="0.16em"
|
||||
fill={DRAW.label}
|
||||
>
|
||||
{useT()(title).toUpperCase()}
|
||||
</text>
|
||||
{children}
|
||||
</g>
|
||||
);
|
||||
|
||||
/** A box for one day of a week or a countdown, centred on cx. */
|
||||
export const DayBox: FC<{
|
||||
cx: number;
|
||||
y: number;
|
||||
width: number;
|
||||
label: string;
|
||||
filled: boolean;
|
||||
scale?: number;
|
||||
}> = ({ cx, y, width, label, filled, scale = 1 }) => (
|
||||
<g transform={`translate(${cx} ${y + 55}) scale(${scale})`}>
|
||||
<rect
|
||||
x={-width / 2}
|
||||
y={-55}
|
||||
width={width}
|
||||
height={110}
|
||||
rx={26}
|
||||
fill={filled ? DRAW.fg : DRAW.glass}
|
||||
stroke={filled ? DRAW.fg : DRAW.rim}
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
<Label x={0} y={8} text={label} color={filled ? DRAW.bg : DRAW.fg} size={24} />
|
||||
</g>
|
||||
);
|
||||
|
||||
/** A sheet of paper. Children are drawn in its coordinates. */
|
||||
export const Page: FC<{ x: number; y: number; width: number; height: number; children?: ReactNode }> = ({
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
children,
|
||||
}) => (
|
||||
<g transform={`translate(${x} ${y})`}>
|
||||
<rect width={width} height={height} rx={24} fill={DRAW.glass} stroke={DRAW.fg} strokeWidth={2.5} />
|
||||
{children}
|
||||
</g>
|
||||
);
|
||||
@@ -0,0 +1,259 @@
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { useCurrentFrame } from "remotion";
|
||||
import { COLOR, DRAW } from "../brand/tokens";
|
||||
import { STAGE_WIDTH } from "../slides/SlideDeck";
|
||||
import { inOut, mix, out, popAt, progress } from "./motion";
|
||||
import { AppWindow, Cursor, DataChip, DoneBadge, Icon, TILE_SIZE, Tile, type IconName } from "./parts";
|
||||
|
||||
/** Scenes that several videos share. Each is one slide's illustration, drawn from its frame 0. */
|
||||
|
||||
export const SCENE_HEIGHT = 520;
|
||||
export const CTA_SCENE_HEIGHT = 300;
|
||||
|
||||
export type Tool = { readonly icon: IconName; readonly label: string };
|
||||
/** Scenes with a row of tools expect exactly four of them. */
|
||||
export type Tools = readonly [Tool, Tool, Tool, Tool];
|
||||
|
||||
const TOOL_COUNT = 4;
|
||||
const TOOL_GAP = (STAGE_WIDTH - TOOL_COUNT * TILE_SIZE) / (TOOL_COUNT - 1);
|
||||
export const toolX = (index: number): number => index * (TILE_SIZE + TOOL_GAP);
|
||||
export const ROW_Y = 185;
|
||||
|
||||
export const Canvas: FC<{ height?: number; children: ReactNode }> = ({ height = SCENE_HEIGHT, children }) => (
|
||||
<svg width={STAGE_WIDTH} height={height} viewBox={`0 0 ${STAGE_WIDTH} ${height}`} style={{ overflow: "visible" }}>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CHIP_DIGITS = ["0110", "1001", "1101"] as const;
|
||||
|
||||
/** Values carried by hand from one window into another, again and again. */
|
||||
export const CarryScene: FC<{ fromTitle: string; toTitle: string }> = ({ fromTitle, toTitle }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const perChip = 25;
|
||||
const rowY = (row: number): number => 166 + row * 92;
|
||||
const current = Math.min(CHIP_DIGITS.length - 1, Math.floor(frame / perChip));
|
||||
const chipAt = (row: number): { x: number; y: number } => {
|
||||
const t = row < current ? 1 : row > current ? 0 : inOut(progress(frame, row * perChip + 4, row * perChip + 20));
|
||||
return { x: mix(34, 472 + 38, t), y: rowY(row) - Math.sin(Math.PI * t) * 46 };
|
||||
};
|
||||
const held = chipAt(current);
|
||||
return (
|
||||
<Canvas>
|
||||
<AppWindow x={0} y={60} width={400} height={400} title={fromTitle}>
|
||||
{CHIP_DIGITS.map((digits, row) => (
|
||||
<rect key={digits} x={200} y={rowY(row) - 46} width={160} height={16} rx={8} fill={DRAW.soft} />
|
||||
))}
|
||||
</AppWindow>
|
||||
<AppWindow x={472} y={60} width={400} height={400} title={toTitle}>
|
||||
{CHIP_DIGITS.map((digits, row) => (
|
||||
<rect
|
||||
key={digits}
|
||||
x={30}
|
||||
y={rowY(row) - 66}
|
||||
width={340}
|
||||
height={56}
|
||||
rx={28}
|
||||
fill="none"
|
||||
stroke={DRAW.rim}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="10 10"
|
||||
/>
|
||||
))}
|
||||
</AppWindow>
|
||||
{CHIP_DIGITS.map((digits, row) => {
|
||||
const chip = chipAt(row);
|
||||
return <DataChip key={digits} x={chip.x} y={chip.y} digits={digits} />;
|
||||
})}
|
||||
<Cursor x={held.x + 112} y={held.y + 26} />
|
||||
</Canvas>
|
||||
);
|
||||
};
|
||||
|
||||
/** Four separate tools, and a person hopping between them with the data. */
|
||||
export const HopScene: FC<{ tools: Tools }> = ({ tools }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const perTool = 15;
|
||||
const active = Math.min(TOOL_COUNT - 1, Math.floor(frame / perTool));
|
||||
const hop = active === 0 ? 1 : inOut(progress(frame, active * perTool, active * perTool + 9));
|
||||
const x = mix(toolX(Math.max(0, active - 1)), toolX(active), hop) + 78;
|
||||
const y = ROW_Y + TILE_SIZE + 70 - Math.sin(Math.PI * hop) * 40;
|
||||
return (
|
||||
<Canvas>
|
||||
{tools.map((tool, index) => (
|
||||
<Tile key={tool.label} x={toolX(index)} y={ROW_Y} {...tool} ring={index === active ? COLOR.active : undefined} />
|
||||
))}
|
||||
<DataChip x={x + 34} y={y + 26} width={110} />
|
||||
<Cursor x={x} y={y} />
|
||||
</Canvas>
|
||||
);
|
||||
};
|
||||
|
||||
const CARD = { width: 300, height: 66, step: 46, count: 9, x: 60, base: 440 } as const;
|
||||
|
||||
/** One item of work waiting to be handled. */
|
||||
export const WorkCard: FC<{
|
||||
x: number;
|
||||
y: number;
|
||||
icon: IconName;
|
||||
tilt?: number;
|
||||
opacity?: number;
|
||||
done?: boolean;
|
||||
width?: number;
|
||||
}> = ({ x, y, icon, tilt = 0, opacity = 1, done = false, width = CARD.width }) => (
|
||||
<g transform={`translate(${x} ${y}) rotate(${tilt} ${width / 2} ${CARD.height / 2})`} opacity={opacity}>
|
||||
<rect width={width} height={CARD.height} rx={20} fill={DRAW.glass} stroke={done ? COLOR.done : DRAW.fg} strokeWidth={2.5} />
|
||||
<Icon name={icon} cx={40} cy={CARD.height / 2} size={34} color={done ? COLOR.done : DRAW.fg} />
|
||||
<rect x={76} y={CARD.height / 2 - 8} width={width / 2} height={16} rx={8} fill={DRAW.soft} />
|
||||
</g>
|
||||
);
|
||||
|
||||
const cardTilt = (index: number): number => (index % 2 === 0 ? -2.5 : 2);
|
||||
const cardY = (index: number): number => CARD.base - index * CARD.step;
|
||||
|
||||
/** Work arrives faster than one person can handle it. */
|
||||
export const PileScene: FC<{ icon: IconName }> = ({ icon }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const every = 5;
|
||||
return (
|
||||
<Canvas>
|
||||
{Array.from({ length: CARD.count }, (_, index) => {
|
||||
const fall = out(progress(frame, index * every, index * every + 8));
|
||||
return fall === 0 ? null : (
|
||||
<WorkCard key={index} x={CARD.x} y={mix(-120, cardY(index), fall)} icon={icon} tilt={cardTilt(index)} opacity={fall} />
|
||||
);
|
||||
})}
|
||||
<Tile x={600} y={ROW_Y} icon="people" label="One person" ring={COLOR.active} />
|
||||
<Cursor x={690 + Math.cos(frame / 5) * 14} y={ROW_Y + TILE_SIZE / 2 + 40 + Math.sin(frame / 5) * 14} />
|
||||
</Canvas>
|
||||
);
|
||||
};
|
||||
|
||||
/** The pile from PileScene clears itself. */
|
||||
export const ClearedScene: FC<{ icon: IconName }> = ({ icon }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const every = 4;
|
||||
return (
|
||||
<Canvas>
|
||||
<DoneBadge cx={STAGE_WIDTH / 2} cy={250} r={120} scale={popAt(frame, 36, 14)} />
|
||||
{Array.from({ length: CARD.count }, (_, index) => {
|
||||
const order = CARD.count - 1 - index;
|
||||
const leave = inOut(progress(frame, 4 + order * every, 14 + order * every));
|
||||
return leave === 1 ? null : (
|
||||
<WorkCard
|
||||
key={index}
|
||||
x={CARD.x + leave * 620}
|
||||
y={cardY(index)}
|
||||
icon={icon}
|
||||
tilt={cardTilt(index)}
|
||||
opacity={1 - leave}
|
||||
done={leave > 0}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Canvas>
|
||||
);
|
||||
};
|
||||
|
||||
const PACKET_TRAVEL_FRAMES = 27;
|
||||
const PACKET_EVERY_FRAMES = 9;
|
||||
|
||||
/** Four tools in a row, connected, with work travelling through on its own. */
|
||||
export const FlowRow: FC<{
|
||||
tools: Tools;
|
||||
y: number;
|
||||
/** 0..1: how far the link after tool `index` is drawn. */
|
||||
connected: (index: number) => number;
|
||||
/** Frame at which work starts to flow, or null while the tools are only being connected. */
|
||||
flowFrom: number | null;
|
||||
}> = ({ tools, y, connected, flowFrom }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const centreY = y + TILE_SIZE / 2;
|
||||
const firstCentre = toolX(0) + TILE_SIZE / 2;
|
||||
const lastCentre = toolX(TOOL_COUNT - 1) + TILE_SIZE / 2;
|
||||
const flowing = flowFrom === null ? -1 : frame - flowFrom;
|
||||
const packets =
|
||||
flowing < 0 ? [] : Array.from({ length: Math.floor(flowing / PACKET_EVERY_FRAMES) + 1 }, (_, n) => n);
|
||||
const passFrame = (index: number): number => (PACKET_TRAVEL_FRAMES * index) / (TOOL_COUNT - 1);
|
||||
return (
|
||||
<>
|
||||
{tools.slice(1).map((tool, index) => {
|
||||
const from = toolX(index) + TILE_SIZE;
|
||||
const drawn = connected(index);
|
||||
const tip = mix(from, toolX(index + 1), drawn);
|
||||
return drawn === 0 ? null : (
|
||||
<g key={tool.label}>
|
||||
<path d={`M${from} ${centreY}H${tip}`} stroke={DRAW.fg} strokeWidth={2.5} />
|
||||
<circle cx={tip} cy={centreY} r={6} fill={DRAW.fg} />
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{packets.map((n) => {
|
||||
const t = (flowing - n * PACKET_EVERY_FRAMES) / PACKET_TRAVEL_FRAMES;
|
||||
return t > 1 ? null : (
|
||||
<circle key={n} cx={mix(firstCentre, lastCentre, t)} cy={centreY} r={11}
|
||||
fill={DRAW.mark}
|
||||
stroke={DRAW.mark}
|
||||
strokeOpacity={0.28}
|
||||
strokeWidth={16}
|
||||
style={{ filter: `drop-shadow(0 0 10px ${DRAW.mark})` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{tools.map((tool, index) => {
|
||||
const since = flowing - passFrame(index);
|
||||
const pulse = since < 0 ? 0 : 1 - (since % PACKET_EVERY_FRAMES) / PACKET_EVERY_FRAMES;
|
||||
return (
|
||||
<g key={tool.label}>
|
||||
<Tile x={toolX(index)} y={y} {...tool} scale={1 + pulse * 0.05} />
|
||||
{since >= 0 && (
|
||||
<DoneBadge cx={toolX(index) + TILE_SIZE - 8} cy={y + 8} r={26} scale={popAt(since, 0) * (1 + pulse * 0.12)} />
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/** The tools get connected, one link after the other. */
|
||||
export const ConnectScene: FC<{ tools: Tools }> = ({ tools }) => {
|
||||
const frame = useCurrentFrame();
|
||||
return (
|
||||
<Canvas>
|
||||
<FlowRow
|
||||
tools={tools}
|
||||
y={ROW_Y}
|
||||
connected={(index) => inOut(progress(frame, 8 + index * 12, 20 + index * 12))}
|
||||
flowFrom={null}
|
||||
/>
|
||||
</Canvas>
|
||||
);
|
||||
};
|
||||
|
||||
const RUNS_FLOW_FROM = 4;
|
||||
const FINISHED_SLOTS = 9;
|
||||
|
||||
/** Work runs through all four tools by itself; finished items collect below. */
|
||||
export const RunsScene: FC<{ tools: Tools }> = ({ tools }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const firstArrival = RUNS_FLOW_FROM + PACKET_TRAVEL_FRAMES;
|
||||
return (
|
||||
<Canvas>
|
||||
<FlowRow tools={tools} y={110} connected={() => 1} flowFrom={RUNS_FLOW_FROM} />
|
||||
{Array.from({ length: FINISHED_SLOTS }, (_, n) => {
|
||||
const arrival = firstArrival + n * PACKET_EVERY_FRAMES;
|
||||
return frame < arrival ? null : (
|
||||
<DoneBadge key={n} cx={36 + n * 100} cy={420} r={30} scale={popAt(frame, arrival)} />
|
||||
);
|
||||
})}
|
||||
</Canvas>
|
||||
);
|
||||
};
|
||||
|
||||
/** The connected tools, still running: sits above the call to action. */
|
||||
export const RunningScene: FC<{ tools: Tools }> = ({ tools }) => (
|
||||
<Canvas height={CTA_SCENE_HEIGHT}>
|
||||
<FlowRow tools={tools} y={64} connected={() => 1} flowFrom={-60} />
|
||||
</Canvas>
|
||||
);
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { FC } from "react";
|
||||
import { useVideoConfig } from "remotion";
|
||||
import { FONT } from "../brand/fonts";
|
||||
import { DRAW } from "../brand/tokens";
|
||||
import { CopyProvider } from "../content/CopyContext";
|
||||
import { IMAGES, type ImageCopy, type ImageId } from "../content/images";
|
||||
import type { Lang } from "../content/videos";
|
||||
import { Backdrop, FilmStage, Lines, SIDE, type Caption } from "../films/FilmStage";
|
||||
import { VIDEOS } from "../registry";
|
||||
import { headlineSize } from "../slides/SlideDeck";
|
||||
|
||||
const MAX_CARD_FONT = 132;
|
||||
/** The statement of a poster stands for good: an image has one frame. */
|
||||
const ALWAYS: readonly Caption[] = [{ id: "poster", from: 0, to: 1_000_000 }];
|
||||
|
||||
/**
|
||||
* One picture of a film, with the film's idea in two lines below it. Which moment is shown is
|
||||
* decided by the frame the composition is rendered at (see Root and the export script).
|
||||
*/
|
||||
const Poster: FC<{ video: string; copy: ImageCopy; lang: Lang }> = ({ video, copy, lang }) => {
|
||||
const entry = VIDEOS.find((candidate) => candidate.id === video);
|
||||
if (!entry || !("Drawing" in entry)) throw new Error(`No film with a drawing is registered as "${video}"`);
|
||||
return (
|
||||
<CopyProvider value={{ lang, copy: { title: copy.title, slides: { poster: copy.lines }, post: copy.post } }}>
|
||||
<FilmStage captions={ALWAYS}>
|
||||
<entry.Drawing />
|
||||
</FilmStage>
|
||||
</CopyProvider>
|
||||
);
|
||||
};
|
||||
|
||||
/** A question clients ask, small, and its answer in very large type. Nothing else. */
|
||||
const Card: FC<{ copy: ImageCopy }> = ({ copy }) => {
|
||||
const { width } = useVideoConfig();
|
||||
const fontSize = Math.min(MAX_CARD_FONT, headlineSize(copy.lines, width - SIDE * 2));
|
||||
return (
|
||||
<Backdrop>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: `0 ${SIDE}px 150px`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 22,
|
||||
backgroundColor: DRAW.mark,
|
||||
boxShadow: `0 0 28px 6px ${DRAW.mark}`,
|
||||
marginBottom: fontSize * 0.5,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: FONT.mono,
|
||||
fontWeight: 500,
|
||||
fontSize: Math.round(fontSize * 0.28),
|
||||
letterSpacing: "0.18em",
|
||||
textTransform: "uppercase",
|
||||
color: DRAW.mark,
|
||||
marginBottom: fontSize * 0.45,
|
||||
}}
|
||||
>
|
||||
{copy.label}
|
||||
</div>
|
||||
<Lines lines={copy.lines} fontSize={fontSize} />
|
||||
</div>
|
||||
</Backdrop>
|
||||
);
|
||||
};
|
||||
|
||||
/** One image post in one language: a poster of a film, or a card. */
|
||||
export const ImagePost: FC<{ id: ImageId; lang: Lang }> = ({ id, lang }) => {
|
||||
const image = IMAGES[id];
|
||||
const copy = image.copy[lang];
|
||||
return image.poster ? (
|
||||
<Poster video={image.poster.video} copy={copy} lang={lang} />
|
||||
) : (
|
||||
<Card copy={copy} />
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
import { registerRoot } from "remotion";
|
||||
import { Root } from "./Root";
|
||||
|
||||
registerRoot(Root);
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { outlinePoint, sparkAt, type Box } from "./aura";
|
||||
|
||||
const BOX: Box = { width: 400, height: 200 };
|
||||
const LOOP = 300;
|
||||
|
||||
describe("outlinePoint", () => {
|
||||
it("starts at the top left corner and walks clockwise", () => {
|
||||
expect(outlinePoint(0, BOX)).toEqual({ x: 0, y: 0, nx: 0, ny: -1 });
|
||||
// Perimeter is 1200: a quarter of it is 300 px along the top edge.
|
||||
expect(outlinePoint(0.25, BOX)).toEqual({ x: 300, y: 0, nx: 0, ny: -1 });
|
||||
});
|
||||
|
||||
it("gives the right edge an outward normal pointing right", () => {
|
||||
// 450 px: 400 along the top, 50 down the right edge.
|
||||
expect(outlinePoint(450 / 1200, BOX)).toEqual({ x: 400, y: expect.closeTo(50, 6), nx: 1, ny: 0 });
|
||||
});
|
||||
|
||||
it("walks the bottom edge from right to left and the left edge upwards", () => {
|
||||
expect(outlinePoint(700 / 1200, BOX)).toEqual({ x: expect.closeTo(300, 6), y: 200, nx: 0, ny: 1 });
|
||||
expect(outlinePoint(1100 / 1200, BOX)).toEqual({ x: 0, y: expect.closeTo(100, 6), nx: -1, ny: 0 });
|
||||
});
|
||||
|
||||
it("wraps positions outside 0..1", () => {
|
||||
expect(outlinePoint(1.25, BOX).x).toBeCloseTo(300, 6);
|
||||
expect(outlinePoint(-0.75, BOX).x).toBeCloseTo(300, 6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sparkAt", () => {
|
||||
it("is deterministic", () => {
|
||||
expect(sparkAt(7, 120, LOOP, BOX)).toEqual(sparkAt(7, 120, LOOP, BOX));
|
||||
});
|
||||
|
||||
it("is the same on the frame after the loop ends as on frame 0", () => {
|
||||
for (const index of [0, 1, 2, 17, 40]) {
|
||||
expect(sparkAt(index, LOOP, LOOP, BOX)).toEqual(sparkAt(index, 0, LOOP, BOX));
|
||||
}
|
||||
});
|
||||
|
||||
it("hides a spark outside its life and shows it inside", () => {
|
||||
const visibleFrames = Array.from({ length: LOOP }, (_, frame) => sparkAt(3, frame, LOOP, BOX)).filter(
|
||||
(spark) => spark !== null,
|
||||
);
|
||||
expect(visibleFrames.length).toBeGreaterThan(0);
|
||||
expect(visibleFrames.length).toBeLessThan(LOOP);
|
||||
});
|
||||
|
||||
it("fades in and out: opacity stays within 0..1 and digits are 0 or 1", () => {
|
||||
for (let frame = 0; frame < LOOP; frame++) {
|
||||
const spark = sparkAt(5, frame, LOOP, BOX);
|
||||
if (spark === null) continue;
|
||||
expect(spark.opacity).toBeGreaterThanOrEqual(0);
|
||||
expect(spark.opacity).toBeLessThanOrEqual(1);
|
||||
expect(["0", "1"]).toContain(spark.digit);
|
||||
}
|
||||
});
|
||||
|
||||
it("moves away from the outline as it ages", () => {
|
||||
const frames = Array.from({ length: LOOP }, (_, frame) => sparkAt(9, frame, LOOP, BOX));
|
||||
const first = frames.findIndex((spark) => spark !== null);
|
||||
const distance = (frame: number): number => {
|
||||
const spark = frames[frame];
|
||||
if (!spark) throw new Error("spark expected");
|
||||
const outsideX = Math.max(0, -spark.x, spark.x - BOX.width);
|
||||
const outsideY = Math.max(0, -spark.y, spark.y - BOX.height);
|
||||
return Math.hypot(outsideX, outsideY);
|
||||
};
|
||||
// Frame `first` can be mid-life if the spark was born before the loop wrapped; pick a run of two frames.
|
||||
const young = frames.findIndex((spark, frame) => spark !== null && frames[frame + 6] !== null && frame >= first);
|
||||
expect(distance(young + 6)).toBeGreaterThan(distance(young));
|
||||
});
|
||||
|
||||
it("gives different sparks different places", () => {
|
||||
const places = new Set(
|
||||
Array.from({ length: 60 }, (_, index) => sparkAt(index, 150, LOOP, BOX))
|
||||
.filter((spark) => spark !== null)
|
||||
.map((spark) => `${Math.round(spark.x)}:${Math.round(spark.y)}`),
|
||||
);
|
||||
expect(places.size).toBeGreaterThan(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* The website's BitAura (energy along the outline of an element, digits drifting off it), as pure
|
||||
* functions of the frame number. The website simulates it with random particles; here every spark's
|
||||
* whole life is computed from its index, so each frame can be rendered on its own and the motion
|
||||
* repeats exactly when a video loops.
|
||||
*/
|
||||
|
||||
export type Box = { readonly width: number; readonly height: number };
|
||||
|
||||
export type OutlinePoint = {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
/** Outward normal of the edge the point lies on. */
|
||||
readonly nx: number;
|
||||
readonly ny: number;
|
||||
};
|
||||
|
||||
export type Spark = {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly opacity: number;
|
||||
readonly digit: "0" | "1";
|
||||
readonly large: boolean;
|
||||
};
|
||||
|
||||
/** How often the light travels round the element during one loop of the video. */
|
||||
export const AURA_LAPS = 2;
|
||||
|
||||
const MIN_LIFE_FRAMES = 50;
|
||||
const EXTRA_LIFE_FRAMES = 60;
|
||||
const MIN_SPEED = 0.35;
|
||||
const EXTRA_SPEED = 0.75;
|
||||
const MAX_SLIDE = 0.3;
|
||||
const SCATTER = 0.14;
|
||||
const LARGE_SHARE = 0.22;
|
||||
const FLIP_EVERY_FRAMES = 33;
|
||||
|
||||
const fraction = (value: number): number => value - Math.floor(value);
|
||||
|
||||
/** Point on the outline of a box, walking clockwise from the top left corner. `at` wraps at 1. */
|
||||
export const outlinePoint = (at: number, { width, height }: Box): OutlinePoint => {
|
||||
const distance = fraction(at) * 2 * (width + height);
|
||||
if (distance < width) return { x: distance, y: 0, nx: 0, ny: -1 };
|
||||
if (distance < width + height) return { x: width, y: distance - width, nx: 1, ny: 0 };
|
||||
if (distance < 2 * width + height) return { x: 2 * width + height - distance, y: height, nx: 0, ny: 1 };
|
||||
return { x: 0, y: 2 * (width + height) - distance, nx: -1, ny: 0 };
|
||||
};
|
||||
|
||||
/** A stable pseudo-random number in 0..1 for a spark and one of its properties. */
|
||||
const random = (index: number, property: number): number => {
|
||||
const hash = Math.imul(index + 1, 2654435761) ^ Math.imul(property + 1, 2246822519);
|
||||
return ((Math.imul(hash ^ (hash >>> 15), 3266489917) >>> 0) % 100000) / 100000;
|
||||
};
|
||||
|
||||
/** Where the light is on the outline at a frame: it circles AURA_LAPS times per loop. */
|
||||
export const cometAt = (frame: number, loopFrames: number): number =>
|
||||
fraction((frame / loopFrames) * AURA_LAPS);
|
||||
|
||||
/**
|
||||
* Spark number `index` at `frame`, or null while it is not alive. It is born where the light is at
|
||||
* that moment, drifts away from the edge and fades. `loopFrames` is the length of the video.
|
||||
*/
|
||||
export const sparkAt = (index: number, frame: number, loopFrames: number, box: Box): Spark | null => {
|
||||
const birth = Math.floor(random(index, 0) * loopFrames);
|
||||
const life = Math.min(loopFrames, MIN_LIFE_FRAMES + random(index, 1) * EXTRA_LIFE_FRAMES);
|
||||
const age = (((frame - birth) % loopFrames) + loopFrames) % loopFrames;
|
||||
if (age >= life) return null;
|
||||
|
||||
const second = index % 2 === 1 ? 0.5 : 0;
|
||||
const origin = outlinePoint(cometAt(birth, loopFrames) + second + (random(index, 2) - 0.5) * SCATTER, box);
|
||||
const travelled = (MIN_SPEED + random(index, 3) * EXTRA_SPEED) * age;
|
||||
const slid = (random(index, 4) - 0.5) * 2 * MAX_SLIDE * age;
|
||||
const seed = random(index, 5);
|
||||
|
||||
return {
|
||||
x: origin.x + origin.nx * travelled - origin.ny * slid,
|
||||
y: origin.y + origin.ny * travelled + origin.nx * slid,
|
||||
opacity: Math.sin((Math.PI * age) / life),
|
||||
digit: Math.floor(age / FLIP_EVERY_FRAMES + seed * 10) % 2 === 0 ? "1" : "0",
|
||||
large: random(index, 6) < LARGE_SHARE,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { bitString, decodeText } from "./bits";
|
||||
|
||||
const isBinary = (s: string): boolean => /^[01]*$/.test(s);
|
||||
|
||||
describe("decodeText", () => {
|
||||
it("shows only digits for letters and numbers at progress 0, keeps punctuation and spaces", () => {
|
||||
const out = decodeText("Hi, 2 you!", 0, 0);
|
||||
expect(out).toHaveLength("Hi, 2 you!".length);
|
||||
expect(out.replace(/[^A-Za-z2-9]/g, "")).toBe("");
|
||||
expect(out[2]).toBe(",");
|
||||
expect(out[3]).toBe(" ");
|
||||
expect(out[9]).toBe("!");
|
||||
});
|
||||
|
||||
it("returns the original text at progress 1", () => {
|
||||
expect(decodeText("Concept to code", 1, 7)).toBe("Concept to code");
|
||||
});
|
||||
|
||||
it("resolves left to right: at 0.5 the first half is readable, the rest still digits", () => {
|
||||
const out = decodeText("ABCDEFGH", 0.5, 3);
|
||||
expect(out.slice(0, 4)).toBe("ABCD");
|
||||
expect(isBinary(out.slice(4))).toBe(true);
|
||||
});
|
||||
|
||||
it("clamps progress outside 0..1", () => {
|
||||
expect(decodeText("Abc", 2, 0)).toBe("Abc");
|
||||
expect(isBinary(decodeText("Abc", -1, 0))).toBe(true);
|
||||
});
|
||||
|
||||
it("is deterministic for the same tick and varies with the tick", () => {
|
||||
const text = "WEB APPS & WEBSITES";
|
||||
expect(decodeText(text, 0, 5)).toBe(decodeText(text, 0, 5));
|
||||
const ticks = new Set(
|
||||
Array.from({ length: 10 }, (_, tick) => decodeText(text, 0, tick)),
|
||||
);
|
||||
expect(ticks.size).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("handles the empty string", () => {
|
||||
expect(decodeText("", 0.5, 0)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bitString", () => {
|
||||
it("has the requested length and only 0 and 1", () => {
|
||||
const out = bitString(40, 3);
|
||||
expect(out).toHaveLength(40);
|
||||
expect(isBinary(out)).toBe(true);
|
||||
});
|
||||
|
||||
it("is deterministic and changes with the tick", () => {
|
||||
expect(bitString(40, 3)).toBe(bitString(40, 3));
|
||||
expect(bitString(40, 3)).not.toBe(bitString(40, 4));
|
||||
});
|
||||
|
||||
it("is empty for length 0", () => {
|
||||
expect(bitString(0, 1)).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Pure, frame-driven binary effects. Remotion renders frame by frame, so nothing here
|
||||
* uses timers or randomness: the same inputs always give the same output.
|
||||
*/
|
||||
|
||||
const HASH_MULTIPLIER = 2654435761;
|
||||
const TICK_MULTIPLIER = 40503;
|
||||
const LETTER_OR_DIGIT = /[A-Za-z0-9]/;
|
||||
|
||||
const clamp01 = (n: number): number => Math.min(1, Math.max(0, n));
|
||||
|
||||
/** A stable pseudo-random bit for a position at a given tick. */
|
||||
const bitAt = (index: number, tick: number): "0" | "1" => {
|
||||
const hash = Math.imul(index + 1, HASH_MULTIPLIER) ^ Math.imul(tick + 1, TICK_MULTIPLIER);
|
||||
return ((hash >>> 7) & 1) === 0 ? "0" : "1";
|
||||
};
|
||||
|
||||
/**
|
||||
* Text resolving from digits, left to right. Letters and numbers not yet reached show
|
||||
* as 0 or 1; spaces and punctuation stay put so the shape of the text is readable.
|
||||
*/
|
||||
export const decodeText = (text: string, progress: number, tick: number): string => {
|
||||
const chars = Array.from(text);
|
||||
const revealed = Math.floor(clamp01(progress) * chars.length);
|
||||
return chars
|
||||
.map((char, index) =>
|
||||
index < revealed || !LETTER_OR_DIGIT.test(char) ? char : bitAt(index, tick),
|
||||
)
|
||||
.join("");
|
||||
};
|
||||
|
||||
/** A string of `length` binary digits for a given tick. */
|
||||
export const bitString = (length: number, tick: number): string =>
|
||||
Array.from({ length }, (_, index) => bitAt(index, tick)).join("");
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { curtainEdge, curtainPolygon } from "./curtain";
|
||||
|
||||
const ROWS = Array.from({ length: 30 }, (_, row) => row);
|
||||
|
||||
describe("curtainEdge", () => {
|
||||
it("is fully closed at progress 0 and fully open at progress 1, in every row", () => {
|
||||
for (const row of ROWS) {
|
||||
expect(curtainEdge(row, 0)).toBe(0);
|
||||
expect(curtainEdge(row, 1)).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("never moves backwards as progress grows", () => {
|
||||
for (const row of ROWS) {
|
||||
let last = 0;
|
||||
for (let step = 0; step <= 20; step++) {
|
||||
const edge = curtainEdge(row, step / 20);
|
||||
expect(edge).toBeGreaterThanOrEqual(last);
|
||||
last = edge;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("is ragged: rows are at different places half way through", () => {
|
||||
const places = new Set(ROWS.map((row) => curtainEdge(row, 0.5).toFixed(3)));
|
||||
expect(places.size).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it("stays within 0..1 and clamps progress outside 0..1", () => {
|
||||
expect(curtainEdge(3, -1)).toBe(0);
|
||||
expect(curtainEdge(3, 2)).toBe(1);
|
||||
});
|
||||
|
||||
it("is deterministic per row", () => {
|
||||
expect(curtainEdge(7, 0.4)).toBe(curtainEdge(7, 0.4));
|
||||
});
|
||||
});
|
||||
|
||||
describe("curtainPolygon", () => {
|
||||
const box = { width: 200, height: 40, cell: 20 };
|
||||
|
||||
it("covers nothing at progress 0 and the whole box at progress 1 (behind the edge)", () => {
|
||||
expect(curtainPolygon("behind", 0, box)).toBe("polygon(0px 0px, 0px 0px, 0px 20px, 0px 20px, 0px 40px, 0px 40px)");
|
||||
expect(curtainPolygon("behind", 1, box)).toBe(
|
||||
"polygon(0px 0px, 200px 0px, 200px 20px, 200px 20px, 200px 40px, 0px 40px)",
|
||||
);
|
||||
});
|
||||
|
||||
it("is the complement ahead of the edge", () => {
|
||||
expect(curtainPolygon("ahead", 1, box)).toBe(
|
||||
"polygon(200px 0px, 200px 0px, 200px 20px, 200px 20px, 200px 40px, 200px 40px)",
|
||||
);
|
||||
expect(curtainPolygon("ahead", 0, box)).toBe(
|
||||
"polygon(200px 0px, 0px 0px, 0px 20px, 0px 20px, 0px 40px, 200px 40px)",
|
||||
);
|
||||
});
|
||||
|
||||
it("adds a row for a height that is not a multiple of the cell", () => {
|
||||
const points = curtainPolygon("behind", 1, { width: 100, height: 50, cell: 20 }).split(",");
|
||||
// Two corner points plus two per row, for three rows.
|
||||
expect(points).toHaveLength(8);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* The website's BitCurtain (the language switch transition): a front of binary digits with a ragged
|
||||
* edge that sweeps from left to right. Here it changes slides. Pure functions of the progress.
|
||||
*/
|
||||
|
||||
/** How ragged the edge is, as a share of the width. */
|
||||
const SPREAD = 0.24;
|
||||
|
||||
const clamp01 = (n: number): number => Math.min(1, Math.max(0, n));
|
||||
|
||||
/** A fixed pseudo-random number in 0..1 per row, so the edge keeps its shape while it moves. */
|
||||
const rowLag = (row: number): number => {
|
||||
const x = Math.sin((row + 1) * 12.9898) * 43758.5453;
|
||||
return x - Math.floor(x);
|
||||
};
|
||||
|
||||
/** Where the edge is in a row, as a share of the width: 0 before the sweep, 1 after it. */
|
||||
export const curtainEdge = (row: number, progress: number): number =>
|
||||
clamp01(clamp01(progress) * (1 + SPREAD) - rowLag(row) * SPREAD);
|
||||
|
||||
export type CurtainBox = { readonly width: number; readonly height: number; readonly cell: number };
|
||||
|
||||
/**
|
||||
* CSS `clip-path` for one side of the edge: `behind` is what the front has already passed
|
||||
* (left of it), `ahead` is what it has not reached yet.
|
||||
*/
|
||||
export const curtainPolygon = (side: "behind" | "ahead", progress: number, box: CurtainBox): string => {
|
||||
const outer = side === "behind" ? 0 : box.width;
|
||||
const rows = Math.ceil(box.height / box.cell);
|
||||
const steps = Array.from({ length: rows }, (_, row) => {
|
||||
const x = curtainEdge(row, progress) * box.width;
|
||||
return [`${x}px ${row * box.cell}px`, `${x}px ${Math.min(box.height, (row + 1) * box.cell)}px`];
|
||||
}).flat();
|
||||
return `polygon(${[`${outer}px 0px`, ...steps, `${outer}px ${box.height}px`].join(", ")})`;
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hopAt } from "./hop";
|
||||
|
||||
const A = { x: 100, y: 300 };
|
||||
const B = { x: 300, y: 300 };
|
||||
const C = { x: 700, y: 300 };
|
||||
const FRAMES = 10;
|
||||
const ARC = 0.5;
|
||||
|
||||
describe("hopAt", () => {
|
||||
it("rests on the first stop at time 0 and on the next one a hop later", () => {
|
||||
expect(hopAt(0, [A, B, C], FRAMES, ARC)).toEqual(A);
|
||||
expect(hopAt(10, [A, B, C], FRAMES, ARC)).toEqual(B);
|
||||
expect(hopAt(20, [A, B, C], FRAMES, ARC)).toEqual(C);
|
||||
});
|
||||
|
||||
it("is half way across and highest in the middle of a hop", () => {
|
||||
// A to B is 200 px: the arc rises by half of that.
|
||||
const mid = hopAt(5, [A, B, C], FRAMES, ARC);
|
||||
expect(mid.x).toBeCloseTo(200, 6);
|
||||
expect(mid.y).toBeCloseTo(200, 6);
|
||||
});
|
||||
|
||||
it("jumps higher on a longer hop", () => {
|
||||
// B to C is 400 px.
|
||||
expect(hopAt(15, [A, B, C], FRAMES, ARC).y).toBeCloseTo(100, 6);
|
||||
});
|
||||
|
||||
it("returns from the last stop to the first and starts again", () => {
|
||||
expect(hopAt(30, [A, B, C], FRAMES, ARC)).toEqual(A);
|
||||
expect(hopAt(40, [A, B, C], FRAMES, ARC)).toEqual(B);
|
||||
expect(hopAt(25, [A, B, C], FRAMES, ARC).x).toBeCloseTo(400, 6);
|
||||
});
|
||||
|
||||
it("works backwards in time, so a loop can end where it starts", () => {
|
||||
expect(hopAt(-10, [A, B, C], FRAMES, ARC)).toEqual(C);
|
||||
expect(hopAt(-30, [A, B, C], FRAMES, ARC)).toEqual(A);
|
||||
});
|
||||
|
||||
it("eases: slower near a stop than in the middle of a hop", () => {
|
||||
const nearStop = hopAt(1, [A, B], FRAMES, ARC).x - A.x;
|
||||
const inFlight = hopAt(5, [A, B], FRAMES, ARC).x - hopAt(4, [A, B], FRAMES, ARC).x;
|
||||
expect(nearStop).toBeLessThan(inFlight);
|
||||
});
|
||||
|
||||
it("stays put with a single stop", () => {
|
||||
expect(hopAt(7, [A], FRAMES, ARC)).toEqual(A);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
/** Something that hops from stop to stop in an arc, round and round. A pure function of time. */
|
||||
|
||||
export type Point = { readonly x: number; readonly y: number };
|
||||
|
||||
const easeInOut = (t: number): number => (t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2);
|
||||
const wrap = (index: number, length: number): number => ((index % length) + length) % length;
|
||||
|
||||
/**
|
||||
* Where the hopper is at `time`. It rests on `stops[0]` at time 0, reaches the next stop every
|
||||
* `framesPerHop`, and returns from the last stop to the first. The arc of a hop rises by
|
||||
* `arcRatio` times the distance covered. Negative times are fine.
|
||||
*/
|
||||
export const hopAt = (time: number, stops: readonly Point[], framesPerHop: number, arcRatio: number): Point => {
|
||||
const hop = Math.floor(time / framesPerHop);
|
||||
const from = stops[wrap(hop, stops.length)];
|
||||
const to = stops[wrap(hop + 1, stops.length)];
|
||||
if (!from || !to) throw new Error("hopAt needs at least one stop");
|
||||
const t = easeInOut(time / framesPerHop - hop);
|
||||
const distance = Math.hypot(to.x - from.x, to.y - from.y);
|
||||
return {
|
||||
x: from.x + (to.x - from.x) * t,
|
||||
y: from.y + (to.y - from.y) * t - Math.sin(Math.PI * t) * distance * arcRatio,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { appRescue } from "./compositions/AppRescue";
|
||||
import { automationDocuments } from "./compositions/AutomationDocuments";
|
||||
import { automationEnquiry } from "./compositions/AutomationEnquiry";
|
||||
import { automationOrder } from "./compositions/AutomationOrder";
|
||||
import { productDeveloper } from "./compositions/ProductDeveloper";
|
||||
import { websiteDone } from "./compositions/WebsiteDone";
|
||||
import type { VideoEntry } from "./slides/ServiceVideo";
|
||||
|
||||
/** Register new videos here. Each one is offered as `<id>-<language>-<format>`. */
|
||||
export const VIDEOS: readonly VideoEntry[] = [
|
||||
automationOrder,
|
||||
automationDocuments,
|
||||
automationEnquiry,
|
||||
websiteDone,
|
||||
appRescue,
|
||||
productDeveloper,
|
||||
];
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { FC } from "react";
|
||||
import { BrandFrame } from "../brand/BrandFrame";
|
||||
import { CopyProvider } from "../content/CopyContext";
|
||||
import { COPY, type Lang, type VideoId } from "../content/videos";
|
||||
import { SlideDeck, deckDuration, type Slide } from "./SlideDeck";
|
||||
|
||||
/**
|
||||
* A video about one service, and the id under which its texts are kept. It is either a deck of
|
||||
* slides or a film: one continuous shot that brings its own stage.
|
||||
*/
|
||||
export type VideoEntry =
|
||||
| { readonly id: VideoId; readonly slides: readonly Slide[] }
|
||||
| {
|
||||
readonly id: VideoId;
|
||||
readonly Film: FC;
|
||||
/** The picture of the film alone, in stage coordinates. */
|
||||
readonly Drawing: FC;
|
||||
readonly durationInFrames: number;
|
||||
};
|
||||
|
||||
export const videoDuration = (video: VideoEntry): number =>
|
||||
"Film" in video ? video.durationInFrames : deckDuration(video.slides);
|
||||
|
||||
/** One service video in one language: brand frame, looping slide deck, texts from the copy file. */
|
||||
export const ServiceVideo: FC<{ video: VideoEntry; lang: Lang }> = ({ video, lang }) => {
|
||||
const copy = COPY[video.id][lang];
|
||||
return (
|
||||
<CopyProvider value={{ lang, copy }}>
|
||||
{"Film" in video ? (
|
||||
<video.Film />
|
||||
) : (
|
||||
<BrandFrame>
|
||||
<SlideDeck slides={video.slides} />
|
||||
</BrandFrame>
|
||||
)}
|
||||
</CopyProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,306 @@
|
||||
import type { CSSProperties, FC, ReactNode } from "react";
|
||||
import { Easing, Freeze, Sequence, interpolate, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import { BitWipe } from "../bits/BitWipe";
|
||||
import { FRAME } from "../brand/BrandFrame";
|
||||
import { FONT } from "../brand/fonts";
|
||||
import { Panel } from "../brand/Panel";
|
||||
import { COLOR, DRAW } from "../brand/tokens";
|
||||
import { useCopy } from "../content/CopyContext";
|
||||
import { bitString } from "../lib/bits";
|
||||
import { curtainEdge, curtainPolygon } from "../lib/curtain";
|
||||
|
||||
/** Illustrations are drawn in a box this wide; it is scaled down to fit into the panel. */
|
||||
export const STAGE_WIDTH = 872;
|
||||
const DEFAULT_ILLUSTRATION_HEIGHT = 520;
|
||||
const ILLUSTRATION_SCALE = 0.9;
|
||||
const PANEL_HEIGHT = 600;
|
||||
/** Height the panel shrinks to on a slide with extra content below the headline (the button). */
|
||||
const PANEL_HEIGHT_WITH_AFTER = 454;
|
||||
const GAP = 60;
|
||||
const TEXT_HEIGHT = 200;
|
||||
const TALL_STAGE_HEIGHT = PANEL_HEIGHT + GAP + TEXT_HEIGHT;
|
||||
const WIDE_TEXT_WIDTH = 620;
|
||||
const WIDE_GAP = 110;
|
||||
const WIDE_STAGE = { width: STAGE_WIDTH + WIDE_GAP + WIDE_TEXT_WIDTH, height: PANEL_HEIGHT };
|
||||
|
||||
const ENTER_FRAMES = 10;
|
||||
const EXIT_FRAMES = 8;
|
||||
/** Length of the digit curtain that changes the picture. */
|
||||
const WIPE_FRAMES = 16;
|
||||
const CURTAIN_CELL = 24;
|
||||
const CURTAIN_TRAIL = 9;
|
||||
const CURTAIN_LEAD = 2;
|
||||
const MAX_HEADLINE_SIZE = 100;
|
||||
/** Average width of a character of the headline font, as a share of the font size. */
|
||||
const CHAR_WIDTH = 0.53;
|
||||
|
||||
export type Slide = {
|
||||
/** Also the key of the slide's headline in the video's copy. */
|
||||
readonly id: string;
|
||||
readonly durationInFrames: number;
|
||||
/** Draws into a box of STAGE_WIDTH x illustrationHeight. Its frame 0 is the slide's first frame. */
|
||||
readonly Illustration: FC;
|
||||
readonly illustrationHeight?: number;
|
||||
/** Extra content below the headline, e.g. the button on the last slide. */
|
||||
readonly after?: ReactNode;
|
||||
};
|
||||
|
||||
type Box = { readonly width: number; readonly height: number };
|
||||
|
||||
const clampOpts = { extrapolateLeft: "clamp", extrapolateRight: "clamp" } as const;
|
||||
const easeOut = Easing.out(Easing.cubic);
|
||||
const easeInOut = Easing.inOut(Easing.cubic);
|
||||
|
||||
/** How far the curtain is, `since` frames into a wipe. Complete on the wipe's last frame. */
|
||||
const wipeProgress = (since: number): number =>
|
||||
easeInOut(interpolate(since, [-1, WIPE_FRAMES - 1], [0, 1], clampOpts));
|
||||
|
||||
/** Largest headline size at which the longest line still fits the given width. */
|
||||
export const headlineSize = (lines: readonly string[], width: number): number => {
|
||||
const longest = Math.max(1, ...lines.map((line) => line.length));
|
||||
return Math.min(MAX_HEADLINE_SIZE, Math.floor(width / (longest * CHAR_WIDTH)));
|
||||
};
|
||||
|
||||
/**
|
||||
* The drawing of one slide, centred in the panel. The curtain reveals it from the left at its
|
||||
* start (`wipeIn`) and takes it away from the left from frame `wipeOutAt` on.
|
||||
*/
|
||||
const Picture: FC<{ slide: Slide; box: Box; wipeIn: boolean; wipeOutAt: number | null }> = ({
|
||||
slide,
|
||||
box,
|
||||
wipeIn,
|
||||
wipeOutAt,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { Illustration } = slide;
|
||||
const curtain = { ...box, cell: CURTAIN_CELL };
|
||||
const clipPath =
|
||||
wipeOutAt !== null && frame >= wipeOutAt
|
||||
? curtainPolygon("ahead", wipeProgress(frame - wipeOutAt), curtain)
|
||||
: wipeIn && frame < WIPE_FRAMES
|
||||
? curtainPolygon("behind", wipeProgress(frame), curtain)
|
||||
: undefined;
|
||||
return (
|
||||
<div style={{ position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center", clipPath }}>
|
||||
<div
|
||||
style={{
|
||||
width: STAGE_WIDTH,
|
||||
height: slide.illustrationHeight ?? DEFAULT_ILLUSTRATION_HEIGHT,
|
||||
flexShrink: 0,
|
||||
transform: `scale(${ILLUSTRATION_SCALE})`,
|
||||
}}
|
||||
>
|
||||
<Illustration />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** The moving edge of the curtain: bright digits at the front, a fading trail behind it. */
|
||||
const CurtainFront: FC<{ box: Box }> = ({ box }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const progress = wipeProgress(frame);
|
||||
const rows = Math.ceil(box.height / CURTAIN_CELL);
|
||||
return (
|
||||
<svg width={box.width} height={box.height} style={{ position: "absolute", inset: 0 }}>
|
||||
{Array.from({ length: rows }, (_, row) => {
|
||||
const x = curtainEdge(row, progress) * box.width;
|
||||
if (x <= 0 || x >= box.width) return null;
|
||||
const digits = bitString(CURTAIN_TRAIL + CURTAIN_LEAD, frame * 7 + row * 13);
|
||||
return Array.from(digits, (digit, index) => {
|
||||
// k counts cells from the edge back into what is already revealed; negative is ahead of it.
|
||||
const k = index - CURTAIN_LEAD;
|
||||
return (
|
||||
<text
|
||||
key={`${row}-${index}`}
|
||||
x={x - (k + 0.5) * CURTAIN_CELL}
|
||||
y={row * CURTAIN_CELL + CURTAIN_CELL / 2}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontFamily={FONT.mono}
|
||||
fontWeight={700}
|
||||
fontSize={18}
|
||||
fill={k < 0 ? DRAW.label : k < 2 ? DRAW.mark : DRAW.fg}
|
||||
opacity={k < 0 ? 0.5 : (1 - k / CURTAIN_TRAIL) * 0.9}
|
||||
>
|
||||
{digit}
|
||||
</text>
|
||||
);
|
||||
});
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
/** All pictures in turn, changed by the curtain, and the first one again at the very end. */
|
||||
const Pictures: FC<{ slides: readonly Slide[]; starts: readonly number[]; total: number; box: Box }> = ({
|
||||
slides,
|
||||
starts,
|
||||
total,
|
||||
box,
|
||||
}) => {
|
||||
const first = slides[0];
|
||||
const last = slides.length - 1;
|
||||
return (
|
||||
<>
|
||||
{slides.map((slide, index) => {
|
||||
// Every picture keeps playing while the next one is wiped in; the last one is wiped out within its time.
|
||||
const wipeOutAt = index === last ? slide.durationInFrames - WIPE_FRAMES : slide.durationInFrames;
|
||||
return (
|
||||
<Sequence
|
||||
key={slide.id}
|
||||
from={starts[index]}
|
||||
durationInFrames={index === last ? slide.durationInFrames : slide.durationInFrames + WIPE_FRAMES}
|
||||
layout="none"
|
||||
>
|
||||
<Picture slide={slide} box={box} wipeIn={index > 0} wipeOutAt={wipeOutAt} />
|
||||
</Sequence>
|
||||
);
|
||||
})}
|
||||
{first && (
|
||||
<Sequence from={total - WIPE_FRAMES} durationInFrames={WIPE_FRAMES} layout="none">
|
||||
<LoopPicture slide={first} box={box} />
|
||||
</Sequence>
|
||||
)}
|
||||
{[...starts.slice(1), total - WIPE_FRAMES].map((from) => (
|
||||
<Sequence key={from} from={from} durationInFrames={WIPE_FRAMES} layout="none">
|
||||
<CurtainFront box={box} />
|
||||
</Sequence>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/** The first picture as it looks on frame 0, wiped back in at the end of the video. */
|
||||
const LoopPicture: FC<{ slide: Slide; box: Box }> = ({ slide, box }) => {
|
||||
const frame = useCurrentFrame();
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
clipPath: curtainPolygon("behind", wipeProgress(frame), { ...box, cell: CURTAIN_CELL }),
|
||||
}}
|
||||
>
|
||||
<Freeze frame={0}>
|
||||
<Picture slide={slide} box={box} wipeIn={false} wipeOutAt={null} />
|
||||
</Freeze>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** The headline of one slide, and what follows it. `settled` shows it complete from its first frame. */
|
||||
const Words: FC<{ slide: Slide; settled: boolean; width: number }> = ({ slide, settled, width }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const enter = settled ? 1 : interpolate(frame, [0, ENTER_FRAMES], [0, 1], clampOpts);
|
||||
const exit = interpolate(
|
||||
frame,
|
||||
[slide.durationInFrames - EXIT_FRAMES, slide.durationInFrames - 1],
|
||||
[1, 0],
|
||||
clampOpts,
|
||||
);
|
||||
const lines = useCopy().slides[slide.id] ?? [];
|
||||
const style: CSSProperties = {
|
||||
fontFamily: FONT.sans,
|
||||
fontWeight: 900,
|
||||
fontSize: headlineSize(lines, width),
|
||||
lineHeight: 0.98,
|
||||
letterSpacing: "-0.04em",
|
||||
wordSpacing: "0.08em",
|
||||
};
|
||||
return (
|
||||
<div style={{ position: "absolute", inset: 0, opacity: Math.min(enter, exit) }}>
|
||||
{lines.map((line, index) => (
|
||||
<div key={line} style={{ ...style, color: index > 0 && index === lines.length - 1 ? COLOR.faint : COLOR.ink }}>
|
||||
<BitWipe from={settled ? -1000 : 3 + index * 4} durationInFrames={16} digitSize={18}>
|
||||
{line}
|
||||
</BitWipe>
|
||||
</div>
|
||||
))}
|
||||
{slide.after}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** Fades its children in over the exit of the last slide, so the last frame equals the first. */
|
||||
const LoopBack: FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const opacity = interpolate(frame, [0, EXIT_FRAMES - 1], [0, 1], clampOpts);
|
||||
return <div style={{ position: "absolute", inset: 0, opacity }}>{children}</div>;
|
||||
};
|
||||
|
||||
/**
|
||||
* A looping sequence of slides: big picture, few words. The pictures share one dark panel and are
|
||||
* changed by a curtain of digits; the headlines sit below the panel (portrait and square) or beside
|
||||
* it (16:9). The first slide is complete on frame 0, which is the preview image, and returns at the end.
|
||||
*/
|
||||
export const SlideDeck: FC<{ slides: readonly Slide[] }> = ({ slides }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { width, height } = useVideoConfig();
|
||||
const wide = width > height;
|
||||
const stage = wide ? WIDE_STAGE : { width: STAGE_WIDTH, height: TALL_STAGE_HEIGHT };
|
||||
// As large as the frame allows: the drawing is what matters.
|
||||
const scale = Math.min(
|
||||
(height - FRAME.top - FRAME.bottom) / stage.height,
|
||||
(width - FRAME.side * 2) / stage.width,
|
||||
);
|
||||
|
||||
const starts = slides.map((_, index) =>
|
||||
slides.slice(0, index).reduce((sum, slide) => sum + slide.durationInFrames, 0),
|
||||
);
|
||||
const total = deckDuration(slides);
|
||||
const first = slides[0];
|
||||
|
||||
// In portrait the panel makes room for a slide that has more below its headline.
|
||||
const shrink = wide
|
||||
? 0
|
||||
: Math.max(
|
||||
0,
|
||||
...slides.map((slide, index) => {
|
||||
if (!slide.after) return 0;
|
||||
const start = starts[index] ?? 0;
|
||||
const end = start + slide.durationInFrames;
|
||||
return Math.min(
|
||||
interpolate(frame, [start, start + ENTER_FRAMES], [0, 1], clampOpts),
|
||||
interpolate(frame, [end - WIPE_FRAMES, end - 1], [1, 0], clampOpts),
|
||||
);
|
||||
}),
|
||||
);
|
||||
const panel = { width: STAGE_WIDTH, height: PANEL_HEIGHT - (PANEL_HEIGHT - PANEL_HEIGHT_WITH_AFTER) * easeOut(shrink) };
|
||||
const textWidth = wide ? WIDE_TEXT_WIDTH : STAGE_WIDTH;
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative", ...stage, flexShrink: 0, transform: `scale(${scale})` }}>
|
||||
<div style={{ position: "absolute", top: 0, left: 0 }}>
|
||||
<Panel {...panel}>
|
||||
<Pictures slides={slides} starts={starts} total={total} box={panel} />
|
||||
</Panel>
|
||||
</div>
|
||||
<div
|
||||
style={
|
||||
wide
|
||||
? { position: "absolute", top: 200, left: STAGE_WIDTH + WIDE_GAP, width: WIDE_TEXT_WIDTH, height: TEXT_HEIGHT }
|
||||
: { position: "absolute", top: panel.height + GAP, left: 0, width: STAGE_WIDTH, height: TEXT_HEIGHT }
|
||||
}
|
||||
>
|
||||
{slides.map((slide, index) => (
|
||||
<Sequence key={slide.id} from={starts[index]} durationInFrames={slide.durationInFrames} layout="none">
|
||||
<Words slide={slide} settled={index === 0} width={textWidth} />
|
||||
</Sequence>
|
||||
))}
|
||||
{first && (
|
||||
<Sequence from={total - EXIT_FRAMES} durationInFrames={EXIT_FRAMES} layout="none">
|
||||
<LoopBack>
|
||||
<Freeze frame={0}>
|
||||
<Words slide={first} settled width={textWidth} />
|
||||
</Freeze>
|
||||
</LoopBack>
|
||||
</Sequence>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const deckDuration = (slides: readonly Slide[]): number =>
|
||||
slides.reduce((sum, slide) => sum + slide.durationInFrames, 0);
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { FC } from "react";
|
||||
import { BrandButton } from "../brand/BrandButton";
|
||||
import { CTA_SCENE_HEIGHT } from "../illustrations/scenes";
|
||||
import type { Slide } from "./SlideDeck";
|
||||
|
||||
const CTA_FRAMES = 75;
|
||||
|
||||
/** The closing slide of every video: a smaller picture, the last statement and the website's button. */
|
||||
export const ctaSlide = (Illustration: FC): Slide => ({
|
||||
id: "cta",
|
||||
durationInFrames: CTA_FRAMES,
|
||||
Illustration,
|
||||
illustrationHeight: CTA_SCENE_HEIGHT,
|
||||
after: (
|
||||
<div style={{ marginTop: 48 }}>
|
||||
<BrandButton label="Get in touch" size={28} />
|
||||
</div>
|
||||
),
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": [
|
||||
"node"
|
||||
],
|
||||
"allowImportingTsExtensions": true
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
"scripts",
|
||||
"remotion.config.ts"
|
||||
]
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
"use server";
|
||||
import { handleServerFunctions as payloadHandleServerFunctions } from "@payloadcms/next/layouts";
|
||||
import config from "@payload-config";
|
||||
// @ts-ignore - Payload generates this file during the build process
|
||||
import { importMap } from "./admin/importMap";
|
||||
|
||||
export const handleServerFunctions = async (args: any) => {
|
||||
return payloadHandleServerFunctions({
|
||||
...args,
|
||||
config,
|
||||
importMap,
|
||||
});
|
||||
};
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import configPromise from "@payload-config";
|
||||
import { RootPage, generatePageMetadata } from "@payloadcms/next/views";
|
||||
// @ts-ignore - Payload generates this file during the build process
|
||||
import { importMap } from "../importMap";
|
||||
|
||||
type Args = {
|
||||
params: Promise<{
|
||||
segments: string[];
|
||||
}>;
|
||||
searchParams: Promise<{
|
||||
[key: string]: string | string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
export const generateMetadata = async ({
|
||||
params,
|
||||
searchParams,
|
||||
}: Args): Promise<Metadata> =>
|
||||
generatePageMetadata({ config: configPromise, params, searchParams });
|
||||
|
||||
const Page = async ({ params, searchParams }: Args) =>
|
||||
RootPage({ config: configPromise, importMap, params, searchParams });
|
||||
|
||||
export default Page;
|
||||
@@ -1,120 +0,0 @@
|
||||
import { AiMediaButtons as AiMediaButtons_1d402a78164f07306f77dce953e62e11 } from "@mintel/payload-ai/components/AiMediaButtons";
|
||||
import { OptimizeButton as OptimizeButton_338ff118e214cff355f6d710d1a381fb } from "@mintel/payload-ai/components/OptimizeButton";
|
||||
import { GenerateSlugButton as GenerateSlugButton_5baeea8510d263708dd253e86d55e0b4 } from "@mintel/payload-ai/components/FieldGenerators/GenerateSlugButton";
|
||||
import { default as default_76cec558bd86098fa1dab70b12eb818f } from "@/src/payload/components/TagSelector";
|
||||
import { GenerateThumbnailButton as GenerateThumbnailButton_e5e8c00f1c031f15175fef0ff67513dc } from "@mintel/payload-ai/components/FieldGenerators/GenerateThumbnailButton";
|
||||
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from "@payloadcms/richtext-lexical/rsc";
|
||||
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from "@payloadcms/richtext-lexical/rsc";
|
||||
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from "@payloadcms/richtext-lexical/rsc";
|
||||
import { BlocksFeatureClient as BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { AiFieldButton as AiFieldButton_9125bc0af442fbb1889d8de3dff98501 } from "@mintel/payload-ai/components/FieldGenerators/AiFieldButton";
|
||||
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { default as default_2ebf44fdf8ebc607cf0de30cff485248 } from "@/src/payload/components/ColorPicker";
|
||||
import { default as default_a1c6da8fb7dd9846a8b07123ff256d09 } from "@/src/payload/components/IconSelector";
|
||||
import { ConvertInquiryButton as ConvertInquiryButton_09fd670bce023a947ab66e4eebea5168 } from "@/src/payload/components/ConvertInquiryButton";
|
||||
import { AiAnalyzeButton as AiAnalyzeButton_51a6009c2b12d068d736ffd2b8182c71 } from "@/src/payload/components/AiAnalyzeButton";
|
||||
import { BulkMailButton as BulkMailButton_ebc54eabb8a07878581b0043bd687708 } from "@/src/payload/components/BulkMailButton";
|
||||
import { GanttChartView as GanttChartView_0162b82db971e8f1e27fbdd0aaa2f1f4 } from "@/src/payload/views/GanttChart";
|
||||
import { ChatWindowProvider as ChatWindowProvider_258e2d0901cb901e46c3eeed91676211 } from "@mintel/payload-ai/components/ChatWindow/index";
|
||||
import { S3ClientUploadHandler as S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24 } from "@payloadcms/storage-s3/client";
|
||||
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from "@payloadcms/next/rsc";
|
||||
|
||||
export const importMap = {
|
||||
"@mintel/payload-ai/components/AiMediaButtons#AiMediaButtons":
|
||||
AiMediaButtons_1d402a78164f07306f77dce953e62e11,
|
||||
"@mintel/payload-ai/components/OptimizeButton#OptimizeButton":
|
||||
OptimizeButton_338ff118e214cff355f6d710d1a381fb,
|
||||
"@mintel/payload-ai/components/FieldGenerators/GenerateSlugButton#GenerateSlugButton":
|
||||
GenerateSlugButton_5baeea8510d263708dd253e86d55e0b4,
|
||||
"@/src/payload/components/TagSelector#default":
|
||||
default_76cec558bd86098fa1dab70b12eb818f,
|
||||
"@mintel/payload-ai/components/FieldGenerators/GenerateThumbnailButton#GenerateThumbnailButton":
|
||||
GenerateThumbnailButton_e5e8c00f1c031f15175fef0ff67513dc,
|
||||
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell":
|
||||
RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField":
|
||||
RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent":
|
||||
LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/client#BlocksFeatureClient":
|
||||
BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@mintel/payload-ai/components/FieldGenerators/AiFieldButton#AiFieldButton":
|
||||
AiFieldButton_9125bc0af442fbb1889d8de3dff98501,
|
||||
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient":
|
||||
InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient":
|
||||
HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UploadFeatureClient":
|
||||
UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#BlockquoteFeatureClient":
|
||||
BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#RelationshipFeatureClient":
|
||||
RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#LinkFeatureClient":
|
||||
LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ChecklistFeatureClient":
|
||||
ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#OrderedListFeatureClient":
|
||||
OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UnorderedListFeatureClient":
|
||||
UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#IndentFeatureClient":
|
||||
IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#AlignFeatureClient":
|
||||
AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#HeadingFeatureClient":
|
||||
HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient":
|
||||
ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#InlineCodeFeatureClient":
|
||||
InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#SuperscriptFeatureClient":
|
||||
SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#SubscriptFeatureClient":
|
||||
SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#StrikethroughFeatureClient":
|
||||
StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient":
|
||||
UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#BoldFeatureClient":
|
||||
BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ItalicFeatureClient":
|
||||
ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@/src/payload/components/ColorPicker#default":
|
||||
default_2ebf44fdf8ebc607cf0de30cff485248,
|
||||
"@/src/payload/components/IconSelector#default":
|
||||
default_a1c6da8fb7dd9846a8b07123ff256d09,
|
||||
"@/src/payload/components/ConvertInquiryButton#ConvertInquiryButton":
|
||||
ConvertInquiryButton_09fd670bce023a947ab66e4eebea5168,
|
||||
"@/src/payload/components/AiAnalyzeButton#AiAnalyzeButton":
|
||||
AiAnalyzeButton_51a6009c2b12d068d736ffd2b8182c71,
|
||||
"@/src/payload/components/BulkMailButton#BulkMailButton":
|
||||
BulkMailButton_ebc54eabb8a07878581b0043bd687708,
|
||||
"@/src/payload/views/GanttChart#GanttChartView":
|
||||
GanttChartView_0162b82db971e8f1e27fbdd0aaa2f1f4,
|
||||
"@mintel/payload-ai/components/ChatWindow/index#ChatWindowProvider":
|
||||
ChatWindowProvider_258e2d0901cb901e46c3eeed91676211,
|
||||
"@payloadcms/storage-s3/client#S3ClientUploadHandler":
|
||||
S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24,
|
||||
"@payloadcms/next/rsc#CollectionCards":
|
||||
CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1,
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
import config from "@payload-config";
|
||||
import {
|
||||
REST_DELETE,
|
||||
REST_GET,
|
||||
REST_OPTIONS,
|
||||
REST_PATCH,
|
||||
REST_POST,
|
||||
REST_PUT,
|
||||
} from "@payloadcms/next/routes";
|
||||
|
||||
export const GET = REST_GET(config);
|
||||
export const POST = REST_POST(config);
|
||||
export const DELETE = REST_DELETE(config);
|
||||
export const OPTIONS = REST_OPTIONS(config);
|
||||
export const PATCH = REST_PATCH(config);
|
||||
export const PUT = REST_PUT(config);
|
||||
@@ -1,20 +0,0 @@
|
||||
import configPromise from "@payload-config";
|
||||
import "@payloadcms/next/css";
|
||||
import { RootLayout } from "@payloadcms/next/layouts";
|
||||
import React from "react";
|
||||
|
||||
import { handleServerFunctions } from "./actions";
|
||||
// @ts-ignore - Payload generates this file during the build process
|
||||
import { importMap } from "./admin/importMap";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<RootLayout
|
||||
config={configPromise}
|
||||
importMap={importMap}
|
||||
serverFunction={handleServerFunctions}
|
||||
>
|
||||
{children}
|
||||
</RootLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { ImageResponse } from "next/og";
|
||||
import { OGImageTemplate } from "../../../src/components/OGImageTemplate";
|
||||
import { getOgFonts, OG_IMAGE_SIZE } from "../../../src/lib/og-helper";
|
||||
|
||||
export const size = OG_IMAGE_SIZE;
|
||||
export const contentType = "image/png";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export default async function Image() {
|
||||
const fonts = await getOgFonts();
|
||||
|
||||
return new ImageResponse(
|
||||
<OGImageTemplate
|
||||
title="Über mich."
|
||||
description="15 Jahre Erfahrung. Ein Ziel: Websites, die ihre Versprechen halten."
|
||||
label="Marc Mintel"
|
||||
/>,
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts: fonts as any,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,333 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { Section } from "@/src/components/Section";
|
||||
import { Reveal } from "@/src/components/Reveal";
|
||||
import {
|
||||
ExperienceIllustration,
|
||||
ResponsibilityIllustration,
|
||||
ResultIllustration,
|
||||
ContactIllustration,
|
||||
HeroLines,
|
||||
ParticleNetwork,
|
||||
GridLines,
|
||||
} from "@/src/components/Landing";
|
||||
import { Signature } from "@/src/components/Signature";
|
||||
import { Check } from "lucide-react";
|
||||
import {
|
||||
H1,
|
||||
H3,
|
||||
H4,
|
||||
LeadText,
|
||||
BodyText,
|
||||
Label,
|
||||
MonoLabel,
|
||||
} from "@/src/components/Typography";
|
||||
import { Card, Container } from "@/src/components/Layout";
|
||||
import { Button } from "@/src/components/Button";
|
||||
import { IconList, IconListItem } from "@/src/components/IconList";
|
||||
import {
|
||||
GradientMesh,
|
||||
CodeSnippet,
|
||||
AbstractCircuit,
|
||||
} from "@/src/components/Effects";
|
||||
|
||||
import { Marker } from "@/src/components/Marker";
|
||||
|
||||
export default function AboutPage() {
|
||||
return (
|
||||
<div className="flex flex-col bg-white overflow-hidden relative">
|
||||
{/* Background decoration removed per user request */}
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="relative pt-12 md:pt-32 pb-8 md:pb-24 overflow-hidden border-b border-slate-50">
|
||||
<Container variant="narrow" className="relative z-10">
|
||||
<div className="flex flex-col items-center text-center space-y-6 md:space-y-12">
|
||||
<Reveal width="fit-content">
|
||||
<div className="relative">
|
||||
{/* Structural rings around avatar */}
|
||||
{/* Structural rings removed per user request */}
|
||||
|
||||
<div className="relative w-32 h-32 md:w-40 md:h-40 rounded-full overflow-hidden border border-slate-200 shadow-xl bg-white p-1 group">
|
||||
<div className="w-full h-full rounded-full overflow-hidden relative aspect-square">
|
||||
<img
|
||||
src="/marc-mintel.png"
|
||||
alt="Marc Mintel"
|
||||
className="object-cover grayscale transition-all duration-1000 ease-in-out scale-110 group-hover:scale-100 group-hover:grayscale-0 w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<div className="space-y-3 md:space-y-6 max-w-3xl">
|
||||
<Reveal delay={0.1}>
|
||||
<div className="flex items-center justify-center gap-2 md:gap-4 mb-1 md:mb-4">
|
||||
<div className="h-px w-6 md:w-8 bg-slate-900"></div>
|
||||
<MonoLabel className="text-slate-900 text-[10px] md:text-sm">
|
||||
Digital Architect
|
||||
</MonoLabel>
|
||||
<div className="h-px w-6 md:w-8 bg-slate-900"></div>
|
||||
</div>
|
||||
</Reveal>
|
||||
<Reveal delay={0.2}>
|
||||
<H1 className="text-4xl md:text-8xl leading-none tracking-tighter">
|
||||
Über <span className="text-slate-400">mich.</span>
|
||||
</H1>
|
||||
</Reveal>
|
||||
<Reveal delay={0.3}>
|
||||
<p className="text-slate-400 font-medium max-w-xl mx-auto text-sm md:text-xl">
|
||||
15 Jahre Erfahrung. Ein Ziel: Websites, die ihre Versprechen
|
||||
halten.
|
||||
</p>
|
||||
</Reveal>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
{/* Connector to first section */}
|
||||
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-px h-12 md:h-16 bg-gradient-to-b from-transparent to-slate-200" />
|
||||
</section>
|
||||
|
||||
{/* Section 01: Story */}
|
||||
<Section
|
||||
number="01"
|
||||
title="Erfahrung"
|
||||
borderTop
|
||||
illustration={<ExperienceIllustration className="w-24 h-24" />}
|
||||
>
|
||||
<div className="space-y-8 md:space-y-12">
|
||||
<Reveal>
|
||||
<H3 className="text-2xl md:text-5xl leading-tight max-w-3xl">
|
||||
Vom Designer <br />
|
||||
<span className="text-slate-400">zum Architekten.</span>
|
||||
</H3>
|
||||
</Reveal>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-12">
|
||||
<Reveal delay={0.1}>
|
||||
<div className="space-y-6 md:space-y-8">
|
||||
<LeadText className="text-xl md:text-2xl text-slate-400">
|
||||
Agenturen, Konzerne, Startups – ich habe die Branche von allen
|
||||
Seiten kennengelernt. Was hängen geblieben ist:{" "}
|
||||
<span className="text-slate-900">
|
||||
<Marker delay={0.2}>Ergebnisse</Marker> zählen. Nicht der
|
||||
Weg dorthin.
|
||||
</span>
|
||||
</LeadText>
|
||||
<IconList className="space-y-4">
|
||||
{[
|
||||
"Frontend, Backend, Infrastruktur – Fullstack",
|
||||
"Komplexe Systeme auf das Wesentliche reduziert",
|
||||
"Performance-Probleme systematisch gelöst",
|
||||
].map((item, i) => (
|
||||
<IconListItem key={i} bullet>
|
||||
<BodyText className="text-lg">{item}</BodyText>
|
||||
</IconListItem>
|
||||
))}
|
||||
</IconList>
|
||||
</div>
|
||||
</Reveal>
|
||||
<Reveal delay={0.2}>
|
||||
<Card
|
||||
variant="gray"
|
||||
hover={false}
|
||||
padding="normal"
|
||||
className="group"
|
||||
>
|
||||
<H4 className="text-xl mb-6">
|
||||
Heute: Direkte Zusammenarbeit ohne Reibungsverluste.
|
||||
</H4>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{["Effizient", "Pragmatisch", "Verlässlich"].map((tag, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="px-4 py-2 bg-white border border-slate-200 rounded-full shadow-sm"
|
||||
>
|
||||
<Label className="text-slate-900">{tag}</Label>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</Reveal>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Section 02: Arbeitsweise – HOW I work */}
|
||||
<Section
|
||||
number="02"
|
||||
title="Arbeitsweise"
|
||||
variant="gray"
|
||||
borderTop
|
||||
illustration={<ResponsibilityIllustration className="w-24 h-24" />}
|
||||
effects={<GradientMesh variant="subtle" className="opacity-60" />}
|
||||
>
|
||||
<div className="space-y-12">
|
||||
<Reveal>
|
||||
<H3 className="text-2xl md:text-5xl leading-tight max-w-3xl">
|
||||
So läuft ein Projekt <br />
|
||||
<span className="text-slate-400">bei mir ab.</span>
|
||||
</H3>
|
||||
</Reveal>
|
||||
|
||||
{/* Timeline Steps */}
|
||||
<div className="space-y-1 relative">
|
||||
{/* Connecting line */}
|
||||
<div className="absolute left-[15px] top-8 bottom-8 w-px bg-slate-200 hidden md:block" />
|
||||
|
||||
{[
|
||||
{
|
||||
step: "01",
|
||||
title: "Briefing",
|
||||
desc: "Sie beschreiben Ihr Vorhaben. Ich höre zu und stelle die richtigen Fragen.",
|
||||
},
|
||||
{
|
||||
step: "02",
|
||||
title: "Angebot",
|
||||
desc: "Ein Fixpreis-Angebot mit klarem Leistungsumfang. Keine Überraschungen.",
|
||||
},
|
||||
{
|
||||
step: "03",
|
||||
title: "Umsetzung",
|
||||
desc: "Schnelle Iterationen. Sie sehen regelmäßig den Fortschritt und geben Feedback.",
|
||||
},
|
||||
{
|
||||
step: "04",
|
||||
title: "Launch",
|
||||
desc: "Go-Live mit automatisiertem Deployment. Dokumentiert und übergabereif.",
|
||||
},
|
||||
].map((item, i) => (
|
||||
<Reveal key={i} delay={0.1 + i * 0.1}>
|
||||
<div className="flex gap-4 md:gap-6 py-2 md:py-6 group">
|
||||
<div className="relative z-10 shrink-0">
|
||||
<div className="w-8 h-8 rounded-full bg-white border border-slate-200 flex items-center justify-center group-hover:border-slate-400 group-hover:shadow-md transition-all duration-500">
|
||||
<span className="text-[9px] font-mono font-bold text-slate-400 group-hover:text-slate-900 transition-colors">
|
||||
{item.step}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1 md:space-y-2 pt-1">
|
||||
<H4 className="text-base md:text-xl font-bold">
|
||||
{item.title}
|
||||
</H4>
|
||||
<BodyText className="text-slate-500 text-sm md:text-base">
|
||||
{item.desc}
|
||||
</BodyText>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Section 03: Garantie – The Pledge */}
|
||||
<Section number="03" title="Garantie" borderTop>
|
||||
<div className="relative">
|
||||
<Reveal>
|
||||
<div className="max-w-4xl text-left space-y-12 md:space-y-16 py-8 md:py-16">
|
||||
<H3 className="text-3xl md:text-6xl leading-tight">
|
||||
Ich stehe für <br />
|
||||
<span className="text-slate-400">meine Arbeit gerade.</span>
|
||||
</H3>
|
||||
|
||||
<div className="prose prose-lg md:prose-2xl text-slate-500 leading-relaxed">
|
||||
<p>
|
||||
Keine Hierarchien. Keine Ausreden. Wenn etwas nicht passt,
|
||||
liegt die Verantwortung bei mir.
|
||||
</p>
|
||||
<p>
|
||||
Ich liefere nicht nur Code, sondern{" "}
|
||||
<span className="text-slate-900 font-medium relative inline-block">
|
||||
Ergebnisse
|
||||
<svg
|
||||
className="absolute -bottom-2 left-0 w-full h-3 text-blue-500/30"
|
||||
viewBox="0 0 100 10"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<path
|
||||
d="M0 5 Q 50 10 100 5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
, auf die Sie bauen können.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 max-w-2xl text-left">
|
||||
<div className="p-6 bg-slate-50 rounded-2xl border border-slate-100">
|
||||
<h4 className="font-bold text-slate-900 mb-2">
|
||||
Fixpreis-Garantie
|
||||
</h4>
|
||||
<p className="text-slate-500 text-sm">
|
||||
Keine versteckten Kosten. Der vereinbarte Preis ist final.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 bg-slate-50 rounded-2xl border border-slate-100">
|
||||
<h4 className="font-bold text-slate-900 mb-2">
|
||||
Satisfaction Guarantee
|
||||
</h4>
|
||||
<p className="text-slate-500 text-sm">
|
||||
Wir gehen erst live, wenn Sie zu 100% zufrieden sind.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-8 md:pt-12 flex flex-col items-start">
|
||||
<div className="w-64 md:w-80">
|
||||
<Signature delay={0.5} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Section 04: CTA */}
|
||||
<Section
|
||||
number="04"
|
||||
title="Kontakt"
|
||||
variant="gray"
|
||||
borderTop
|
||||
illustration={<ContactIllustration className="w-24 h-24" />}
|
||||
effects={<GradientMesh variant="metallic" className="opacity-60" />}
|
||||
>
|
||||
<div className="space-y-10 md:space-y-12">
|
||||
<Reveal>
|
||||
<H3 className="text-2xl md:text-5xl leading-tight max-w-3xl">
|
||||
Bereit für eine <br />
|
||||
<span className="text-slate-400">Zusammenarbeit?</span>
|
||||
</H3>
|
||||
</Reveal>
|
||||
|
||||
<Card
|
||||
variant="glass"
|
||||
hover={false}
|
||||
padding="normal"
|
||||
techBorder
|
||||
className="rounded-3xl shadow-xl relative overflow-hidden group"
|
||||
>
|
||||
<div className="relative z-10 space-y-6 md:space-y-8">
|
||||
<LeadText className="text-lg md:text-4xl leading-tight max-w-2xl text-slate-400">
|
||||
Lassen Sie uns gemeinsam etwas bauen, das{" "}
|
||||
<span className="text-slate-900">
|
||||
wirklich <Marker delay={0.3}>funktioniert.</Marker>
|
||||
</span>
|
||||
</LeadText>
|
||||
|
||||
<div className="pt-2 md:pt-4">
|
||||
<Button href="/contact" className="w-full md:w-auto">
|
||||
Projekt anfragen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { ImageResponse } from "next/og";
|
||||
import { getAllPosts } from "../../../../src/lib/posts";
|
||||
import { blogThumbnails } from "../../../../src/components/blog/blogThumbnails";
|
||||
import { BlogOGImageTemplate } from "../../../../src/components/BlogOGImageTemplate";
|
||||
import { getOgFonts, OG_IMAGE_SIZE } from "../../../../src/lib/og-helper";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
|
||||
export const size = OG_IMAGE_SIZE;
|
||||
export const contentType = "image/png";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export default async function Image({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const allPosts = await getAllPosts();
|
||||
const post = allPosts.find((p) => p.slug === slug);
|
||||
|
||||
let backgroundImageSrc: string | undefined = undefined;
|
||||
|
||||
// If we have a custom generated thumbnail, convert it to a data URI for Satori
|
||||
if (post?.thumbnail) {
|
||||
try {
|
||||
const filePath = path.join(process.cwd(), "public", post.thumbnail);
|
||||
const fileBuffer = await fs.readFile(filePath);
|
||||
|
||||
const ext = path.extname(post.thumbnail).substring(1).toLowerCase();
|
||||
const mimeType =
|
||||
ext === "jpg" || ext === "jpeg" ? "image/jpeg" : "image/png";
|
||||
|
||||
backgroundImageSrc = `data:${mimeType};base64,${fileBuffer.toString("base64")}`;
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[OG Image Generator] Could not read thumbnail file for ${slug} to use as background:`,
|
||||
err,
|
||||
);
|
||||
// Fall through to standard plain background
|
||||
}
|
||||
}
|
||||
|
||||
const thumbnail = blogThumbnails[slug];
|
||||
|
||||
const title = post?.title || "Marc Mintel";
|
||||
const description =
|
||||
post?.description ||
|
||||
"Technical problem solver's blog - practical insights and learning notes";
|
||||
const label = post ? "Blog Post" : "Engineering";
|
||||
const accentColor = thumbnail?.accent;
|
||||
const keyword = thumbnail?.keyword;
|
||||
|
||||
const fonts = await getOgFonts();
|
||||
|
||||
return new ImageResponse(
|
||||
<BlogOGImageTemplate
|
||||
title={title}
|
||||
description={description}
|
||||
label={label}
|
||||
accentColor={accentColor}
|
||||
keyword={keyword}
|
||||
backgroundImageSrc={backgroundImageSrc}
|
||||
/>,
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts: fonts as any,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
import * as React from "react";
|
||||
import type { Metadata } from "next";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getPayloadHMR } from "@payloadcms/next/utilities";
|
||||
import configPromise from "@payload-config";
|
||||
import { getAllPosts } from "@/src/lib/posts";
|
||||
import { BlogPostHeader } from "@/src/components/blog/BlogPostHeader";
|
||||
import { Section } from "@/src/components/Section";
|
||||
import { Reveal } from "@/src/components/Reveal";
|
||||
import { BlogPostClient } from "@/src/components/BlogPostClient";
|
||||
import { TextSelectionShare } from "@/src/components/TextSelectionShare";
|
||||
import { BlogPostStickyBar } from "@/src/components/blog/BlogPostStickyBar";
|
||||
import { MDXContent } from "@/src/components/MDXContent";
|
||||
import { PayloadRichText } from "@/src/components/PayloadRichText";
|
||||
import { TableOfContents } from "@/src/components/TableOfContents";
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const allPosts = await getAllPosts();
|
||||
return allPosts.map((post) => ({
|
||||
slug: post.slug,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const allPosts = await getAllPosts();
|
||||
const post = allPosts.find((p) => p.slug === slug);
|
||||
|
||||
if (!post) return {};
|
||||
|
||||
return {
|
||||
title: post.title,
|
||||
description: post.description,
|
||||
openGraph: {
|
||||
title: post.title,
|
||||
description: post.description,
|
||||
type: "article",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: post.title,
|
||||
description: post.description,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function BlogPostPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const allPosts = await getAllPosts();
|
||||
const post = allPosts.find((p) => p.slug === slug);
|
||||
|
||||
if (!post) {
|
||||
const payload = await getPayloadHMR({ config: configPromise });
|
||||
const redirectDoc = await payload.find({
|
||||
collection: "redirects",
|
||||
where: {
|
||||
from: { equals: slug },
|
||||
},
|
||||
});
|
||||
|
||||
if (redirectDoc.docs.length > 0) {
|
||||
redirect(`/blog/${redirectDoc.docs[0].to}`);
|
||||
}
|
||||
|
||||
notFound();
|
||||
}
|
||||
|
||||
const formattedDate = new Date(post.date).toLocaleDateString("de-DE", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
const wordCount = post.description.split(/\s+/).length + 300;
|
||||
const readingTime = Math.max(1, Math.ceil(wordCount / 200));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8 md:gap-12 py-8 md:py-24 overflow-hidden">
|
||||
<BlogPostClient readingTime={readingTime} title={post.title} />
|
||||
|
||||
<BlogPostHeader
|
||||
title={post.title}
|
||||
description={post.description}
|
||||
date={formattedDate}
|
||||
readingTime={readingTime}
|
||||
slug={slug}
|
||||
thumbnail={post.thumbnail}
|
||||
/>
|
||||
|
||||
<main id="post-content">
|
||||
<BlogPostStickyBar
|
||||
title={post.title}
|
||||
url={`https://mintel.me/blog/${slug}`}
|
||||
/>
|
||||
|
||||
<Section containerVariant="wide" className="pt-0 md:pt-0">
|
||||
<div className="max-w-4xl mx-auto px-5 md:px-0">
|
||||
<Reveal delay={0.4} width="100%">
|
||||
{post.tags && post.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-10 md:mb-12">
|
||||
{post.tags?.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="px-2.5 py-1 bg-slate-50 border border-slate-100 rounded text-[9px] md:text-[10px] font-mono text-slate-500 uppercase tracking-widest"
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="article-content max-w-none">
|
||||
<TableOfContents />
|
||||
{post.lexicalContent ? (
|
||||
<PayloadRichText data={post.lexicalContent} />
|
||||
) : (
|
||||
<MDXContent code={post.body.code} />
|
||||
)}
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</Section>
|
||||
</main>
|
||||
|
||||
<TextSelectionShare />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { getAllPosts } from "@/src/lib/posts";
|
||||
import { BlogClient } from "@/src/components/blog/BlogClient";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Blog | Mintel.me",
|
||||
description:
|
||||
"Gedanken über Engineering, Design und die Architektur der Zukunft.",
|
||||
};
|
||||
|
||||
export default async function BlogPage() {
|
||||
const posts = await getAllPosts();
|
||||
return <BlogClient allPosts={posts as any} />;
|
||||
}
|
||||
@@ -1,640 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { motion, useScroll, useTransform } from "framer-motion";
|
||||
import { Section } from "@/src/components/Section";
|
||||
import { Reveal } from "@/src/components/Reveal";
|
||||
import {
|
||||
H1,
|
||||
H2,
|
||||
H3,
|
||||
LeadText,
|
||||
Label,
|
||||
MonoLabel,
|
||||
BodyText,
|
||||
} from "@/src/components/Typography";
|
||||
import { BackgroundGrid, Container } from "@/src/components/Layout";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/src/components/Button";
|
||||
import { IframeSection } from "@/src/components/IframeSection";
|
||||
import {
|
||||
Activity,
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
ShieldCheck,
|
||||
Cpu,
|
||||
Server,
|
||||
Layers,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Marker } from "@/src/components/Marker";
|
||||
import { GlitchText } from "@/src/components/GlitchText";
|
||||
|
||||
export default function KLZCablesCaseStudy() {
|
||||
const { scrollYProgress } = useScroll();
|
||||
const heroY = useTransform(scrollYProgress, [0, 0.2], [0, -20]);
|
||||
const heroOpacity = useTransform(scrollYProgress, [0, 0.15], [1, 0]);
|
||||
const gridRotate = useTransform(scrollYProgress, [0, 1], [0, 2]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col bg-white relative min-h-screen selection:bg-slate-900 selection:text-white overflow-hidden">
|
||||
<motion.div
|
||||
style={{ opacity: heroOpacity }}
|
||||
className="fixed inset-0 z-0 pointer-events-none"
|
||||
>
|
||||
<BackgroundGrid />
|
||||
</motion.div>
|
||||
|
||||
{/* --- HERO: INDUSTRIAL INFRASTRUCTURE --- */}
|
||||
<section className="relative min-h-[30vh] md:min-h-[40vh] py-10 md:py-20 overflow-hidden border-b border-slate-100 bg-white">
|
||||
<motion.div
|
||||
style={{ y: heroY, rotate: gridRotate }}
|
||||
className="absolute inset-0 bg-[linear-gradient(to_right,#f1f5f9_1px,transparent_1px),linear-gradient(to_bottom,#f1f5f9_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_60%_50%_at_50%_50%,#000_20%,transparent_100%)] pointer-events-none opacity-40"
|
||||
/>
|
||||
|
||||
<Container variant="narrow" className="relative z-10">
|
||||
<Reveal>
|
||||
<Link
|
||||
href="/case-studies"
|
||||
className="inline-flex items-center gap-2 text-slate-400 hover:text-slate-900 mb-8 md:mb-12 transition-colors font-bold text-[10px] uppercase tracking-[0.4em] group"
|
||||
>
|
||||
<ArrowLeft className="w-3 h-3 group-hover:-translate-x-1 transition-transform" />{" "}
|
||||
Zurück
|
||||
</Link>
|
||||
</Reveal>
|
||||
<div className="space-y-12">
|
||||
<Reveal direction="down" blur>
|
||||
<div className="inline-flex items-center gap-6">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
whileInView={{ width: 48 }}
|
||||
transition={{ duration: 1, ease: "circOut" }}
|
||||
className="h-px bg-slate-900"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<MonoLabel className="text-slate-900 tracking-[0.4em]">
|
||||
SYSTEM-ARCHITEKTUR // 2025
|
||||
</MonoLabel>
|
||||
<Label className="text-[10px] text-slate-400 font-mono">
|
||||
HARDENED WORDPRESS // VARNISH STACK
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<div className="space-y-8 md:space-y-12">
|
||||
<GlitchText
|
||||
as="h1"
|
||||
className="text-4xl md:text-8xl tracking-tighter leading-[0.9] font-bold text-slate-900"
|
||||
>
|
||||
KLZ Cables
|
||||
</GlitchText>
|
||||
<br className="hidden md:block" />
|
||||
<span className="text-slate-100 text-3xl md:text-6xl font-bold tracking-tighter">
|
||||
Case Study.
|
||||
</span>
|
||||
|
||||
<Reveal delay={0.2} direction="right" blur>
|
||||
<div className="max-w-3xl border-l-[3px] border-slate-900 pl-6 md:pl-12">
|
||||
<LeadText className="text-lg md:text-4xl leading-tight text-slate-900 font-medium">
|
||||
Engineering eines <br className="hidden md:block" />
|
||||
<Marker delay={0.2}>Systems.</Marker>
|
||||
</LeadText>
|
||||
<BodyText className="mt-4 md:mt-6 text-base md:text-xl text-slate-500 max-w-xl leading-relaxed font-serif italic">
|
||||
Vom statischen Altsystem zum industriellen Standard. Ich
|
||||
habe das KLZ-System auf das Wesentliche reduziert: Hardened
|
||||
Infrastructure, parametrische Datenpflege und zero
|
||||
maintenance.
|
||||
</BodyText>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
<Reveal delay={0.4} direction="up" scale={0.98} blur>
|
||||
<div className="flex flex-wrap gap-8 md:gap-24 pt-6 md:pt-12 border-t border-slate-100">
|
||||
<div className="space-y-1 md:space-y-2">
|
||||
<Label className="text-slate-400 text-[10px] md:text-xs">
|
||||
Data Integrity
|
||||
</Label>
|
||||
<div className="flex items-center gap-2 md:gap-3">
|
||||
<div className="w-2 h-2 md:w-2.5 md:h-2.5 bg-[rgba(129,199,132,1)] rounded-full animate-pulse" />
|
||||
<span className="text-base md:text-2xl font-bold font-mono text-slate-900 tracking-tight">
|
||||
Relational Data
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1 md:space-y-2">
|
||||
<Label className="text-slate-400 text-[10px] md:text-xs">
|
||||
Security Layer
|
||||
</Label>
|
||||
<div className="flex items-center gap-2 md:gap-3 text-base md:text-2xl font-bold font-mono text-slate-900">
|
||||
<ShieldCheck className="w-5 h-5 md:w-6 md:h-6 text-[rgba(129,199,132,1)]" />
|
||||
<span>WP + Varnish</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
|
||||
{/* --- SECTION 01: ARCHITECTURE --- */}
|
||||
<Section
|
||||
number="01"
|
||||
title="System-Hardening & Logic"
|
||||
borderBottom
|
||||
containerVariant="normal"
|
||||
>
|
||||
{/* Binary overlay background */}
|
||||
<div className="absolute top-0 right-0 p-8 opacity-[0.03] select-none pointer-events-none font-mono text-[10px] hidden md:block">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i}>01001101 01001001 01001110 01010100</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-8 lg:gap-24 items-start">
|
||||
<div className="md:col-span-12 mb-4 md:mb-12">
|
||||
<H2 className="text-3xl md:text-8xl tracking-tighter mb-4 md:mb-12">
|
||||
<GlitchText>Architektur-</GlitchText> <br />
|
||||
Refactor.
|
||||
</H2>
|
||||
</div>
|
||||
<div className="md:col-span-7 space-y-8 md:space-y-12">
|
||||
<Reveal delay={0.1} direction="up" blur>
|
||||
<div className="space-y-6 md:space-y-10">
|
||||
<BodyText className="text-xl md:text-2xl leading-relaxed font-serif italic text-slate-500">
|
||||
Vom statischen HTML zur zentralen Daten-Instanz.
|
||||
</BodyText>
|
||||
<BodyText className="text-lg md:text-xl text-slate-600 leading-relaxed">
|
||||
Ich habe die KLZ-Architektur radikal auf einen entkoppelten
|
||||
High-Performance-Stack umgestellt. WordPress fungiert hier
|
||||
nicht als CMS-Baukasten, sondern speichert alle technischen
|
||||
Attribute in einer zentralen relationalen Instanz. Durch die
|
||||
Implementierung nativer PHP-Services und den Verzicht auf
|
||||
volatile Drittanbieter-Plugins wurde ein System geschaffen,
|
||||
das keine technologischen Überraschungen zulässt. Stability by{" "}
|
||||
<Marker delay={0.5}>Design.</Marker>
|
||||
</BodyText>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
<div className="md:col-span-5 relative mt-8 md:mt-0">
|
||||
<Reveal delay={0.3} direction="right" scale={0.98} blur>
|
||||
<motion.div
|
||||
whileHover={{ y: -5, scale: 1.01 }}
|
||||
transition={{ type: "spring", stiffness: 200, damping: 25 }}
|
||||
className="p-6 md:p-12 bg-slate-50 rounded-[2.5rem] md:rounded-[3rem] border border-slate-100 space-y-6 md:space-y-12 relative overflow-hidden group shadow-sm text-left"
|
||||
>
|
||||
<div className="space-y-6 md:space-y-8 relative z-10">
|
||||
<Label className="text-slate-900">System Metriken</Label>
|
||||
<div className="space-y-6 md:space-y-8">
|
||||
{[
|
||||
{
|
||||
label: "Edge Caching",
|
||||
desc: "Varnish + W3TC Object Cache",
|
||||
icon: <Server className="w-5 h-5 text-slate-400" />,
|
||||
},
|
||||
{
|
||||
label: "Analytics",
|
||||
desc: "Independent (Global Data Compliance)",
|
||||
icon: <Activity className="w-5 h-5 text-slate-400" />,
|
||||
},
|
||||
{
|
||||
label: "Custom Core",
|
||||
desc: "REST via Native Services",
|
||||
icon: <Cpu className="w-5 h-5 text-slate-400" />,
|
||||
},
|
||||
].map((item, i) => (
|
||||
<Reveal
|
||||
key={i}
|
||||
direction="right"
|
||||
delay={0.5 + i * 0.1}
|
||||
width="100%"
|
||||
className="flex gap-4 md:gap-6 border-b border-slate-200/50 pb-6 last:border-0 last:pb-0"
|
||||
>
|
||||
<div className="shrink-0 mt-1">{item.icon}</div>
|
||||
<div className="space-y-1">
|
||||
<MonoLabel className="text-[9px] md:text-[10px] text-slate-400">
|
||||
{item.label}
|
||||
</MonoLabel>
|
||||
<BodyText className="text-base font-bold text-slate-900">
|
||||
{item.desc}
|
||||
</BodyText>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* --- SHOWCASE: LANDING --- */}
|
||||
<section className="py-12 md:py-32 bg-slate-50 border-y border-slate-100 overflow-hidden relative">
|
||||
<div className="w-full max-w-[1920px] mx-auto px-4 md:px-12 relative z-10">
|
||||
<Reveal direction="none" blur>
|
||||
<div className="relative mb-8 md:mb-16 flex justify-between items-end">
|
||||
<div className="space-y-6">
|
||||
<Label className="text-slate-500">
|
||||
Infrastructure Validation
|
||||
</Label>
|
||||
<H3 className="text-3xl md:text-8xl tracking-tighter">
|
||||
<GlitchText>Global Hub.</GlitchText>
|
||||
</H3>
|
||||
</div>
|
||||
|
||||
{/* Binary overlay left */}
|
||||
<div className="absolute left-0 bottom-0 p-8 opacity-[0.03] select-none pointer-events-none font-mono text-[10px] hidden md:block group-hover:opacity-10 transition-opacity duration-1000">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i}>HANDSHAKE_0x00{i}A // SYNC_ACTIVE</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
<Reveal delay={0.2} width="100%" direction="up" scale={0.98} blur>
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.01 }}
|
||||
transition={{ type: "spring", stiffness: 200, damping: 20 }}
|
||||
>
|
||||
<IframeSection
|
||||
src="/showcase/klz-cables.com/index.html"
|
||||
height="400px"
|
||||
mobileHeight="350px"
|
||||
desktopHeight="850px"
|
||||
desktopWidth={1920}
|
||||
allowScroll
|
||||
browserFrame
|
||||
className="w-full h-full transition-all duration-1000 ease-in-out no-scrollbar"
|
||||
/>
|
||||
</motion.div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* --- SECTION 02: TECHNICAL DETAIL --- */}
|
||||
<Section
|
||||
number="02"
|
||||
title="Asset Management"
|
||||
variant="white"
|
||||
borderBottom
|
||||
containerVariant="wide"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-8 md:gap-16">
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-6 md:gap-24 items-end">
|
||||
<div className="md:col-span-8">
|
||||
<Reveal direction="left" blur>
|
||||
<div className="space-y-6 md:space-y-12">
|
||||
<Label className="text-slate-400 text-xs md:text-sm">
|
||||
Asset Pipelines
|
||||
</Label>
|
||||
<H3 className="text-2xl md:text-6xl tracking-tighter">
|
||||
Automated Documentation.
|
||||
</H3>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
<Reveal delay={0.1} direction="right" blur>
|
||||
<BodyText className="text-lg md:text-xl text-slate-500 pb-2 font-serif italic leading-relaxed">
|
||||
Für Hochspannungs-N2XS(F)2Y Kabel ist Datentreue eine
|
||||
Sicherheitsanforderung. Ich habe eine automatisierte
|
||||
Asset-Pipeline entwickelt, die technische Datenblätter
|
||||
serverseitig generiert und validiert.
|
||||
</BodyText>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
<Reveal delay={0.2} width="100%" direction="up" scale={0.98} blur>
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.01 }}
|
||||
transition={{ type: "spring", stiffness: 200, damping: 20 }}
|
||||
className="relative w-full group"
|
||||
>
|
||||
<div className="relative w-full overflow-visible">
|
||||
<IframeSection
|
||||
src="/showcase/klz-cables.com/power-cables-medium-voltage-cables.html"
|
||||
height="450px"
|
||||
mobileHeight="400px"
|
||||
desktopHeight="1000px"
|
||||
desktopWidth={1920}
|
||||
allowScroll
|
||||
offsetY={100}
|
||||
browserFrame
|
||||
className="w-full transition-all duration-1000 no-scrollbar"
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
number="03"
|
||||
title="Katalog-Architektur"
|
||||
borderBottom
|
||||
containerVariant="wide"
|
||||
>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 md:gap-16 items-center">
|
||||
<div className="lg:col-span-12 mb-4 md:mb-12 text-center lg:text-left relative z-10">
|
||||
<H3 className="text-3xl md:text-6xl max-w-4xl tracking-tighter">
|
||||
Fokus auf <br />
|
||||
<Marker delay={0.2}>Spezifikationen.</Marker>
|
||||
</H3>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-8 relative group">
|
||||
<Reveal width="100%" direction="left" scale={0.98} blur>
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.01 }}
|
||||
transition={{ type: "spring", stiffness: 200, damping: 20 }}
|
||||
className="relative h-[400px] md:h-[650px] w-full overflow-hidden group"
|
||||
>
|
||||
<IframeSection
|
||||
src="/showcase/klz-cables.com/which-cables-for-wind-power-differences-from-low-to-extra-high-voltage-explained-2.html"
|
||||
height="100%"
|
||||
desktopWidth={1920}
|
||||
allowScroll
|
||||
browserFrame
|
||||
className="h-full w-full transition-all duration-700 no-scrollbar"
|
||||
/>
|
||||
</motion.div>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-4 space-y-10 relative z-10">
|
||||
<Reveal delay={0.2} direction="right" blur>
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
<Label className="text-slate-400">Katalog-Struktur</Label>
|
||||
<LeadText className="text-base md:text-lg leading-relaxed">
|
||||
Der Produktbereich wurde konsequent auf die Bedürfnisse
|
||||
technischer Planer optimiert. Klare Hierarchien und der
|
||||
Verzicht auf E-Commerce-Rauschen ermöglichen einen direkten
|
||||
Zugriff auf Kabel-Parameter und Datenblätter.
|
||||
</LeadText>
|
||||
<motion.div
|
||||
whileHover={{ x: 10 }}
|
||||
className="p-5 md:p-8 bg-white border border-slate-200 rounded-2xl md:rounded-3xl shadow-sm"
|
||||
>
|
||||
<Layers className="w-5 h-5 md:w-6 md:h-6 text-slate-400 mb-3 md:mb-4" />
|
||||
<BodyText className="text-sm font-medium">
|
||||
Strukturierte Aufbereitung technischer Produktdaten.
|
||||
</BodyText>
|
||||
</motion.div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* --- SECTION 04: CONTENT ENGINE --- */}
|
||||
<Section
|
||||
number="04"
|
||||
title="Content Strategy"
|
||||
variant="white"
|
||||
borderBottom
|
||||
containerVariant="wide"
|
||||
>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-16 items-center">
|
||||
<div className="lg:col-span-4 space-y-10 order-2 lg:order-1">
|
||||
<Reveal direction="left" blur>
|
||||
<div className="space-y-6">
|
||||
<Label className="text-slate-400">Knowledge Transfer</Label>
|
||||
<H3 className="text-3xl md:text-6xl tracking-tighter">
|
||||
Insights & News.
|
||||
</H3>
|
||||
<BodyText className="text-xl text-slate-500 font-serif italic">
|
||||
Die News-Engine dient als technischer Hub für
|
||||
Industrie-Standards. Durch die Implementierung eines
|
||||
performanten Blog-Systems wird Fachwissen direkt an die
|
||||
Zielgruppe kommuniziert.
|
||||
</BodyText>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
<div className="lg:col-span-8 order-1 lg:order-2">
|
||||
<Reveal width="100%" direction="right" scale={0.98} blur>
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.01 }}
|
||||
transition={{ type: "spring", stiffness: 200, damping: 20 }}
|
||||
className="relative h-[450px] md:h-[700px] w-full"
|
||||
>
|
||||
<IframeSection
|
||||
src="/showcase/klz-cables.com/blog.html"
|
||||
height="100%"
|
||||
desktopWidth={1600}
|
||||
allowScroll
|
||||
browserFrame
|
||||
className="h-full w-full no-scrollbar"
|
||||
/>
|
||||
</motion.div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* --- SECTION 05: TEAM & TRUST --- */}
|
||||
<Section
|
||||
number="05"
|
||||
title="Ergebnis"
|
||||
borderBottom
|
||||
containerVariant="wide"
|
||||
>
|
||||
<div className="space-y-10 md:space-y-16 text-center">
|
||||
<Reveal direction="up" blur>
|
||||
<H3 className="text-3xl md:text-8xl tracking-tighter">
|
||||
System-Lifecycle.
|
||||
</H3>
|
||||
<LeadText className="mx-auto max-w-2xl pt-2 md:pt-6 text-lg md:text-xl leading-relaxed">
|
||||
Die Migration von einer statischen Datei-Struktur zu einer
|
||||
zentralisierten Daten-Instanz eliminiert technische Schulden und
|
||||
manuelle Fehlerquellen. Das Ergebnis ist eine wartungsfreie
|
||||
Architektur, die technische Datentreue über den gesamten
|
||||
Produkt-Lifecycle sicherstellt.
|
||||
</LeadText>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={0.2} width="100%" direction="up" scale={0.98} blur>
|
||||
<div className="relative group w-full text-left">
|
||||
<div className="relative block w-full overflow-visible">
|
||||
<IframeSection
|
||||
src="/showcase/klz-cables.com/team.html"
|
||||
height="450px"
|
||||
mobileHeight="400px"
|
||||
desktopHeight="1100px"
|
||||
desktopWidth={1440}
|
||||
allowScroll
|
||||
browserFrame
|
||||
className="w-full h-full no-scrollbar"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* --- SECTION 06: CONVERSION --- */}
|
||||
<Section
|
||||
number="06"
|
||||
title="Lead Engineering"
|
||||
variant="white"
|
||||
containerVariant="wide"
|
||||
className="!pb-32"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-8 md:gap-24 items-center">
|
||||
<div className="md:col-span-6 md:order-2">
|
||||
<Reveal direction="left" blur>
|
||||
<div className="space-y-6">
|
||||
<Label className="text-slate-400">Interaction Layer</Label>
|
||||
<H3 className="text-3xl md:text-7xl tracking-tighter text-slate-900 leading-[1.1]">
|
||||
Direkter Draht.
|
||||
</H3>
|
||||
<BodyText className="text-lg md:text-xl text-slate-500 font-serif italic leading-relaxed">
|
||||
Das Kontakt-System wurde auf maximale Reduktion getrimmt. Ein
|
||||
deterministischer Kanal zwischen technischem Bedarf und
|
||||
individueller Beratung – ohne Umwege, ohne Rauschen.
|
||||
</BodyText>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={0.2} direction="left" blur>
|
||||
<div className="grid grid-cols-2 gap-8 border-t border-slate-100 pt-10">
|
||||
<div className="space-y-2">
|
||||
<MonoLabel className="text-slate-400 text-[9px]">
|
||||
RESPONSE_TIME
|
||||
</MonoLabel>
|
||||
<div className="text-xl font-bold text-slate-900 font-mono">
|
||||
< 120ms
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<MonoLabel className="text-slate-400 text-[9px]">
|
||||
PROTOCOL
|
||||
</MonoLabel>
|
||||
<div className="text-xl font-bold text-slate-900 font-mono">
|
||||
mTLS
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<MonoLabel className="text-slate-400 text-[9px]">
|
||||
AVAILABILITY
|
||||
</MonoLabel>
|
||||
<div className="text-xl font-bold text-[rgba(129,199,132,1)] font-mono">
|
||||
99.9%
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<MonoLabel className="text-slate-400 text-[9px]">
|
||||
ENCRYPTION
|
||||
</MonoLabel>
|
||||
<div className="text-xl font-bold text-slate-900 font-mono">
|
||||
AES-256
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-7">
|
||||
<Reveal direction="right" scale={0.98} blur>
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.002 }}
|
||||
transition={{ type: "spring", stiffness: 100, damping: 30 }}
|
||||
className="relative rounded-[2.5rem] overflow-hidden shadow-2xl shadow-slate-200/50 ring-1 ring-slate-100"
|
||||
>
|
||||
<IframeSection
|
||||
src="/showcase/klz-cables.com/contact.html"
|
||||
height="450px"
|
||||
mobileHeight="400px"
|
||||
desktopHeight="750px"
|
||||
desktopWidth={1200}
|
||||
mobileWidth={390}
|
||||
allowScroll
|
||||
browserFrame
|
||||
className="w-full no-scrollbar"
|
||||
/>
|
||||
</motion.div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
{/* --- FINAL CTA: ARCHITECTURE & VALUE --- */}
|
||||
<section className="py-40 md:py-64 bg-white relative overflow-hidden border-t border-slate-100">
|
||||
<BackgroundGrid />
|
||||
<Container variant="normal" className="relative z-10">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-24 items-center text-left">
|
||||
<div className="space-y-8 md:space-y-12">
|
||||
<Reveal direction="left" blur>
|
||||
<div className="space-y-3 md:space-y-6">
|
||||
<MonoLabel className="text-slate-400 tracking-[0.2em] md:tracking-[0.4em]">
|
||||
CONSULTING // ENGINEERING
|
||||
</MonoLabel>
|
||||
<H2 className="text-4xl md:text-8xl tracking-tighter leading-none font-bold">
|
||||
Architektur <br />
|
||||
<span className="text-slate-100">ohne Altlasten.</span>
|
||||
</H2>
|
||||
</div>
|
||||
</Reveal>
|
||||
<Reveal delay={0.2} direction="left" blur>
|
||||
<BodyText className="text-xl md:text-2xl text-slate-500 max-w-xl font-serif italic leading-relaxed">
|
||||
Vom Prototyp zum industriellen Standard. Ich entwickle
|
||||
digitale Infrastrukturen, die technische Freiheit und
|
||||
operative Stabilität garantieren – wartungsfrei und
|
||||
skalierbar.
|
||||
</BodyText>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-50/80 backdrop-blur-sm border border-slate-100 p-6 md:p-14 rounded-[3.5rem] space-y-10 md:space-y-12 relative group shadow-sm">
|
||||
<Reveal direction="right" blur>
|
||||
<div className="inline-flex items-center gap-2 md:gap-3 px-3 py-1.5 md:px-4 md:py-2 bg-white rounded-full border border-slate-200 mb-4 md:mb-8 font-mono text-[9px] md:text-[10px] tracking-widest text-slate-500 uppercase">
|
||||
<div className="w-1.5 h-1.5 bg-green-400 rounded-full animate-pulse" />
|
||||
Operational Excellence
|
||||
</div>
|
||||
<div className="space-y-8 md:space-y-10">
|
||||
{[
|
||||
{
|
||||
title: "Hardened Infrastructure",
|
||||
desc: "Zentralisierte Datenpflege und entkoppelte WordPress-Instanzen.",
|
||||
},
|
||||
{
|
||||
title: "Automated Data Pipelines",
|
||||
desc: "Validierung technischer Spezifikationen ohne manuelle Eingriffe.",
|
||||
},
|
||||
{
|
||||
title: "Maintenance-Free Core",
|
||||
desc: "Plugin-freie Logik für deterministische System-Sicherheit.",
|
||||
},
|
||||
].map((item, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="space-y-2 md:space-y-3 group/item text-left"
|
||||
>
|
||||
<MonoLabel className="text-[9px] md:text-[10px] text-slate-400 group-hover/item:text-slate-900 transition-colors duration-500">
|
||||
{item.title}
|
||||
</MonoLabel>
|
||||
<BodyText className="text-base md:text-lg font-bold text-slate-900 leading-tight">
|
||||
{item.desc}
|
||||
</BodyText>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Reveal>
|
||||
<Reveal delay={0.5} direction="up" blur className="pt-6">
|
||||
<Button
|
||||
href="/contact"
|
||||
variant="outline"
|
||||
showArrow={false}
|
||||
className="w-full py-6 md:py-8 text-base md:text-lg group border-2 border-slate-900 rounded-full bg-white hover:bg-slate-900 hover:text-white transition-all duration-700"
|
||||
>
|
||||
Jetzt anfragen
|
||||
<ArrowRight className="inline-block ml-3 md:ml-4 w-5 h-5 md:w-6 md:h-6 group-hover:translate-x-4 transition-transform duration-700" />
|
||||
</Button>
|
||||
</Reveal>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { Section } from "@/src/components/Section";
|
||||
import { Reveal } from "@/src/components/Reveal";
|
||||
import { H3, LeadText, Label, BodyText } from "@/src/components/Typography";
|
||||
import { Card } from "@/src/components/Layout";
|
||||
import { Button } from "@/src/components/Button";
|
||||
import { AbstractCircuit } from "@/src/components/Effects";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export default function CaseStudiesPage() {
|
||||
return (
|
||||
<div className="flex flex-col bg-white overflow-hidden relative">
|
||||
<AbstractCircuit />
|
||||
|
||||
{/* Featured Case Study Hero */}
|
||||
<Section className="pt-24 pb-12 md:pt-40 md:pb-24">
|
||||
<div className="space-y-12 md:space-y-24">
|
||||
<Reveal>
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
<H3 className="text-4xl md:text-8xl tracking-tighter leading-none">
|
||||
Case <span className="text-slate-400">Studies.</span>
|
||||
</H3>
|
||||
<LeadText className="text-lg md:text-2xl text-slate-400 max-w-2xl">
|
||||
Ergebnisse statt Versprechen. Dokumentierte Architektur-Lösungen
|
||||
für komplexe Anforderungen.
|
||||
</LeadText>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal>
|
||||
<a href="/case-studies/klz-cables" className="block group">
|
||||
<Card
|
||||
variant="glass"
|
||||
padding="none"
|
||||
techBorder
|
||||
className="overflow-hidden relative group min-h-[400px] md:min-h-[500px] flex flex-col md:flex-row"
|
||||
>
|
||||
{/* Brand Gradient Background */}
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_30%_30%,rgba(14,165,233,0.08)_0%,transparent_50%),radial-gradient(circle_at_70%_70%,rgba(99,102,241,0.05)_0%,transparent_50%)]" />
|
||||
|
||||
{/* Left Column: Content */}
|
||||
<div className="flex-1 p-4 md:p-12 relative z-10 flex flex-col justify-between">
|
||||
<div className="space-y-4 md:space-y-8">
|
||||
<div className="flex items-center gap-3 md:gap-4">
|
||||
<Image
|
||||
src="/showcase/klz-cables.com/assets/klz-cables.com/wp-content/uploads/2024/11/white_logo_transparent_background.svg"
|
||||
alt="KLZ Logo"
|
||||
width={32}
|
||||
height={32}
|
||||
className="h-6 md:h-8 w-auto invert opacity-80 group-hover:opacity-100 transition-opacity duration-500"
|
||||
/>
|
||||
<div className="h-px w-8 md:w-12 bg-slate-100" />
|
||||
<Label className="text-slate-400 text-[9px] md:text-[10px]">
|
||||
Case Study 2025
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 md:space-y-4">
|
||||
<H3 className="text-3xl md:text-6xl tracking-tighter">
|
||||
KLZ <span className="text-slate-300">Cables</span>
|
||||
</H3>
|
||||
<LeadText className="text-slate-500 text-base md:text-xl max-w-xl leading-relaxed">
|
||||
Engineering eines industriellen B2B-Systems mit
|
||||
<span className="text-slate-900 font-medium">
|
||||
{" "}
|
||||
automatisierter Asset-Pipeline
|
||||
</span>{" "}
|
||||
und hochperformantem Headless-Stack.
|
||||
</LeadText>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 pt-1 md:pt-2">
|
||||
{["Next.js", "Varnish", "Asset Pipeline", "B2B DB"].map(
|
||||
(tag, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="px-2 py-0.5 md:px-2.5 md:py-1 border border-slate-100 bg-white/50 rounded-md text-[8px] md:text-[9px] font-mono text-slate-400 uppercase tracking-widest group-hover:border-slate-300 transition-colors duration-500"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-8 md:pt-12">
|
||||
<div className="inline-flex items-center gap-2 md:gap-3 text-[10px] md:text-sm font-bold text-slate-400 group-hover:text-slate-900 transition-all duration-500">
|
||||
<span>EXPLORE PROJECT</span>
|
||||
<ArrowRight className="w-4 h-4 group-hover:translate-x-2 transition-transform duration-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Visual/Technical Decor */}
|
||||
<div className="w-full md:w-1/3 min-h-[150px] md:min-h-0 bg-slate-50 relative overflow-hidden border-t md:border-t-0 md:border-l border-slate-100">
|
||||
<div className="absolute inset-0 opacity-[0.03] select-none pointer-events-none font-mono text-[6px] md:text-[8px] p-4 flex flex-col gap-1 overflow-hidden">
|
||||
{Array.from({ length: 40 }).map((_, i) => (
|
||||
<div key={i} className="whitespace-nowrap">
|
||||
{Array.from({ length: 15 })
|
||||
.map((_, j) => (
|
||||
<span
|
||||
key={j}
|
||||
className={
|
||||
Math.random() > 0.5
|
||||
? "text-slate-900"
|
||||
: "text-slate-400"
|
||||
}
|
||||
>
|
||||
{Math.floor(Math.random() * 2)}
|
||||
</span>
|
||||
))
|
||||
.join(" ")}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Abstract "Cable" lines */}
|
||||
<div className="absolute inset-0 flex items-center justify-center p-8 md:p-12">
|
||||
<div className="w-full h-full relative">
|
||||
{[1, 2, 3].map((v) => (
|
||||
<motion.div
|
||||
key={v}
|
||||
initial={{ scaleY: 0 }}
|
||||
whileInView={{ scaleY: 1 }}
|
||||
transition={{ duration: 2, delay: 0.5 + v * 0.2 }}
|
||||
className="absolute inset-y-0 border-r border-slate-200 origin-top"
|
||||
style={{ right: `${v * 25}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-4 right-4 md:bottom-8 md:right-8 text-[8px] md:text-[10px] font-mono text-slate-300 rotate-90 origin-right uppercase tracking-[0.3em]">
|
||||
Industrial Grade
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</a>
|
||||
</Reveal>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Coming Soon */}
|
||||
<Section number="02" title="Kommt bald" borderTop>
|
||||
<Reveal>
|
||||
<Card
|
||||
variant="glass"
|
||||
padding="large"
|
||||
techBorder
|
||||
className="text-center relative overflow-hidden group"
|
||||
>
|
||||
<div className="relative z-10 space-y-4 py-4 md:py-8">
|
||||
<div className="flex items-center justify-center gap-2 md:gap-3">
|
||||
<div className="w-2 h-2 rounded-full bg-slate-300 animate-pulse" />
|
||||
<Label className="text-slate-400">In Arbeit</Label>
|
||||
</div>
|
||||
<H3 className="text-2xl md:text-3xl text-slate-400">
|
||||
Weitere Case Studies in Kürze.
|
||||
</H3>
|
||||
<BodyText className="text-slate-400 max-w-md mx-auto">
|
||||
Ich dokumentiere laufende Projekte – schauen Sie bald wieder
|
||||
vorbei oder kontaktieren Sie mich direkt.
|
||||
</BodyText>
|
||||
<div className="pt-4">
|
||||
<Button href="/contact" variant="outline">
|
||||
Kontakt aufnehmen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Reveal>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { ImageResponse } from "next/og";
|
||||
import { OGImageTemplate } from "../../../src/components/OGImageTemplate";
|
||||
import { getOgFonts, OG_IMAGE_SIZE } from "../../../src/lib/og-helper";
|
||||
|
||||
export const size = OG_IMAGE_SIZE;
|
||||
export const contentType = "image/png";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export default async function Image() {
|
||||
const fonts = await getOgFonts();
|
||||
|
||||
return new ImageResponse(
|
||||
<OGImageTemplate
|
||||
title="Kontakt."
|
||||
description="Bereit für eine Zusammenarbeit? Lassen Sie uns gemeinsam etwas bauen, das wirklich funktioniert."
|
||||
label="Get in touch"
|
||||
/>,
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts: fonts as any,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Section } from "@/src/components/Section";
|
||||
import { ContactForm } from "@/src/components/ContactForm";
|
||||
import { AbstractCircuit } from "@/src/components/Effects";
|
||||
|
||||
export default function ContactPage() {
|
||||
return (
|
||||
<div className="flex flex-col bg-white min-h-screen overflow-hidden relative">
|
||||
<AbstractCircuit />
|
||||
|
||||
<Section
|
||||
containerVariant="wide"
|
||||
effects={<></>}
|
||||
className="pt-24 pb-12 md:pt-32 md:pb-20"
|
||||
>
|
||||
{/* Full-width Form */}
|
||||
<ContactForm />
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter, Newsreader } from "next/font/google";
|
||||
import { Analytics } from "@/src/components/Analytics";
|
||||
import { Footer } from "@/src/components/Footer";
|
||||
import { Header } from "@/src/components/Header";
|
||||
import { InteractiveElements } from "@/src/components/InteractiveElements";
|
||||
import "./globals.css";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
|
||||
const newsreader = Newsreader({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-newsreader",
|
||||
style: "italic",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: "Marc Mintel",
|
||||
template: "%s | Marc Mintel",
|
||||
},
|
||||
description:
|
||||
"Technical problem solver's blog - practical insights and learning notes",
|
||||
metadataBase: new URL("https://mintel.me"),
|
||||
icons: {
|
||||
icon: "/favicon.svg",
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" className={`${inter.variable} ${newsreader.variable}`}>
|
||||
<body className="min-h-screen bg-white">
|
||||
<Header />
|
||||
<main>{children}</main>
|
||||
<Footer />
|
||||
<InteractiveElements />
|
||||
<Analytics />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { ImageResponse } from "next/og";
|
||||
import { OGImageTemplate } from "../../src/components/OGImageTemplate";
|
||||
import { getOgFonts, OG_IMAGE_SIZE } from "../../src/lib/og-helper";
|
||||
|
||||
export const size = OG_IMAGE_SIZE;
|
||||
export const contentType = "image/png";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export default async function Image() {
|
||||
const fonts = await getOgFonts();
|
||||
|
||||
return new ImageResponse(
|
||||
<OGImageTemplate
|
||||
title="Marc Mintel"
|
||||
description="Technical problem solver's blog - practical insights and learning notes"
|
||||
label="Engineering"
|
||||
/>,
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts: fonts as any,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,327 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ComparisonRow,
|
||||
ConceptCode,
|
||||
ConceptCommunication,
|
||||
ConceptPrice,
|
||||
ConceptPrototyping,
|
||||
ConceptWebsite,
|
||||
} from "@/src/components/Landing";
|
||||
import { Reveal } from "@/src/components/Reveal";
|
||||
import { Section } from "@/src/components/Section";
|
||||
import {
|
||||
H1,
|
||||
H3,
|
||||
LeadText,
|
||||
BodyText,
|
||||
MonoLabel,
|
||||
Label,
|
||||
} from "@/src/components/Typography";
|
||||
import { Card, Container } from "@/src/components/Layout";
|
||||
import { Button } from "@/src/components/Button";
|
||||
import { GradientMesh, CodeSnippet } from "@/src/components/Effects";
|
||||
import { IconList, IconListItem } from "@/src/components/IconList";
|
||||
import { HeroSection } from "@/src/components/HeroSection";
|
||||
import { GlitchText } from "@/src/components/GlitchText";
|
||||
import { Marker } from "@/src/components/Marker";
|
||||
import { PenCircle } from "@/src/components/PenCircle";
|
||||
|
||||
export default function LandingPage() {
|
||||
return (
|
||||
<div className="flex flex-col bg-white overflow-hidden relative">
|
||||
{/* Dark Hero */}
|
||||
<HeroSection />
|
||||
|
||||
{/* Rest of page on white */}
|
||||
|
||||
{/* Section 02: The Promise – Streamlined */}
|
||||
<Section
|
||||
number="02"
|
||||
title="Das Versprechen"
|
||||
borderTop
|
||||
effects={<GradientMesh variant="metallic" className="opacity-70" />}
|
||||
>
|
||||
<div className="space-y-10 md:space-y-16 relative">
|
||||
<Reveal>
|
||||
<H3 className="max-w-3xl">
|
||||
Kein Agentur-Zirkus. <br />
|
||||
<Marker delay={0.3}>Ergebnisse.</Marker>
|
||||
</H3>
|
||||
</Reveal>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-12 relative z-10">
|
||||
{[
|
||||
{
|
||||
icon: <ConceptCommunication className="w-8 h-8" />,
|
||||
title: "Direkte Kommunikation",
|
||||
text: "Sie sprechen mit dem Entwickler. Keine Stille Post, keine Umwege.",
|
||||
},
|
||||
{
|
||||
icon: <ConceptPrototyping className="w-8 h-8" />,
|
||||
title: "Schnelle Umsetzung",
|
||||
text: "Sichtbare Fortschritte in Tagen. Prototypen statt Konzeptpapiere.",
|
||||
},
|
||||
{
|
||||
icon: <ConceptCode className="w-8 h-8" />,
|
||||
title: "Sauberer Code",
|
||||
text: "Maßgeschneiderte Architektur. Kein Baukasten, kein Plugin-Chaos.",
|
||||
},
|
||||
{
|
||||
icon: <ConceptPrice className="w-8 h-8" />,
|
||||
title: "Klare Fixpreise",
|
||||
text: "Volle Budgetsicherheit. Keine versteckten Kosten.",
|
||||
},
|
||||
].map((item, i) => (
|
||||
<Reveal key={i} delay={0.1 + i * 0.1}>
|
||||
<Card
|
||||
variant="glass"
|
||||
padding="normal"
|
||||
techBorder
|
||||
className="group"
|
||||
>
|
||||
<div className="space-y-4 relative z-10">
|
||||
<div className="w-12 h-12 rounded-xl bg-slate-50 border border-slate-100 flex items-center justify-center group-hover:scale-110 transition-transform duration-500">
|
||||
{item.icon}
|
||||
</div>
|
||||
<Label className="text-slate-900">{item.title}</Label>
|
||||
<BodyText className="text-slate-500">{item.text}</BodyText>
|
||||
</div>
|
||||
</Card>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Section 03: The Difference – Visual Comparison */}
|
||||
<Section number="03" title="Der Unterschied" variant="white" borderTop>
|
||||
<div className="space-y-10 md:space-y-16 relative">
|
||||
<Reveal>
|
||||
<H3 className="max-w-3xl">
|
||||
Ich arbeite für das Ergebnis, <br />
|
||||
nicht gegen die <Marker delay={0.4}>Uhr.</Marker>
|
||||
</H3>
|
||||
</Reveal>
|
||||
|
||||
<div className="grid grid-cols-1 gap-8 relative z-20">
|
||||
<ComparisonRow
|
||||
negativeLabel="Klassisch"
|
||||
negativeText="Wochen in Planung, bevor eine einzige Zeile Code geschrieben wird."
|
||||
positiveLabel="Mein Weg"
|
||||
positiveText={
|
||||
<>
|
||||
Schnelle Prototypen. Ergebnisse in{" "}
|
||||
<PenCircle delay={0.5}>Tagen</PenCircle>, nicht Monaten.
|
||||
</>
|
||||
}
|
||||
delay={0.1}
|
||||
/>
|
||||
<ComparisonRow
|
||||
negativeLabel="Klassisch"
|
||||
negativeText="Unvorhersehbare Kosten durch Stundenabrechnungen."
|
||||
positiveLabel="Mein Weg"
|
||||
positiveText={
|
||||
<>
|
||||
<PenCircle delay={0.5}>Fixpreise.</PenCircle> Sie wissen von
|
||||
Anfang an, was es kostet.
|
||||
</>
|
||||
}
|
||||
reverse
|
||||
delay={0.2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Section 04: Target Group */}
|
||||
<Section number="04" title="Für wen" borderTop>
|
||||
<div className="grid grid-cols-1 md:grid-cols-1 lg:grid-cols-2 gap-4 md:gap-6 lg:gap-10 relative z-10">
|
||||
<Reveal>
|
||||
<Card variant="glass" padding="normal" techBorder className="group">
|
||||
<div className="space-y-4 md:space-y-6 relative overflow-hidden">
|
||||
<div className="w-12 h-12 md:w-16 md:h-16 bg-slate-50 border border-slate-100 rounded-xl flex items-center justify-center">
|
||||
<ConceptPrice className="w-6 h-6 md:w-8 md:h-8" />
|
||||
</div>
|
||||
<H3 className="text-xl md:text-3xl">
|
||||
Unternehmer & <br />
|
||||
Geschäftsführer
|
||||
</H3>
|
||||
<LeadText className="text-slate-400 text-base md:text-lg">
|
||||
Sie wollen eine Website, die funktioniert – ohne sich mit
|
||||
Technik beschäftigen zu müssen.
|
||||
</LeadText>
|
||||
</div>
|
||||
<div className="pt-6 md:pt-8 border-t border-slate-50 mt-6 md:mt-8">
|
||||
<Label className="group-hover:text-slate-900 transition-colors">
|
||||
Perfekt für Sie
|
||||
</Label>
|
||||
</div>
|
||||
</Card>
|
||||
</Reveal>
|
||||
<Reveal delay={0.2}>
|
||||
<Card variant="glass" padding="normal" techBorder className="group">
|
||||
<div className="space-y-6 relative overflow-hidden">
|
||||
<div className="w-12 h-12 md:w-16 md:h-16 bg-slate-50 border border-slate-100 rounded-xl flex items-center justify-center">
|
||||
{/* Icon placeholder or same as above if needed */}
|
||||
<ConceptWebsite className="w-6 h-6 md:w-8 md:h-8" />
|
||||
</div>
|
||||
<H3 className="text-xl md:text-3xl">
|
||||
Marketing & <br />
|
||||
Vertrieb
|
||||
</H3>
|
||||
<LeadText className="text-slate-400 text-base md:text-lg">
|
||||
Sie brauchen Landingpages und Tools, die Ergebnisse liefern.
|
||||
Schnell und zuverlässig.
|
||||
</LeadText>
|
||||
</div>
|
||||
<div className="pt-6 md:pt-8 border-t border-slate-50 mt-6 md:mt-8">
|
||||
<Label className="group-hover:text-slate-900 transition-colors">
|
||||
Perfekt für Sie
|
||||
</Label>
|
||||
</div>
|
||||
</Card>
|
||||
</Reveal>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Section 05: Leistungen — Interactive Service Rows */}
|
||||
<Section number="05" title="Leistungen" variant="gray" borderTop>
|
||||
<div className="space-y-0 relative z-20">
|
||||
{[
|
||||
{
|
||||
num: "01",
|
||||
binary: "00000001",
|
||||
title: "Websites",
|
||||
text: "High-Performance Websites mit maßgeschneiderter Architektur. Von der Konzeption bis zum Go-Live — individuell, schnell, messbar.",
|
||||
tags: ["Next.js", "React", "TypeScript", "Performance"],
|
||||
href: "/websites",
|
||||
},
|
||||
{
|
||||
num: "02",
|
||||
binary: "00000010",
|
||||
title: "Systeme",
|
||||
text: "Web-Applikationen und interne Tools, wenn Standard-Software nicht reicht. Dashboards, Portale, Automatisierungen.",
|
||||
tags: ["Full-Stack", "APIs", "Datenbanken", "Auth"],
|
||||
href: "/contact",
|
||||
},
|
||||
{
|
||||
num: "03",
|
||||
binary: "00000011",
|
||||
title: "Automatisierung",
|
||||
text: "Verbindung von Tools, automatische Prozesse, Daten-Synchronisation. Weniger manuelle Arbeit, mehr Effizienz.",
|
||||
tags: ["CI/CD", "Workflows", "Integrationen", "Monitoring"],
|
||||
href: "/contact",
|
||||
},
|
||||
].map((service, i) => (
|
||||
<Reveal key={i} delay={0.1 + i * 0.15}>
|
||||
<div className="group py-8 md:py-12 lg:py-16 border-b border-slate-100 last:border-b-0 cursor-pointer transition-all duration-500">
|
||||
<div className="flex flex-col md:flex-row md:items-start gap-6 md:gap-10 lg:gap-16">
|
||||
{/* Number + Binary */}
|
||||
<div className="shrink-0 flex md:block items-baseline gap-4">
|
||||
<span className="text-4xl md:text-6xl font-black text-slate-100 group-hover:text-slate-200 transition-colors duration-500 tracking-tighter block leading-none">
|
||||
{service.num}
|
||||
</span>
|
||||
<span
|
||||
className="text-[8px] font-mono text-slate-200 tracking-[0.3em] mt-2 block select-none group-hover:text-blue-300 transition-colors duration-700 leading-none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{service.binary}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 space-y-4 md:space-y-6">
|
||||
<H3 className="text-xl md:text-4xl group-hover:translate-x-2 transition-transform duration-500">
|
||||
<GlitchText
|
||||
trigger="inView"
|
||||
delay={0.2 + i * 0.15}
|
||||
duration={0.6}
|
||||
>
|
||||
{service.title}
|
||||
</GlitchText>
|
||||
</H3>
|
||||
<BodyText className="text-slate-400 text-sm md:text-base max-w-xl group-hover:text-slate-500 transition-colors duration-500">
|
||||
{service.text}
|
||||
</BodyText>
|
||||
{/* Tags */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{service.tags.map((tag, j) => (
|
||||
<span
|
||||
key={j}
|
||||
className="px-3 py-1 text-[8px] md:text-[9px] font-mono uppercase tracking-widest text-slate-400 border border-slate-100 rounded-full bg-white/50 group-hover:border-slate-200 group-hover:text-slate-500 transition-all duration-500"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Arrow */}
|
||||
<div className="md:self-center shrink-0 pt-4 md:pt-0">
|
||||
<Button
|
||||
href={service.href}
|
||||
variant="ghost"
|
||||
size="normal"
|
||||
showArrow
|
||||
className="w-full md:w-auto"
|
||||
>
|
||||
Details
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Section 06: Contact */}
|
||||
<Section number="06" title="Kontakt" borderTop>
|
||||
<div className="relative py-4 md:py-12" id="contact">
|
||||
<Reveal>
|
||||
<div className="space-y-8 md:space-y-12 lg:space-y-16">
|
||||
<H1 className="text-4xl md:text-6xl lg:text-8xl">
|
||||
Lassen Sie uns <br />
|
||||
<span className="text-slate-400">starten.</span>
|
||||
</H1>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-6 md:gap-10 lg:gap-16 items-start relative z-10">
|
||||
<div className="space-y-4 md:space-y-8 flex-1">
|
||||
<LeadText className="text-lg md:text-3xl text-slate-400">
|
||||
Beschreiben Sie kurz Ihr Vorhaben. Ich melde mich{" "}
|
||||
<span className="text-slate-900 border-b-2 border-slate-900/10">
|
||||
<Marker>zeitnah</Marker>
|
||||
</span>{" "}
|
||||
bei Ihnen.
|
||||
</LeadText>
|
||||
<div className="pt-2 md:pt-4">
|
||||
<Button
|
||||
href="/contact"
|
||||
size="large"
|
||||
className="w-full md:w-auto"
|
||||
>
|
||||
Projekt anfragen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-72 space-y-4 md:space-y-6 p-6 glass rounded-2xl border border-slate-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||
<Label className="text-slate-900">Verfügbarkeit</Label>
|
||||
</div>
|
||||
<BodyText className="text-sm md:text-base leading-snug">
|
||||
Aktuell nehme ich Projekte für{" "}
|
||||
<span className="font-bold text-slate-900">Q2 2026</span>{" "}
|
||||
an.
|
||||
</BodyText>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { MetadataRoute } from "next";
|
||||
import { getAllPosts } from "@/src/lib/posts";
|
||||
import { technologies } from "./technologies/[slug]/data";
|
||||
|
||||
/**
|
||||
* Sitemap Generator
|
||||
*
|
||||
* Standard Next.js 15 App Router sitemap generation.
|
||||
* This file dynamically generates /sitemap.xml
|
||||
*/
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const allPosts = await getAllPosts();
|
||||
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://mintel.me";
|
||||
|
||||
// 1. Core Pages
|
||||
const routes = [
|
||||
"",
|
||||
"/about",
|
||||
"/blog",
|
||||
"/case-studies",
|
||||
"/case-studies/klz-cables",
|
||||
"/contact",
|
||||
"/websites",
|
||||
].map((route) => ({
|
||||
url: `${baseUrl}${route}`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "monthly" as const,
|
||||
priority: route === "" ? 1.0 : 0.8,
|
||||
}));
|
||||
|
||||
// 2. Dynamic Blog Posts
|
||||
const blogRoutes = allPosts.map((post) => ({
|
||||
url: `${baseUrl}/blog/${post.slug}`,
|
||||
lastModified: new Date(post.date),
|
||||
changeFrequency: "monthly" as const,
|
||||
priority: 0.7,
|
||||
}));
|
||||
|
||||
// 3. Technology Detail Pages
|
||||
const techRoutes = Object.keys(technologies).map((slug) => ({
|
||||
url: `${baseUrl}/technologies/${slug}`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "monthly" as const,
|
||||
priority: 0.6,
|
||||
}));
|
||||
|
||||
// 4. Tag Pages
|
||||
const allTags = [...new Set(allPosts.flatMap((post) => post.tags))];
|
||||
const tagRoutes = allTags.map((tag) => ({
|
||||
url: `${baseUrl}/tags/${tag}`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "weekly" as const,
|
||||
priority: 0.3,
|
||||
}));
|
||||
|
||||
return [...routes, ...blogRoutes, ...techRoutes, ...tagRoutes];
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
/**
|
||||
* Smart Proxy for Umami Analytics.
|
||||
*
|
||||
* This Route Handler receives tracking events from the browser,
|
||||
* injects the secret UMAMI_WEBSITE_ID, and forwards them to the
|
||||
* internal Umami API endpoint.
|
||||
*
|
||||
* This ensures:
|
||||
* 1. The Website ID is NOT leaked to the client bundle.
|
||||
* 2. The Umami API endpoint is hidden behind our domain.
|
||||
* 3. We have full control over the tracking data.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { type, payload } = body;
|
||||
|
||||
// Inject the secret websiteId from server config
|
||||
const websiteId = env.UMAMI_WEBSITE_ID || env.NEXT_PUBLIC_UMAMI_WEBSITE_ID;
|
||||
|
||||
if (!websiteId) {
|
||||
console.warn(
|
||||
"Umami tracking received but no Website ID configured on server",
|
||||
);
|
||||
return NextResponse.json({ status: "ignored" }, { status: 200 });
|
||||
}
|
||||
|
||||
// Prepare the enhanced payload with the secret ID
|
||||
const enhancedPayload = {
|
||||
...payload,
|
||||
website: websiteId,
|
||||
};
|
||||
|
||||
const umamiEndpoint = env.UMAMI_API_ENDPOINT;
|
||||
|
||||
// Log the event (debug only)
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.debug("Forwarding analytics event", {
|
||||
type,
|
||||
url: payload.url,
|
||||
website: websiteId.slice(0, 8) + "...",
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch(`${umamiEndpoint}/api/send`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": request.headers.get("user-agent") || "Mintel-Smart-Proxy",
|
||||
"X-Forwarded-For": request.headers.get("x-forwarded-for") || "",
|
||||
},
|
||||
body: JSON.stringify({ type, payload: enhancedPayload }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error("Umami API responded with error", {
|
||||
status: response.status,
|
||||
error: errorText.slice(0, 100),
|
||||
});
|
||||
return new NextResponse(errorText, { status: response.status });
|
||||
}
|
||||
|
||||
return NextResponse.json({ status: "ok" });
|
||||
} catch (error) {
|
||||
console.error("Failed to proxy analytics request", {
|
||||
error: (error as Error).message,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: "Internal Server Error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { getAllPosts } from "@/src/lib/posts";
|
||||
import { MediumCard } from "@/src/components/MediumCard";
|
||||
import { Reveal } from "@/src/components/Reveal";
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const allPosts = await getAllPosts();
|
||||
const allTags = Array.from(
|
||||
new Set(allPosts.flatMap((post) => post.tags || [])),
|
||||
);
|
||||
return allTags.map((tag) => ({
|
||||
tag,
|
||||
}));
|
||||
}
|
||||
|
||||
export default async function TagPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ tag: string }>;
|
||||
}) {
|
||||
const { tag } = await params;
|
||||
const allPosts = await getAllPosts();
|
||||
const posts = allPosts.filter((post) => post.tags?.includes(tag));
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-4 py-8">
|
||||
<header className="mb-8">
|
||||
<Reveal>
|
||||
<h1 className="text-3xl font-bold text-slate-900 mb-2">
|
||||
Posts tagged{" "}
|
||||
<span className="highlighter-yellow px-2 rounded">{tag}</span>
|
||||
</h1>
|
||||
</Reveal>
|
||||
<Reveal delay={0.1}>
|
||||
<p className="text-slate-600">
|
||||
{posts.length} post{posts.length === 1 ? "" : "s"}
|
||||
</p>
|
||||
</Reveal>
|
||||
</header>
|
||||
|
||||
<div className="space-y-4">
|
||||
{posts.map((post, i) => (
|
||||
<Reveal key={post.slug} delay={0.1 + i * 0.05} width="100%">
|
||||
<MediumCard post={post} />
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Reveal delay={0.3}>
|
||||
<div className="mt-8 pt-6 border-t border-slate-200">
|
||||
<Link
|
||||
href="/blog"
|
||||
className="text-slate-600 hover:text-slate-900 inline-flex items-center"
|
||||
>
|
||||
← Back to blog
|
||||
</Link>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { Container } from "@/src/components/Layout";
|
||||
import { Label } from "@/src/components/Typography";
|
||||
import { Check, ArrowLeft, Zap, ExternalLink } from "lucide-react";
|
||||
import { technologies } from "./data";
|
||||
import { Reveal } from "@/src/components/Reveal";
|
||||
|
||||
export default function TechnologyContent({ slug }: { slug: string }) {
|
||||
const tech = technologies[slug];
|
||||
|
||||
if (!tech) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold mb-4">Technology Not Found</h1>
|
||||
<Link href="/" className="text-blue-600 hover:underline">
|
||||
Return Home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Icon = tech.icon;
|
||||
|
||||
return (
|
||||
<div className="bg-white min-h-screen text-slate-900 pb-24">
|
||||
<div className="bg-slate-50 border-b border-slate-200">
|
||||
<Container className="py-24">
|
||||
<Reveal>
|
||||
<Link
|
||||
href="/case-studies/klz-cables"
|
||||
className="inline-flex items-center text-sm font-bold text-slate-500 hover:text-slate-900 mb-8 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" /> Back to Case Study
|
||||
</Link>
|
||||
</Reveal>
|
||||
|
||||
<div className="flex flex-col md:flex-row items-start gap-8">
|
||||
<Reveal>
|
||||
<div className={`p-6 rounded-2xl shadow-lg ${tech.color}`}>
|
||||
<Icon className="w-12 h-12" />
|
||||
</div>
|
||||
</Reveal>
|
||||
<div className="flex-1">
|
||||
<Reveal delay={0.1}>
|
||||
<Label className="text-slate-400 mb-2">
|
||||
TECHNOLOGY DEEP DIVE
|
||||
</Label>
|
||||
</Reveal>
|
||||
<Reveal delay={0.2}>
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tight mb-4">
|
||||
{tech.title}
|
||||
</h1>
|
||||
</Reveal>
|
||||
<Reveal delay={0.3}>
|
||||
<p className="text-xl text-slate-500 font-medium">
|
||||
{tech.subtitle}
|
||||
</p>
|
||||
</Reveal>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
|
||||
<Container className="py-16">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-16">
|
||||
<div className="lg:col-span-8 space-y-12">
|
||||
<section>
|
||||
<Reveal>
|
||||
<h2 className="text-2xl font-bold mb-4">What is it?</h2>
|
||||
<p className="text-xl leading-relaxed text-slate-700">
|
||||
{tech.description}
|
||||
</p>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<Reveal>
|
||||
<h2 className="text-2xl font-bold mb-6 flex items-center gap-3">
|
||||
<Zap className="w-6 h-6 text-amber-500" /> Why I use it
|
||||
</h2>
|
||||
</Reveal>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{tech.benefits.map((benefit, i) => (
|
||||
<Reveal key={i} delay={0.1 + i * 0.05}>
|
||||
<div className="flex gap-3 p-4 bg-slate-50 rounded-xl border border-slate-100 h-full">
|
||||
<Check className="w-5 h-5 text-green-600 shrink-0" />
|
||||
<span className="font-medium text-slate-700">
|
||||
{benefit}
|
||||
</span>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Reveal delay={0.4}>
|
||||
<section className="bg-blue-50 border border-blue-100 rounded-2xl p-8">
|
||||
<Label className="text-blue-600 mb-2">CUSTOMER IMPACT</Label>
|
||||
<h3 className="text-2xl font-bold text-slate-900 mb-4">
|
||||
What does this mean for you?
|
||||
</h3>
|
||||
<p className="text-lg text-slate-700 leading-relaxed">
|
||||
{tech.customerValue}
|
||||
</p>
|
||||
</section>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-4 space-y-8">
|
||||
<Reveal delay={0.5}>
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-2xl p-6 sticky top-24">
|
||||
<h3 className="font-bold text-slate-900 mb-4">
|
||||
Related Technologies
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{tech.related.map((item) => (
|
||||
<Link
|
||||
key={item.slug}
|
||||
href={`/technologies/${item.slug}`}
|
||||
className="flex items-center justify-between p-3 bg-white border border-slate-200 rounded-lg hover:border-slate-400 hover:shadow-sm transition-all group"
|
||||
>
|
||||
<span className="font-medium text-slate-700 group-hover:text-slate-900">
|
||||
{item.name}
|
||||
</span>
|
||||
<ExternalLink className="w-4 h-4 text-slate-400 group-hover:text-slate-600" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import { Layers, Code, Database, Palette, Terminal } from "lucide-react";
|
||||
|
||||
export interface TechInfo {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
description: string;
|
||||
icon: any; // React.ElementType
|
||||
benefits: string[];
|
||||
customerValue: string;
|
||||
color: string;
|
||||
related: { name: string; slug: string }[];
|
||||
}
|
||||
|
||||
export const technologies: Record<string, TechInfo> = {
|
||||
"next-js-14": {
|
||||
title: "Next.js 14",
|
||||
subtitle: "The React Framework for the Web",
|
||||
description:
|
||||
"Next.js 14 is the latest version of the industry-leading framework for building high-performance web applications. It allows me to create fast, scalable, and search-engine-friendly websites by rendering content on the server before sending it to your users.",
|
||||
icon: Layers,
|
||||
benefits: [
|
||||
"Lightning-fast page loads with Server Components",
|
||||
"Automatic image optimization",
|
||||
"Instant navigation between pages",
|
||||
"Top-tier SEO (Search Engine Optimization) out of the box",
|
||||
],
|
||||
customerValue:
|
||||
"For my clients, Next.js means a website that ranks higher on Google, keeps visitors engaged with instant interactions, and scales effortlessly as your traffic grows.",
|
||||
color: "bg-black text-white",
|
||||
related: [
|
||||
{ name: "TypeScript", slug: "typescript" },
|
||||
{ name: "React", slug: "react" },
|
||||
],
|
||||
},
|
||||
typescript: {
|
||||
title: "TypeScript",
|
||||
subtitle: "JavaScript with Syntax for Types",
|
||||
description:
|
||||
"TypeScript adds a powerful type system to JavaScript, catching errors before they ever reach production. It acts as a safety net for your code, ensuring that data flows exactly as expected through your entire application.",
|
||||
icon: Code,
|
||||
benefits: [
|
||||
"Eliminates whole categories of common bugs",
|
||||
"Makes large codebases easier to maintain",
|
||||
"Improves developer productivity and code confidence",
|
||||
"Ensures critical data integrity",
|
||||
],
|
||||
customerValue:
|
||||
'Using TypeScript means your application is robust and reliable from day one. It dramatically reduces the risk of "runtime errors" that could crash your site, saving time and money on bug fixes down the line.',
|
||||
color: "bg-blue-600 text-white",
|
||||
related: [
|
||||
{ name: "Next.js 14", slug: "next-js-14" },
|
||||
],
|
||||
},
|
||||
|
||||
"tailwind-css": {
|
||||
title: "Tailwind CSS",
|
||||
subtitle: "Utility-First CSS Framework",
|
||||
description:
|
||||
"Tailwind CSS is a utility-first framework that allows me to build custom designs directly in markup. It eliminates the need for bulky, overriding stylesheets and ensures a consistent design system across every page.",
|
||||
icon: Palette,
|
||||
benefits: [
|
||||
"Rapid UI development and prototyping",
|
||||
"Consistent spacing, colors, and typography",
|
||||
"Highly optimized final bundle size (only includes used styles)",
|
||||
"Responsive design made simple",
|
||||
],
|
||||
customerValue:
|
||||
"Tailwind ensures your brand looks pixel-perfect on every device. It also results in incredibly small CSS files, meaning your site loads faster for users on mobile networks.",
|
||||
color: "bg-cyan-500 text-white",
|
||||
related: [
|
||||
{ name: "React", slug: "react" },
|
||||
{ name: "Next.js 14", slug: "next-js-14" },
|
||||
],
|
||||
},
|
||||
react: {
|
||||
title: "React",
|
||||
subtitle: "The Library for Web and Native User Interfaces",
|
||||
description:
|
||||
"React is the core library powering Next.js. It lets me build encapsulated components that manage their own state, then compose them to make complex UIs.",
|
||||
icon: Terminal,
|
||||
benefits: [
|
||||
"Component-based architecture for reuse",
|
||||
"Efficient updates and rendering",
|
||||
"Rich ecosystem of libraries and tools",
|
||||
"Strong community support",
|
||||
],
|
||||
customerValue:
|
||||
"React enables rich, app-like interactions on your website. Whether it's a complex dashboard or a smooth animation, React makes it possible to build dynamic experiences that feel instantaneous.",
|
||||
color: "bg-blue-400 text-white",
|
||||
related: [
|
||||
{ name: "Next.js 14", slug: "next-js-14" },
|
||||
{ name: "Tailwind CSS", slug: "tailwind-css" },
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -1,19 +0,0 @@
|
||||
import React from "react";
|
||||
import { technologies } from "./data";
|
||||
import TechnologyContent from "./content";
|
||||
|
||||
export default async function TechnologyPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
return <TechnologyContent slug={slug} />;
|
||||
}
|
||||
|
||||
// Generate static params for these dynamic routes
|
||||
export async function generateStaticParams() {
|
||||
return Object.keys(technologies).map((slug) => ({
|
||||
slug,
|
||||
}));
|
||||
}
|
||||
@@ -1,375 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Reveal } from "@/src/components/Reveal";
|
||||
import { Section } from "@/src/components/Section";
|
||||
import {
|
||||
SpeedPerformance,
|
||||
SolidFoundation,
|
||||
LayerSeparation,
|
||||
TaskDone,
|
||||
} from "@/src/components/Landing";
|
||||
import {
|
||||
H3,
|
||||
LeadText,
|
||||
BodyText,
|
||||
Label,
|
||||
MonoLabel,
|
||||
} from "@/src/components/Typography";
|
||||
import { Card } from "@/src/components/Layout";
|
||||
import { Button } from "@/src/components/Button";
|
||||
import { IconList, IconListItem } from "@/src/components/IconList";
|
||||
import {
|
||||
GradientMesh,
|
||||
CodeSnippet,
|
||||
AbstractCircuit,
|
||||
CMSVisualizer,
|
||||
ArchitectureVisualizer,
|
||||
ResultVisualizer,
|
||||
} from "@/src/components/Effects";
|
||||
import { Marker } from "@/src/components/Marker";
|
||||
|
||||
export default function WebsitesPage() {
|
||||
return (
|
||||
<div className="flex flex-col bg-white overflow-hidden relative">
|
||||
<AbstractCircuit />
|
||||
|
||||
<Section className="pt-24 pb-12 md:pt-40 md:pb-24">
|
||||
<div className="space-y-12 md:space-y-24">
|
||||
<div className="space-y-6 md:space-y-10 max-w-5xl">
|
||||
<Reveal>
|
||||
<div className="space-y-4">
|
||||
<MonoLabel className="text-blue-500 tracking-[0.2em] text-[10px] md:text-xs">
|
||||
SYSTEM ENGINEERING
|
||||
</MonoLabel>
|
||||
<H3 className="text-4xl md:text-8xl leading-[1.0] tracking-tighter">
|
||||
Websites, die einfach <br />
|
||||
<span className="text-slate-400">
|
||||
<Marker>funktionieren.</Marker>
|
||||
</span>
|
||||
</H3>
|
||||
</div>
|
||||
</Reveal>
|
||||
<Reveal delay={0.2}>
|
||||
<LeadText className="text-lg md:text-2xl max-w-2xl text-slate-500 md:text-slate-400 leading-relaxed">
|
||||
Kein Baukasten. Kein Plugin-Chaos. Maßgeschneiderte Architektur
|
||||
für{" "}
|
||||
<span className="text-slate-900 font-bold underline decoration-slate-200 underline-offset-8">
|
||||
maximale Performance
|
||||
</span>
|
||||
.
|
||||
</LeadText>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
<div className="space-y-12">
|
||||
<Reveal delay={0.3} direction="up">
|
||||
<ArchitectureVisualizer />
|
||||
</Reveal>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[
|
||||
{
|
||||
label: "Next.js",
|
||||
sub: "Architecture",
|
||||
desc: "React-Framework für maximale SEO & Speed.",
|
||||
},
|
||||
{
|
||||
label: "Docker",
|
||||
sub: "Infrastructure",
|
||||
desc: "Reproduzierbare Umgebungen überall.",
|
||||
},
|
||||
{
|
||||
label: "Directus",
|
||||
sub: "Management",
|
||||
desc: "Headless CMS für flexible Datenabfrage.",
|
||||
},
|
||||
{
|
||||
label: "Gitea",
|
||||
sub: "Pipeline",
|
||||
desc: "Self-hosted Git & CI/CD Pipelines.",
|
||||
},
|
||||
].map((item, i) => (
|
||||
<Reveal key={i} delay={0.4 + i * 0.1}>
|
||||
<div className="space-y-2 p-6 rounded-2xl border border-slate-50 bg-white shadow-sm hover:border-slate-200 transition-all group">
|
||||
<Label className="text-slate-900 group-hover:text-blue-600 transition-colors uppercase tracking-widest text-[10px]">
|
||||
{item.label}
|
||||
</Label>
|
||||
<BodyText className="text-xs text-slate-400">
|
||||
{item.desc}
|
||||
</BodyText>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* 02: Performance */}
|
||||
<Section
|
||||
number="02"
|
||||
title="Performance"
|
||||
borderTop
|
||||
variant="gray"
|
||||
illustration={<SpeedPerformance className="w-24 h-24" />}
|
||||
effects={<GradientMesh variant="metallic" className="opacity-60" />}
|
||||
>
|
||||
<div className="space-y-8 md:space-y-12">
|
||||
<Reveal>
|
||||
<H3 className="text-2xl md:text-5xl leading-tight max-w-3xl">
|
||||
Geschwindigkeit ist <br />
|
||||
<span className="text-slate-400">
|
||||
kein Extra. Sie ist <Marker delay={0.3}>Standard.</Marker>
|
||||
</span>
|
||||
</H3>
|
||||
</Reveal>
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-8 md:gap-12 items-center">
|
||||
<div className="md:col-span-12 lg:col-span-7 space-y-6 md:space-y-8">
|
||||
<Reveal delay={0.2}>
|
||||
<LeadText className="text-lg md:text-xl text-slate-500 md:text-slate-400">
|
||||
Jede Seite wird vorab gerendert und über ein CDN ausgeliefert.
|
||||
Das Ergebnis: Ladezeiten unter einer Sekunde. Messbar.{" "}
|
||||
<span className="text-slate-900">Reproduzierbar.</span>
|
||||
</LeadText>
|
||||
</Reveal>
|
||||
<Reveal delay={0.4}>
|
||||
<IconList className="space-y-2 md:space-y-4">
|
||||
{[
|
||||
"Server-Side Rendering für sofortige Inhalte",
|
||||
"Automatische Bild-Optimierung (WebP, AVIF)",
|
||||
"Lighthouse-Score 90+ als Mindeststandard",
|
||||
"Core Web Vitals im grünen Bereich",
|
||||
].map((item, i) => (
|
||||
<IconListItem key={i} bullet>
|
||||
<LeadText className="text-base md:text-xl">
|
||||
{item}
|
||||
</LeadText>
|
||||
</IconListItem>
|
||||
))}
|
||||
</IconList>
|
||||
</Reveal>
|
||||
</div>
|
||||
<div className="md:col-span-5">
|
||||
<Reveal delay={0.6}>
|
||||
<Card
|
||||
variant="glass"
|
||||
padding="normal"
|
||||
techBorder
|
||||
className="text-center group py-10 md:py-12"
|
||||
>
|
||||
<div className="text-5xl md:text-8xl font-bold text-slate-900 tracking-tighter group-hover:scale-110 transition-transform duration-700">
|
||||
90+
|
||||
</div>
|
||||
<Label className="mt-4">Lighthouse Score</Label>
|
||||
<span className="block text-[8px] md:text-[9px] font-mono text-slate-300 mt-2 tracking-wider">
|
||||
PERFORMANCE · ACCESSIBILITY · SEO
|
||||
</span>
|
||||
</Card>
|
||||
</Reveal>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* 03: Code-Qualität */}
|
||||
<Section
|
||||
number="03"
|
||||
title="Code"
|
||||
borderTop
|
||||
illustration={<SolidFoundation className="w-24 h-24" />}
|
||||
>
|
||||
<div className="space-y-8 md:space-y-12">
|
||||
<Reveal>
|
||||
<H3 className="text-2xl md:text-5xl leading-tight max-w-3xl">
|
||||
Keine Plugins. <br />
|
||||
<span className="text-slate-400">Keine Abhängigkeiten.</span>
|
||||
</H3>
|
||||
</Reveal>
|
||||
<Reveal delay={0.2}>
|
||||
<LeadText className="text-xl md:text-2xl max-w-2xl text-slate-500 md:text-slate-400">
|
||||
Ihre Website besteht aus{" "}
|
||||
<span className="text-slate-900">Ihrem Code</span>. Kein
|
||||
WordPress, kein Wix, keine Blackbox. Alles versioniert, alles
|
||||
nachvollziehbar.
|
||||
</LeadText>
|
||||
</Reveal>
|
||||
|
||||
{/* Git Branch Visualization */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<Reveal delay={0.4}>
|
||||
<CodeSnippet variant="git" />
|
||||
</Reveal>
|
||||
<Reveal delay={0.5}>
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
<Card variant="glass" padding="normal" className="group">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-slate-900">Versionskontrolle</Label>
|
||||
<BodyText>
|
||||
Jede Änderung ist dokumentiert. Rollbacks in Sekunden.
|
||||
Kein „wer hat das kaputt gemacht?".
|
||||
</BodyText>
|
||||
</div>
|
||||
</Card>
|
||||
<Card variant="glass" padding="normal" className="group">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-slate-900">
|
||||
Automatisches Deployment
|
||||
</Label>
|
||||
<BodyText>
|
||||
Code wird geprüft, getestet und automatisch live
|
||||
geschaltet. Ohne manuellen Eingriff.
|
||||
</BodyText>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* 04: Content-System */}
|
||||
<Section
|
||||
number="04"
|
||||
title="Inhalte"
|
||||
borderTop
|
||||
variant="gray"
|
||||
illustration={<LayerSeparation className="w-24 h-24" />}
|
||||
effects={<GradientMesh variant="subtle" className="opacity-60" />}
|
||||
>
|
||||
<div className="space-y-12 md:space-y-20">
|
||||
<div className="space-y-6 md:space-y-10 max-w-5xl">
|
||||
<Reveal>
|
||||
<div className="space-y-4">
|
||||
<MonoLabel className="text-blue-500 tracking-[0.2em] text-[10px] md:text-xs">
|
||||
ARCHITECTURAL SEPARATION
|
||||
</MonoLabel>
|
||||
<H3 className="text-4xl md:text-7xl leading-[1.1] tracking-tighter">
|
||||
Inhalte pflegen <br />
|
||||
<span className="text-slate-400 italic font-serif">
|
||||
ohne Angst.
|
||||
</span>
|
||||
</H3>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={0.2}>
|
||||
<div className="space-y-6">
|
||||
<LeadText className="text-lg md:text-2xl text-slate-500 md:text-slate-400 leading-relaxed max-w-3xl">
|
||||
Vergessen Sie zerschossene Layouts nach einem Textupdate.
|
||||
Meine Websites trennen{" "}
|
||||
<span className="text-slate-900 font-bold underline decoration-blue-500/30 underline-offset-8">
|
||||
Daten von Design
|
||||
</span>
|
||||
.
|
||||
</LeadText>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 items-start">
|
||||
<div className="lg:col-span-8 relative">
|
||||
<Reveal delay={0.4} direction="up">
|
||||
<CMSVisualizer className="w-full mx-auto" />
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-4 space-y-8">
|
||||
<Reveal delay={0.5}>
|
||||
<div className="space-y-4">
|
||||
<BodyText className="text-slate-500 leading-relaxed">
|
||||
Durch eine krisenfeste Headless-Architektur (Directus)
|
||||
bewegen Sie sich in einem geschützten Sandkasten – während
|
||||
das Frontend-System die visuelle Integrität Ihrer Marke
|
||||
garantiert.
|
||||
</BodyText>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{["Layout-Schutz", "Live-Vorschau", "Role-RBAC"].map(
|
||||
(tag, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="px-3 py-1 bg-white border border-slate-100 rounded-full text-[10px] font-mono text-slate-400"
|
||||
>
|
||||
{tag}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={0.6}>
|
||||
<div className="p-6 bg-slate-900 rounded-2xl shadow-xl text-[10px] font-mono text-white/50 space-y-3">
|
||||
<div className="flex justify-between items-center text-white/90">
|
||||
<span>PROTOCOL</span>
|
||||
<span className="text-green-500 font-bold">ENFORCED</span>
|
||||
</div>
|
||||
<p className="leading-tight">
|
||||
Website architecture validates all CMS payloads against the
|
||||
design schema before rendering.
|
||||
</p>
|
||||
<div className="pt-2 border-t border-white/10 flex items-center justify-between">
|
||||
<span>INTEGRITY</span>
|
||||
<span className="text-white">100%</span>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Reveal delay={0.7}>
|
||||
<div className="p-px w-full bg-gradient-to-r from-transparent via-slate-100 to-transparent" />
|
||||
</Reveal>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* 05: Was Sie bekommen */}
|
||||
<Section
|
||||
number="05"
|
||||
title="Ergebnis"
|
||||
borderTop
|
||||
illustration={<TaskDone className="w-24 h-24" />}
|
||||
>
|
||||
<div className="space-y-12 md:space-y-24">
|
||||
<div className="max-w-4xl space-y-6">
|
||||
<Reveal>
|
||||
<H3 className="text-4xl md:text-7xl leading-[1.1] tracking-tighter">
|
||||
Was Sie konkret <br />
|
||||
<span className="text-slate-400">bekommen.</span>
|
||||
</H3>
|
||||
</Reveal>
|
||||
<Reveal delay={0.2}>
|
||||
<LeadText className="text-lg md:text-2xl text-slate-500 md:text-slate-400 max-w-2xl">
|
||||
Keine halben Sachen. Ich liefere Ihnen ein schlüsselfertiges
|
||||
System mit voller Kontrolle und Transparenz.
|
||||
</LeadText>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
<Reveal delay={0.3} direction="up">
|
||||
<ResultVisualizer />
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={0.5}>
|
||||
<div className="pt-10 md:pt-16 border-t border-slate-100 flex flex-col md:flex-row justify-between items-start md:items-center gap-12">
|
||||
<div className="space-y-3">
|
||||
<MonoLabel className="text-blue-500">
|
||||
BEREIT FÜR DEN NÄCHSTEN SCHRITT?
|
||||
</MonoLabel>
|
||||
<div className="text-xl md:text-3xl font-bold tracking-tight text-slate-900">
|
||||
Lassen Sie uns über Ihr Projekt sprechen.
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
href="/contact"
|
||||
className="w-full md:w-auto h-16 px-10 text-lg rounded-2xl shadow-2xl shadow-blue-500/10"
|
||||
>
|
||||
Projekt anfragen
|
||||
</Button>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
/** Any address that is not a page ends in the error page of its language. */
|
||||
export default function UnknownPage() {
|
||||
notFound();
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
import { useEffect } from "react";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { Button } from "@/src/components/Button";
|
||||
import { useContent, useLocale } from "@/src/components/ContentProvider";
|
||||
import { pathFor } from "@/src/domain/routes";
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
@@ -11,32 +13,50 @@ export default function Error({
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const { error: copy } = useContent();
|
||||
const home = pathFor("home", useLocale());
|
||||
|
||||
useEffect(() => {
|
||||
// Log the error to Sentry/GlitchTip
|
||||
Sentry.captureException(error);
|
||||
console.error("Caught in error.tsx:", error);
|
||||
|
||||
// Auto-reload on ChunkLoadError or Server Action errors to fetch the new deployment assets
|
||||
const isChunkLoadError =
|
||||
error.message &&
|
||||
(error.message.includes("ChunkLoadError") ||
|
||||
error.message.includes("Loading chunk") ||
|
||||
error.message.includes("Failed to find Server Action"));
|
||||
|
||||
if (isChunkLoadError) {
|
||||
const lastReload = sessionStorage.getItem("last-chunk-reload");
|
||||
const now = Date.now();
|
||||
if (!lastReload || now - parseInt(lastReload) > 10000) {
|
||||
sessionStorage.setItem("last-chunk-reload", now.toString());
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[70vh] px-5 py-20 text-center">
|
||||
<div className="space-y-6 max-w-2xl mx-auto">
|
||||
<span className="inline-block px-3 py-1 bg-red-50 border border-red-100 rounded text-[10px] font-mono text-red-500 uppercase tracking-widest">
|
||||
Error 500
|
||||
{copy.kicker}
|
||||
</span>
|
||||
<h1 className="text-5xl md:text-7xl font-black text-slate-900 tracking-tighter">
|
||||
Kritischer Fehler.
|
||||
{copy.title}
|
||||
</h1>
|
||||
<p className="text-xl md:text-2xl text-slate-500 font-serif italic max-w-xl mx-auto leading-relaxed">
|
||||
Ein unerwartetes Problem ist aufgetreten. Unsere Systeme haben den
|
||||
Vorfall protokolliert.
|
||||
{copy.text}
|
||||
</p>
|
||||
|
||||
<div className="pt-8 flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<Button href="#" onClick={() => reset()} variant="primary">
|
||||
System neu starten (Retry)
|
||||
{copy.retry}
|
||||
</Button>
|
||||
<Button href="/" variant="outline">
|
||||
Zurück zur Basis
|
||||
<Button href={home} variant="outline">
|
||||
{copy.home}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import type { Metadata } from "next";
|
||||
import { DecodeText } from "@/src/components/bits/DecodeText";
|
||||
import { Block } from "@/src/components/home/Block";
|
||||
import { ClosingCta } from "@/src/components/home/ClosingCta";
|
||||
import { Faq } from "@/src/components/home/Faq";
|
||||
import { BitAura } from "@/src/components/bits/BitAura";
|
||||
import { PathPicker } from "@/src/components/home/PathPicker";
|
||||
import { TopicButton } from "@/src/components/home/TopicButton";
|
||||
import { ContactDiagram } from "@/src/components/illustrations/ContactDiagram";
|
||||
import { PipelineRun } from "@/src/components/illustrations/PipelineRun";
|
||||
import { Reveal } from "@/src/components/Reveal";
|
||||
import { SwipeRow } from "@/src/components/SwipeRow";
|
||||
import { contentFor } from "@/src/content";
|
||||
import { DEFAULT_LOCALE, parseLocale } from "@/src/domain/locale";
|
||||
import { languageAlternates, pathFor } from "@/src/domain/routes";
|
||||
|
||||
type Props = { readonly params: Promise<{ readonly locale: string }> };
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
|
||||
const { freelance } = contentFor(locale);
|
||||
return {
|
||||
title: { absolute: freelance.metaTitle },
|
||||
description: freelance.metaDescription,
|
||||
alternates: {
|
||||
canonical: pathFor("freelance", locale),
|
||||
languages: languageAlternates("freelance"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** What a team may ask for. Clients have their own on the homepage. */
|
||||
const TEAM_TOPICS = ["developer", "rescue", "other"] as const;
|
||||
|
||||
const MUTED = "text-slate-400";
|
||||
const BODY = "m-0 font-sans text-lg leading-relaxed text-slate-500 md:text-xl";
|
||||
const LIST_TITLE = "m-0 font-sans text-xl font-bold tracking-tight md:text-2xl";
|
||||
|
||||
/**
|
||||
* The page for people who develop themselves or lead a team: which projects
|
||||
* fit, the tools, and how a change is secured before it goes live.
|
||||
*/
|
||||
export default async function FreelancePage({ params }: Props) {
|
||||
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
|
||||
const content = contentFor(locale);
|
||||
const { freelance, technology } = content;
|
||||
const { booking } = freelance;
|
||||
const { services } = content;
|
||||
const { working } = content;
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col bg-white">
|
||||
<section className="bg-white">
|
||||
<div className="mx-auto w-full max-w-[1400px] px-5 pb-4 pt-14 md:px-12 md:pb-10 md:pt-28">
|
||||
<p className="m-0 font-mono text-[11px] uppercase tracking-[0.25em] text-slate-500">
|
||||
{freelance.label}
|
||||
</p>
|
||||
<h1 className="m-0 mt-8 max-w-6xl font-sans text-4xl font-bold leading-[0.95] tracking-tighter text-slate-900 md:text-7xl lg:text-[6.5rem]">
|
||||
{freelance.title.lead}{" "}
|
||||
<span className={MUTED}>{freelance.title.rest}</span>
|
||||
</h1>
|
||||
<p className={`${BODY} mt-10 max-w-3xl md:mt-14`}>
|
||||
{freelance.intro}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Block
|
||||
index="01"
|
||||
label={technology.label}
|
||||
title={
|
||||
<>
|
||||
{technology.title.lead}{" "}
|
||||
<span className={MUTED}>{technology.title.rest}</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<SwipeRow className="grid grid-cols-1 gap-12 md:grid-cols-3 md:gap-10">
|
||||
{technology.fit.map((group, i) => (
|
||||
<Reveal key={group.title} delay={0.1 * i}>
|
||||
<div
|
||||
className={`border-t pt-6 ${i === 2 ? "border-slate-300" : "border-slate-900"}`}
|
||||
>
|
||||
<h3
|
||||
className={`${LIST_TITLE} ${i === 2 ? "text-slate-400" : "text-slate-900"}`}
|
||||
>
|
||||
{group.title}
|
||||
</h3>
|
||||
<ul className="m-0 mt-4 list-none p-0">
|
||||
{group.items.map((item) => (
|
||||
<li
|
||||
key={item}
|
||||
className={`border-b border-slate-100 py-4 font-sans text-lg md:text-xl ${
|
||||
i === 2 ? "text-slate-400" : "text-slate-900"
|
||||
}`}
|
||||
>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</SwipeRow>
|
||||
|
||||
<Reveal>
|
||||
<div className="mt-12 rounded-3xl bg-slate-50 p-6 md:mt-24 md:p-10">
|
||||
<h3 className={`${LIST_TITLE} text-slate-900`}>
|
||||
{technology.techLabel}
|
||||
</h3>
|
||||
<div className="mt-6 grid grid-cols-1 gap-7 sm:grid-cols-2 md:mt-8 md:gap-10 lg:grid-cols-4">
|
||||
{technology.tech.map((group) => (
|
||||
<div key={group.area}>
|
||||
<p className="m-0 font-sans text-xl font-bold tracking-tight text-slate-900 md:text-2xl">
|
||||
{group.area}
|
||||
</p>
|
||||
<ul className="m-0 mt-4 flex list-none flex-wrap gap-2 p-0">
|
||||
{group.items.map((item) => (
|
||||
<li
|
||||
key={item}
|
||||
className="rounded-full border border-slate-200 bg-white px-4 py-2 font-mono text-[11px] uppercase tracking-[0.12em] text-slate-700"
|
||||
>
|
||||
<DecodeText text={item} trigger="both" />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</Block>
|
||||
|
||||
<Block
|
||||
index="02"
|
||||
label={services.label}
|
||||
title={
|
||||
<>
|
||||
{freelance.cases.lead}{" "}
|
||||
<span className={MUTED}>{freelance.cases.rest}</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Reveal>
|
||||
<PathPicker ids={["developer", "rescue"]} pricing="estimate" />
|
||||
</Reveal>
|
||||
</Block>
|
||||
|
||||
<Block
|
||||
index="03"
|
||||
label={freelance.pipelineLabel}
|
||||
title={technology.pipelineTitle}
|
||||
>
|
||||
<Reveal>
|
||||
<p className={`${BODY} mb-12 max-w-3xl`}>
|
||||
{technology.pipelineIntro}
|
||||
</p>
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<PipelineRun />
|
||||
</Reveal>
|
||||
</Block>
|
||||
|
||||
<Block
|
||||
index="04"
|
||||
label={working.label}
|
||||
title={
|
||||
<>
|
||||
{working.title.lead}{" "}
|
||||
<span className={MUTED}>{working.title.rest}</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Reveal>
|
||||
<BitAura source="bottom" tone="light" className="mb-16 md:mb-24">
|
||||
<div className="overflow-hidden rounded-3xl bg-slate-900 p-4 md:p-10">
|
||||
<ContactDiagram />
|
||||
</div>
|
||||
</BitAura>
|
||||
</Reveal>
|
||||
<SwipeRow className="grid grid-cols-1 gap-x-12 gap-y-12 md:grid-cols-2 md:gap-y-16">
|
||||
{working.principles.map((item, i) => (
|
||||
<Reveal key={item.title} delay={0.08 * i}>
|
||||
<div className="border-t border-slate-900 pt-6">
|
||||
<h3 className="m-0 font-sans text-3xl font-bold tracking-tight text-slate-900 md:text-4xl">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className={`${BODY} mt-4`}>{item.text}</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</SwipeRow>
|
||||
</Block>
|
||||
|
||||
<Block
|
||||
index="05"
|
||||
label={booking.label}
|
||||
title={
|
||||
<>
|
||||
{booking.title.lead}{" "}
|
||||
<span className={MUTED}>{booking.title.rest}</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Reveal>
|
||||
<div className="grid grid-cols-1 gap-12 md:grid-cols-12 md:gap-12">
|
||||
<div className="md:col-span-6">
|
||||
<p className={BODY}>{booking.text}</p>
|
||||
<ul className="m-0 mt-8 list-none p-0">
|
||||
{booking.terms.map((term) => (
|
||||
<li
|
||||
key={term}
|
||||
className="border-t border-slate-200 py-4 font-sans text-lg text-slate-900 md:text-xl"
|
||||
>
|
||||
{term}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="md:col-span-6">
|
||||
<div className="rounded-3xl bg-slate-50 p-6 md:p-10">
|
||||
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2">
|
||||
{[booking.rate, booking.example].map((price) => (
|
||||
<div key={price.label}>
|
||||
<p className="m-0 font-sans text-base text-slate-500 md:text-lg">
|
||||
{price.label}
|
||||
</p>
|
||||
<p className="m-0 mt-2 font-sans text-3xl font-black tracking-tighter text-slate-900 md:text-5xl">
|
||||
{price.amount}
|
||||
</p>
|
||||
<p className="m-0 mt-2 font-mono text-[11px] uppercase tracking-[0.2em] text-slate-500">
|
||||
{price.note}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-10 border-t border-slate-200 pt-8">
|
||||
<TopicButton topic="developer">{booking.cta}</TopicButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</Block>
|
||||
|
||||
<Block
|
||||
index="06"
|
||||
label={content.faq.label}
|
||||
title={
|
||||
<>
|
||||
{freelance.faq.title.lead}{" "}
|
||||
<span className={MUTED}>{freelance.faq.title.rest}</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Reveal>
|
||||
<Faq items={freelance.faq.items} />
|
||||
</Reveal>
|
||||
</Block>
|
||||
|
||||
<ClosingCta contact={content.contact} topics={TEAM_TOPICS} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Metadata } from "next";
|
||||
import { LegalPage } from "@/src/components/LegalPage";
|
||||
import { IMPRINT } from "@/src/content/legal/imprint";
|
||||
import { DEFAULT_LOCALE, parseLocale } from "@/src/domain/locale";
|
||||
import { languageAlternates, pathFor } from "@/src/domain/routes";
|
||||
|
||||
type Props = { readonly params: Promise<{ readonly locale: string }> };
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
|
||||
const { title, description } = IMPRINT[locale];
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: pathFor("imprint", locale),
|
||||
languages: languageAlternates("imprint"),
|
||||
},
|
||||
robots: { index: false, follow: true },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page({ params }: Props) {
|
||||
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
|
||||
const { title, Body } = IMPRINT[locale];
|
||||
return (
|
||||
<LegalPage title={title}>
|
||||
<Body />
|
||||
</LegalPage>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import type { StaticImageData } from "next/image";
|
||||
import { Inter, JetBrains_Mono, Newsreader } from "next/font/google";
|
||||
import { BitBurst } from "@/src/components/bits/BitBurst";
|
||||
import { BitGlow } from "@/src/components/bits/BitGlow";
|
||||
import { Footer } from "@/src/components/Footer";
|
||||
import { Header } from "@/src/components/Header";
|
||||
import { InteractiveElements } from "@/src/components/InteractiveElements";
|
||||
import { ContentProvider } from "@/src/components/ContentProvider";
|
||||
import { BitCurtain } from "@/src/components/bits/BitCurtain";
|
||||
import shareImageDe from "@/src/assets/share/share-de.png";
|
||||
import shareImageEn from "@/src/assets/share/share-en.png";
|
||||
import { contentFor } from "@/src/content";
|
||||
import { INDEXING, SITE_NAME, SITE_URL } from "@/src/content/site";
|
||||
import { robotsMeta } from "@/src/domain/indexing";
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
LOCALES,
|
||||
parseLocale,
|
||||
type Locale,
|
||||
} from "@/src/domain/locale";
|
||||
import { pathFor } from "@/src/domain/routes";
|
||||
import "../globals.css";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
|
||||
// A real monospaced fallback has almost the same letter width. The default
|
||||
// fallback (a resized Arial) wraps the mono labels differently, so the hero
|
||||
// jumped when the font arrived.
|
||||
const jetbrainsMono = JetBrains_Mono({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-jetbrains-mono",
|
||||
display: "swap",
|
||||
adjustFontFallback: false,
|
||||
fallback: ["ui-monospace", "Menlo", "Consolas", "monospace"],
|
||||
});
|
||||
const newsreader = Newsreader({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-newsreader",
|
||||
style: "italic",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
type Props = {
|
||||
readonly children: React.ReactNode;
|
||||
readonly params: Promise<{ readonly locale: string }>;
|
||||
};
|
||||
|
||||
/** The share image is a screenshot of the hero in that language (see scripts/render-share-image.mjs). */
|
||||
const SHARE_IMAGE: Readonly<Record<Locale, StaticImageData>> = {
|
||||
en: shareImageEn,
|
||||
de: shareImageDe,
|
||||
};
|
||||
|
||||
export function generateStaticParams() {
|
||||
return LOCALES.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
|
||||
const { site } = contentFor(locale);
|
||||
const image = SHARE_IMAGE[locale];
|
||||
const images = [
|
||||
{
|
||||
url: image.src,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
alt: site.shareImageAlt,
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
metadataBase: new URL(SITE_URL),
|
||||
title: {
|
||||
default: site.title,
|
||||
template: `%s | ${SITE_NAME}`,
|
||||
},
|
||||
description: site.description,
|
||||
applicationName: SITE_NAME,
|
||||
authors: [{ name: SITE_NAME, url: SITE_URL }],
|
||||
creator: SITE_NAME,
|
||||
robots: robotsMeta(INDEXING),
|
||||
icons: {
|
||||
icon: "/favicon.svg",
|
||||
apple: "/apple-touch-icon.png",
|
||||
},
|
||||
openGraph: {
|
||||
type: "website",
|
||||
url: pathFor("home", locale),
|
||||
siteName: SITE_NAME,
|
||||
locale: site.ogLocale,
|
||||
alternateLocale: LOCALES.filter((other) => other !== locale).map(
|
||||
(other) => contentFor(other).site.ogLocale,
|
||||
),
|
||||
title: site.title,
|
||||
description: site.description,
|
||||
images,
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: site.title,
|
||||
description: site.description,
|
||||
images,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: "#ffffff",
|
||||
colorScheme: "light",
|
||||
viewportFit: "cover",
|
||||
};
|
||||
|
||||
export default async function RootLayout({ children, params }: Props) {
|
||||
// The proxy only lets the two languages through; anything else is a file-like
|
||||
// address that ends in the error page of the main language.
|
||||
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
|
||||
|
||||
return (
|
||||
<html
|
||||
lang={locale}
|
||||
className={`${inter.variable} ${newsreader.variable} ${jetbrainsMono.variable}`}
|
||||
>
|
||||
<body className="min-h-screen bg-white">
|
||||
<ContentProvider locale={locale} content={contentFor(locale)}>
|
||||
<Header />
|
||||
<main>{children}</main>
|
||||
<Footer />
|
||||
<InteractiveElements />
|
||||
<BitCurtain />
|
||||
</ContentProvider>
|
||||
<BitBurst />
|
||||
<BitGlow />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +1,28 @@
|
||||
export const metadata = {
|
||||
title: "404 - Seite nicht gefunden | Marc Mintel",
|
||||
description: "Diese Seite konnte leider nicht gefunden werden.",
|
||||
};
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/src/components/Button";
|
||||
import { useContent, useLocale } from "@/src/components/ContentProvider";
|
||||
import { pathFor } from "@/src/domain/routes";
|
||||
|
||||
export default function NotFound() {
|
||||
const { notFound } = useContent();
|
||||
const home = pathFor("home", useLocale());
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[70vh] px-5 py-20 text-center">
|
||||
<div className="space-y-6 max-w-2xl mx-auto">
|
||||
<span className="inline-block px-3 py-1 bg-slate-50 border border-slate-100 rounded text-[10px] font-mono text-slate-500 uppercase tracking-widest">
|
||||
Error 404
|
||||
{notFound.kicker}
|
||||
</span>
|
||||
<h1 className="text-5xl md:text-7xl font-black text-slate-900 tracking-tighter">
|
||||
System-Anomalie.
|
||||
{notFound.title}
|
||||
</h1>
|
||||
<p className="text-xl md:text-2xl text-slate-500 font-serif italic max-w-xl mx-auto leading-relaxed">
|
||||
Die angeforderte URL existiert nicht in dieser Zeitleiste.
|
||||
Möglicherweise wurde die Seite verschoben oder gelöscht.
|
||||
{notFound.text}
|
||||
</p>
|
||||
|
||||
<div className="pt-8 flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<Button href="/" variant="primary">
|
||||
Zurück zur Basis
|
||||
</Button>
|
||||
<Button href="/blog" variant="outline">
|
||||
Aktuelle Artikel lesen
|
||||
<Button href={home} variant="primary">
|
||||
{notFound.home}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { BitAura } from "@/src/components/bits/BitAura";
|
||||
import { Block } from "@/src/components/home/Block";
|
||||
import { ClosingCta } from "@/src/components/home/ClosingCta";
|
||||
import { Faq } from "@/src/components/home/Faq";
|
||||
import { HeroStatement } from "@/src/components/home/HeroStatement";
|
||||
import { HomeJsonLd } from "@/src/components/home/HomeJsonLd";
|
||||
import { PathPicker } from "@/src/components/home/PathPicker";
|
||||
import { PortraitSection } from "@/src/components/home/PortraitSection";
|
||||
import { StickyBook } from "@/src/components/home/StickyBook";
|
||||
import { AutomationFlow } from "@/src/components/illustrations/AutomationFlow";
|
||||
import { CareerLine } from "@/src/components/illustrations/CareerLine";
|
||||
import {
|
||||
PresentationCompare,
|
||||
ReasonDrawing,
|
||||
} from "@/src/components/illustrations/WebPresentation";
|
||||
import { WorkShowcase } from "@/src/components/illustrations/WorkShowcase";
|
||||
import { Reveal } from "@/src/components/Reveal";
|
||||
import { StoryThread } from "@/src/components/story/StoryThread";
|
||||
import { SwipeRow } from "@/src/components/SwipeRow";
|
||||
import { contentFor } from "@/src/content";
|
||||
import type { SplitTitle } from "@/src/content/types";
|
||||
import { DEFAULT_LOCALE, parseLocale } from "@/src/domain/locale";
|
||||
import { languageAlternates, pathFor } from "@/src/domain/routes";
|
||||
|
||||
type Props = { readonly params: Promise<{ readonly locale: string }> };
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
|
||||
const { site } = contentFor(locale);
|
||||
return {
|
||||
title: { absolute: site.title },
|
||||
description: site.description,
|
||||
alternates: {
|
||||
canonical: pathFor("home", locale),
|
||||
languages: languageAlternates("home"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** What a client of the homepage may ask for. Teams have their own on the freelance page. */
|
||||
const HOME_TOPICS = ["automation", "website", "other"] as const;
|
||||
|
||||
const MUTED = "text-slate-400";
|
||||
const BODY = "m-0 font-sans text-lg leading-relaxed text-slate-500 md:text-xl";
|
||||
const LIST_TITLE =
|
||||
"m-0 font-sans text-xl font-bold tracking-tight text-slate-900 md:text-2xl";
|
||||
|
||||
const Title: React.FC<{ title: SplitTitle; muted?: string }> = ({
|
||||
title,
|
||||
muted = MUTED,
|
||||
}) => (
|
||||
<>
|
||||
{title.lead} <span className={muted}>{title.rest}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
export default async function LandingPage({ params }: Props) {
|
||||
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
|
||||
const content = contentFor(locale);
|
||||
const {
|
||||
hello,
|
||||
services,
|
||||
automation,
|
||||
presentations,
|
||||
story,
|
||||
freelance,
|
||||
work,
|
||||
faq,
|
||||
} = content;
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col bg-white">
|
||||
<HomeJsonLd locale={locale} />
|
||||
<HeroStatement hero={content.hero} />
|
||||
<StickyBook />
|
||||
|
||||
<PortraitSection
|
||||
id="hello"
|
||||
index="01"
|
||||
label={hello.label}
|
||||
title={hello.title}
|
||||
portraitAlt={hello.portraitAlt}
|
||||
story={story.hello}
|
||||
>
|
||||
<p className="m-0 font-sans text-2xl leading-snug text-white md:text-4xl">
|
||||
{hello.lead}
|
||||
</p>
|
||||
{hello.paragraphs.map((paragraph, i) => (
|
||||
<p
|
||||
key={paragraph}
|
||||
className={`m-0 font-sans text-lg leading-relaxed text-slate-300 md:text-xl ${i === 0 ? "mt-8" : "mt-6"}`}
|
||||
>
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
</PortraitSection>
|
||||
|
||||
<section className="relative overflow-clip bg-slate-900">
|
||||
<div className="mx-auto w-full max-w-[1400px] px-5 pb-20 pt-4 md:px-12 md:pb-36 md:pt-0">
|
||||
<p className="m-0 mb-10 font-mono text-[11px] uppercase tracking-[0.2em] text-slate-400">
|
||||
{hello.careerLabel}
|
||||
</p>
|
||||
<CareerLine stations={hello.career} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Block
|
||||
index="02"
|
||||
label={services.label}
|
||||
chapter={{ line: story.services, figure: "diamond", flip: false }}
|
||||
title={<Title title={services.title} />}
|
||||
>
|
||||
<Reveal>
|
||||
<PathPicker ids={["automation", "website"]} pricing="offer" />
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<Link
|
||||
href={pathFor("freelance", locale)}
|
||||
className="group mt-14 flex flex-col gap-5 rounded-3xl bg-slate-50 p-6 no-underline transition-colors duration-300 hover:bg-slate-100 md:mt-24 md:flex-row md:items-center md:justify-between md:p-10"
|
||||
>
|
||||
<span className="block max-w-2xl">
|
||||
<span className="block font-sans text-2xl font-bold tracking-tight text-slate-900 md:text-3xl">
|
||||
{freelance.teaser.title}
|
||||
</span>
|
||||
<span className="mt-3 block font-sans text-base leading-relaxed text-slate-500 md:text-lg">
|
||||
{freelance.teaser.text}
|
||||
</span>
|
||||
</span>
|
||||
<span className="inline-flex shrink-0 items-center gap-3 self-start rounded-full bg-slate-900 px-6 py-3 font-sans text-[11px] font-bold uppercase tracking-[0.15em] text-white md:self-auto">
|
||||
{freelance.teaser.cta}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="transition-transform duration-300 group-hover:translate-x-1"
|
||||
>
|
||||
→
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
</Reveal>
|
||||
</Block>
|
||||
|
||||
<Block
|
||||
index="03"
|
||||
label={automation.label}
|
||||
chapter={{ line: story.automation, figure: "hexagon", flip: true }}
|
||||
title={<Title title={automation.title} />}
|
||||
>
|
||||
<Reveal>
|
||||
<p className={`${BODY} mb-12 max-w-3xl`}>{automation.intro}</p>
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<AutomationFlow />
|
||||
</Reveal>
|
||||
<SwipeRow className="mt-12 grid grid-cols-1 gap-12 md:mt-24 md:grid-cols-3 md:gap-10">
|
||||
{automation.notes.map((group, i) => (
|
||||
<Reveal key={group.title} delay={0.1 * i}>
|
||||
<div className="border-t border-slate-900 pt-6">
|
||||
<h3 className={LIST_TITLE}>{group.title}</h3>
|
||||
<ul className="m-0 mt-4 list-none p-0">
|
||||
{group.items.map((item) => (
|
||||
<li
|
||||
key={item}
|
||||
className="border-b border-slate-100 py-4 font-sans text-lg text-slate-900 md:text-xl"
|
||||
>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</SwipeRow>
|
||||
</Block>
|
||||
|
||||
<Block
|
||||
index="04"
|
||||
label={presentations.label}
|
||||
chapter={{ line: story.presentations, figure: "screen", flip: false }}
|
||||
tone="dark"
|
||||
title={<Title title={presentations.title} muted="text-slate-500" />}
|
||||
>
|
||||
<Reveal>
|
||||
<p className="m-0 mb-12 max-w-3xl font-sans text-lg leading-relaxed text-slate-300 md:mb-16 md:text-xl">
|
||||
{presentations.intro}
|
||||
</p>
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<BitAura source="around" tone="dark">
|
||||
<div className="rounded-3xl border border-slate-700 bg-slate-950 px-4 py-8 md:px-10 md:py-12">
|
||||
<PresentationCompare />
|
||||
</div>
|
||||
</BitAura>
|
||||
</Reveal>
|
||||
|
||||
<SwipeRow className="mt-12 grid grid-cols-1 gap-x-10 gap-y-14 md:mt-28 md:grid-cols-2 lg:grid-cols-4">
|
||||
{presentations.reasons.map((reason, i) => (
|
||||
<Reveal key={reason.title} delay={0.08 * i}>
|
||||
<div className="rounded-2xl border border-slate-800 bg-slate-950/60 p-4">
|
||||
<ReasonDrawing index={i} />
|
||||
</div>
|
||||
<h3 className="m-0 mt-6 font-sans text-2xl font-bold tracking-tight text-white">
|
||||
{reason.title}
|
||||
</h3>
|
||||
<p className="m-0 mt-3 font-sans text-base leading-relaxed text-slate-300 md:text-lg">
|
||||
{reason.text}
|
||||
</p>
|
||||
</Reveal>
|
||||
))}
|
||||
</SwipeRow>
|
||||
|
||||
<div className="mt-16 grid grid-cols-1 gap-12 md:mt-28 md:grid-cols-12 md:gap-12">
|
||||
<Reveal className="md:col-span-6">
|
||||
<div className="border-t border-slate-700 pt-6">
|
||||
<h3 className="m-0 font-sans text-xl font-bold tracking-tight text-white md:text-2xl">
|
||||
{presentations.goodFor.title}
|
||||
</h3>
|
||||
<ul className="m-0 mt-4 list-none p-0">
|
||||
{presentations.goodFor.items.map((item) => (
|
||||
<li
|
||||
key={item}
|
||||
className="border-b border-slate-800 py-4 font-sans text-lg text-slate-100 md:text-xl"
|
||||
>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Reveal>
|
||||
<Reveal delay={0.1} className="md:col-span-6">
|
||||
<div className="rounded-3xl border border-slate-700 bg-slate-950/60 p-6 md:p-10">
|
||||
<h3 className="m-0 font-sans text-2xl font-bold tracking-tight text-white md:text-3xl">
|
||||
{presentations.honest.title}
|
||||
</h3>
|
||||
<p className="m-0 mt-4 font-sans text-base leading-relaxed text-slate-300 md:text-lg">
|
||||
{presentations.honest.text}
|
||||
</p>
|
||||
<a
|
||||
href="#contact"
|
||||
className="bit-cta bit-cta-light mt-8 inline-flex items-center gap-3 rounded-full bg-white px-6 py-3 font-sans text-[11px] font-bold uppercase tracking-[0.15em] text-slate-900 no-underline transition-transform duration-300 hover:-translate-y-0.5"
|
||||
>
|
||||
{presentations.cta} <span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</Block>
|
||||
|
||||
<Block
|
||||
index="05"
|
||||
label={work.label}
|
||||
chapter={{ line: story.work, figure: "square", flip: true }}
|
||||
tone="dark"
|
||||
title={<Title title={work.title} muted="text-slate-500" />}
|
||||
>
|
||||
<WorkShowcase />
|
||||
<Reveal>
|
||||
<p className="m-0 mt-20 max-w-3xl border-t border-slate-800 pt-8 font-sans text-lg leading-relaxed text-slate-300 md:mt-28 md:text-xl">
|
||||
{work.note}
|
||||
</p>
|
||||
</Reveal>
|
||||
</Block>
|
||||
|
||||
<Block
|
||||
index="06"
|
||||
label={faq.label}
|
||||
chapter={{ line: story.faq, figure: "triangle", flip: false }}
|
||||
title={<Title title={faq.title} />}
|
||||
>
|
||||
<Reveal>
|
||||
<Faq items={faq.items} />
|
||||
</Reveal>
|
||||
</Block>
|
||||
|
||||
<ClosingCta
|
||||
contact={content.contact}
|
||||
topics={HOME_TOPICS}
|
||||
story={story.contact}
|
||||
/>
|
||||
<StoryThread />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Metadata } from "next";
|
||||
import { LegalPage } from "@/src/components/LegalPage";
|
||||
import { PRIVACY } from "@/src/content/legal/privacy";
|
||||
import { DEFAULT_LOCALE, parseLocale } from "@/src/domain/locale";
|
||||
import { languageAlternates, pathFor } from "@/src/domain/routes";
|
||||
|
||||
type Props = { readonly params: Promise<{ readonly locale: string }> };
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
|
||||
const { title, description } = PRIVACY[locale];
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: pathFor("privacy", locale),
|
||||
languages: languageAlternates("privacy"),
|
||||
},
|
||||
robots: { index: false, follow: true },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page({ params }: Props) {
|
||||
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
|
||||
const { title, Body } = PRIVACY[locale];
|
||||
return (
|
||||
<LegalPage title={title}>
|
||||
<Body />
|
||||
</LegalPage>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getPayload } from "payload";
|
||||
import configPromise from "@payload-config";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Deep CMS Health Check
|
||||
* Validates that Payload CMS can actually query the database.
|
||||
* Used by post-deploy smoke tests to catch migration/schema issues.
|
||||
*/
|
||||
export async function GET() {
|
||||
const checks: Record<string, string> = {};
|
||||
|
||||
try {
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
checks.init = "ok";
|
||||
|
||||
// Verify each collection can be queried (catches missing locale tables, broken migrations)
|
||||
// Adjusted for mintel.me collections
|
||||
const collections = ["posts", "projects", "media", "inquiries"] as const;
|
||||
for (const collection of collections) {
|
||||
try {
|
||||
await payload.find({ collection, limit: 1 });
|
||||
checks[collection] = "ok";
|
||||
} catch (e: any) {
|
||||
checks[collection] = `error: ${e.message?.substring(0, 100)}`;
|
||||
}
|
||||
}
|
||||
|
||||
const hasErrors = Object.values(checks).some((v) => v.startsWith("error"));
|
||||
return NextResponse.json(
|
||||
{ status: hasErrors ? "degraded" : "ok", checks },
|
||||
{ status: hasErrors ? 503 : 200 },
|
||||
);
|
||||
} catch (e: any) {
|
||||
return NextResponse.json(
|
||||
{ status: "error", message: e.message?.substring(0, 200), checks },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getTweet } from 'react-tweet/api';
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
|
||||
try {
|
||||
const tweet = await getTweet(id);
|
||||
|
||||
if (!tweet) {
|
||||
return NextResponse.json({ error: 'Tweet not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: tweet });
|
||||
} catch (error) {
|
||||
console.error('Error fetching tweet:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch tweet' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user