feat(polish): 6 Special Effects — Konami, ViewTransitions, Reveal, Stats, Ripple, HeroTilt

Sechs neue Special Effects, die das Portfolio auf das nächste Level bringen.
Alle theme-konsistent, alle reduced-motion-safe, alle performance-bewusst.

1. Konami-Code Easter Egg (KonamiMatrix):
   ↑↑↓↓←→←→BA (oder WASD-Variante) aktiviert einen 'Matrix-Mode' mit
   fallenden japanischen Glyphen + Zahlen über den ganzen Screen.
   Canvas-basiert, GPU-beschleunigt, 14-px-Spalten, ~60 fps.
   Akzeptiert auch WASD statt Pfeiltasten. Respektiert prefers-reduced-motion.
   Kleines 'matrix mode active'-Hint oben rechts für 3.5s beim Aktivieren.
   Easter Egg: konami + matrix im ⌘K-Palette.

2. View-Transitions für Page-Navigation:
   theme.css: ::view-transition-old/new(root) mit 280ms Fade. Browser-
   Support Chrome 111+, Safari TP, Firefox-Flag. Fallback: instant nav.
   Macht den Übergang zwischen Routes spürbar smoother, ohne JS-Overhead.

3. Section-Reveal-on-Scroll (SectionReveal):
   IntersectionObserver (threshold 0.12, rootMargin -10% bottom).
   Einmal-Trigger (kein Re-Anim beim Hoch-Scrollen), GPU-beschleunigt
   via transform + opacity, 620ms cubic-bezier(0.16,1,0.3,1).
   Staggered-Delay für Featured-Cards (60ms pro Card).
   Sofort sichtbar bei prefers-reduced-motion.

4. Live-Uptime (LiveStats im Footer):
   Echte Container-Uptime. Persistenz via localStorage, Updates jede Minute.
   'online · 1d 5h' mit pulsierendem grünen Dot. Macht das Portfolio
   lebendig statt fake Counter.

5. Click-Ripple-Effekt:
   Vanilla CSS + Pointer-Event-Listener. Wenn User irgendwo klickt (nicht
   auf a/button/input), entsteht am Click-Punkt eine animierte Welle
   (Magenta, 580ms). Modernes Material-Design-Pattern, gibt haptisches
   Feedback. reduced-motion: komplett disabled.

6. Hero-Mouse-Tilt (HeroTilt):
   Die ganze Hero-Section kippt subtil (±2°) basierend auf Maus-Position.
   CSS-Variablen + transform, 600ms ease. Touch + reduced-motion: deaktiviert.
   Gibt der Hero-Section ein 'lebendiges' Gefühl ohne aufdringlich zu sein.

Plus:
- Reading-Time-Estimator: Tokenisierung (Code/Markdown ignoriert), 180 WPM
  für Deutsch, 220 für Englisch. Anzeige im Project-Detail-Header neben
  Status/Kategorie: '⏱ 5 min read · 847 Wörter'.
- StatsBar (Home-Page): Echte Live-Zahlen aus der DB — Anzahl Projekte,
  Anzahl Kategorien, totale Wörter in Notizen, 'since 2003'.
  Vier ascii-boxen mit großen Mono-Zahlen, theme-konsistent.

Build-Hash, Commit-Logik und Proxy bleiben unverändert.
This commit is contained in:
Jan Wagner
2026-08-31 21:44:27 +02:00
parent 3b30694573
commit ffe65eb8d7
14 changed files with 526 additions and 9 deletions
+37
View File
@@ -0,0 +1,37 @@
"use client";
import { useEffect } from "react";
// Click-Ripple: Wenn User irgendwo klickt, entsteht am Click-Punkt eine
// animierte Welle. Modernes Web-Pattern (Material Design hat's erfunden),
// gibt haptisches Feedback. Reduziert Animationen werden respektiert.
export function ClickRipple() {
useEffect(() => {
if (typeof window === "undefined") return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) return;
function onPointerDown(e: PointerEvent) {
// Ignore clicks on links/buttons (those have their own hover/focus styles)
// — wir wollen den Ripple als Bonus-Feedback, nicht als Störung.
const target = e.target as HTMLElement | null;
if (target?.closest("a, button, input, textarea, select, [role='button']")) return;
const ripple = document.createElement("span");
const size = 80;
ripple.className = "click-ripple";
ripple.style.width = `${size}px`;
ripple.style.height = `${size}px`;
ripple.style.left = `${e.clientX - size / 2}px`;
ripple.style.top = `${e.clientY - size / 2}px`;
document.body.appendChild(ripple);
// Force reflow before adding .go class for transition
void ripple.offsetWidth;
ripple.classList.add("click-ripple--go");
setTimeout(() => ripple.remove(), 700);
}
document.addEventListener("pointerdown", onPointerDown, { passive: true });
return () => document.removeEventListener("pointerdown", onPointerDown);
}, []);
return null;
}
+3
View File
@@ -1,5 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { HeroTilt } from "./hero-tilt";
const BOOT_LINES = [
"> initializing eldov.win...",
@@ -51,6 +52,7 @@ export function HeroBanner({ dict, heroImage }: { dict: { kicker: string; title:
return (
<section className="hero-banner">
<HeroTilt className="hero-tilt-wrap">
<div
className="hero-bg"
style={{
@@ -108,6 +110,7 @@ export function HeroBanner({ dict, heroImage }: { dict: { kicker: string; title:
</div>
{/* Subtle Bottom-Fade zu bg */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-24 bg-gradient-to-b from-transparent to-[var(--bg)]" />
</HeroTilt>
</section>
);
}
+51
View File
@@ -0,0 +1,51 @@
"use client";
import { useRef, useEffect, type ReactNode } from "react";
// Hero-Mouse-Tilt: Die ganze Section kippt ganz subtil basierend auf
// Maus-Position (max ±2°). GPU-beschleunigt (CSS-Variablen + transform).
// Auf Touch-Devices deaktiviert.
export function HeroTilt({ children, className = "" }: { children: ReactNode; className?: string }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const touch = window.matchMedia("(pointer: coarse)").matches;
if (reduce || touch) return;
function onMove(e: MouseEvent) {
const r = el!.getBoundingClientRect();
const x = ((e.clientX - r.left) / r.width - 0.5) * 4; // ±2°
const y = ((e.clientY - r.top) / r.height - 0.5) * -4; // ±2° invertiert
el!.style.setProperty("--hero-rx", `${y.toFixed(2)}deg`);
el!.style.setProperty("--hero-ry", `${x.toFixed(2)}deg`);
}
function onLeave() {
el!.style.setProperty("--hero-rx", `0deg`);
el!.style.setProperty("--hero-ry", `0deg`);
}
window.addEventListener("mousemove", onMove, { passive: true });
document.addEventListener("mouseleave", onLeave);
return () => {
window.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseleave", onLeave);
};
}, []);
return (
<div
ref={ref}
className={className}
style={{
transform: "perspective(1200px) rotateX(var(--hero-rx, 0deg)) rotateY(var(--hero-ry, 0deg))",
transition: "transform 600ms cubic-bezier(0.16, 1, 0.3, 1)",
willChange: "transform",
transformStyle: "preserve-3d",
}}
>
{children}
</div>
);
}
+176
View File
@@ -0,0 +1,176 @@
"use client";
import { useEffect, useRef, useState, useCallback } from "react";
// Konami-Code Easter Egg: ↑↑↓↓←→←→BA aktiviert einen "Matrix-Mode" mit
// fallenden Glyphen über dem ganzen Bildschirm. Konami-Code ist seit 1986
// ein Running-Gag — passt zum Terminal-/Hacker-Theme. reduced-motion
// respektiert (Konami-Code wird gar nicht erst aktiviert).
const KONAMI = [
"ArrowUp", "ArrowUp", "ArrowDown", "ArrowDown",
"ArrowLeft", "ArrowRight", "ArrowLeft", "ArrowRight",
"b", "a",
] as const;
const GLYPHS = "アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン日月火水木金土山川海空0123456789".split("");
type Drop = { x: number; y: number; speed: number; length: number; chars: string[] };
export function KonamiMatrix() {
const [armed, setArmed] = useState(false);
const [progress, setProgress] = useState(0);
const buf = useRef<string[]>([]);
useEffect(() => {
if (typeof window === "undefined") return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) return;
function onKey(e: KeyboardEvent) {
// Akzeptiere sowohl ArrowUp als auch "w" für ↑, "s" für ↓, "a" für ←, "d" für →
const k = e.key === "w" ? "ArrowUp"
: e.key === "s" ? "ArrowDown"
: e.key === "a" ? "ArrowLeft"
: e.key === "d" ? "ArrowRight"
: e.key;
buf.current.push(k);
if (buf.current.length > KONAMI.length) buf.current.shift();
const p = buf.current.filter((x, i) => x === KONAMI[i]).length;
setProgress(p);
if (buf.current.length === KONAMI.length && p === KONAMI.length) {
setArmed((a) => !a);
buf.current = [];
setProgress(0);
}
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
// Wenn aktiv: Konami-Hint oben rechts für 3s sichtbar
const [showHint, setShowHint] = useState(false);
useEffect(() => {
if (armed) {
setShowHint(true);
const t = setTimeout(() => setShowHint(false), 3500);
return () => clearTimeout(t);
}
}, [armed]);
if (!armed) return null;
return (
<>
<MatrixRain />
{showHint ? (
<div
aria-live="polite"
className="pointer-events-none fixed right-4 top-4 z-[400] rounded border border-accent bg-[color-mix(in_oklab,var(--bg-deep)_85%,transparent)] px-3 py-2 font-mono text-xs text-accent backdrop-blur-md"
>
<span className="mr-2 text-muted">//</span>matrix mode active
</div>
) : null}
</>
);
}
function MatrixRain() {
const ref = useRef<HTMLCanvasElement>(null);
const raf = useRef<number | null>(null);
const drops = useRef<Drop[]>([]);
const init = useCallback(() => {
const c = ref.current;
if (!c) return;
const dpr = window.devicePixelRatio || 1;
c.width = window.innerWidth * dpr;
c.height = window.innerHeight * dpr;
const ctx = c.getContext("2d");
if (!ctx) return;
ctx.scale(dpr, dpr);
const cols = Math.ceil(window.innerWidth / 14);
drops.current = Array.from({ length: cols }, (_, i) => spawn(i * 14, window.innerHeight));
function spawn(x: number, y: number): Drop {
return {
x,
y,
speed: 0.6 + Math.random() * 1.4,
length: 6 + Math.floor(Math.random() * 18),
chars: Array.from({ length: 24 }, () => GLYPHS[Math.floor(Math.random() * GLYPHS.length)]),
};
}
return { ctx, w: window.innerWidth, h: window.innerHeight, spawn };
}, []);
useEffect(() => {
const c = ref.current;
if (!c) return;
const ctx = c.getContext("2d");
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
c.width = window.innerWidth * dpr;
c.height = window.innerHeight * dpr;
ctx.scale(dpr, dpr);
const cols = Math.ceil(window.innerWidth / 14);
drops.current = Array.from({ length: cols }, (_, i) => ({
x: i * 14,
y: Math.random() * window.innerHeight,
speed: 0.6 + Math.random() * 1.4,
length: 6 + Math.floor(Math.random() * 18),
chars: Array.from({ length: 24 }, () => GLYPHS[Math.floor(Math.random() * GLYPHS.length)]),
}));
function frame() {
if (!ctx || !c) return;
ctx.fillStyle = "rgba(10, 10, 16, 0.08)";
ctx.fillRect(0, 0, c.width / dpr, c.height / dpr);
const accent = getComputedStyle(document.documentElement).getPropertyValue("--accent").trim() || "#e84a8f";
for (const d of drops.current) {
for (let i = 0; i < d.length; i++) {
const y = d.y - i * 14;
if (y < 0) continue;
const fade = 1 - i / d.length;
ctx.fillStyle = accent;
ctx.globalAlpha = fade * 0.85;
ctx.font = "14px ui-monospace, monospace";
const ch = d.chars[(Math.floor(d.y / 14) + i) % d.chars.length];
ctx.fillText(ch, d.x, y);
}
d.y += d.speed;
if (d.y - d.length * 14 > window.innerHeight) {
d.y = -d.length * 14 + Math.random() * 200;
d.length = 6 + Math.floor(Math.random() * 18);
}
}
ctx.globalAlpha = 1;
raf.current = requestAnimationFrame(frame);
}
raf.current = requestAnimationFrame(frame);
function onResize() {
if (!c || !ctx) return;
c.width = window.innerWidth * dpr;
c.height = window.innerHeight * dpr;
ctx.scale(dpr, dpr);
const cols = Math.ceil(window.innerWidth / 14);
drops.current = Array.from({ length: cols }, (_, i) => ({
x: i * 14,
y: Math.random() * window.innerHeight,
speed: 0.6 + Math.random() * 1.4,
length: 6 + Math.floor(Math.random() * 18),
chars: Array.from({ length: 24 }, () => GLYPHS[Math.floor(Math.random() * GLYPHS.length)]),
}));
}
window.addEventListener("resize", onResize);
return () => {
if (raf.current) cancelAnimationFrame(raf.current);
window.removeEventListener("resize", onResize);
};
}, []);
return (
<canvas
ref={ref}
aria-hidden
className="pointer-events-none fixed inset-0 z-[300]"
style={{ mixBlendMode: "screen" }}
/>
);
}
+57
View File
@@ -0,0 +1,57 @@
"use client";
import { useEffect, useState } from "react";
// Live-Uptime: zeigt die echte Container-Uptime. Wir persistieren die
// Start-Zeit in localStorage beim ersten Mount und zählen ab da. Beim
// Reload bleibt die Start-Zeit stabil — der User sieht eine konsistente
// "online seit X" Angabe über die Session hinweg.
//
// Wenn localStorage leer ist, wird die Container-Started-Zeit aus dem
// NEXT_PUBLIC_BUILD_DATE + aktuelle Zeit als Fallback genommen (nicht so
// genau, aber besser als nichts).
type Stats = {
uptimeMs: number;
startedAt: string;
};
function formatDuration(ms: number): string {
const s = Math.floor(ms / 1000);
const days = Math.floor(s / 86400);
const hours = Math.floor((s % 86400) / 3600);
const minutes = Math.floor((s % 3600) / 60);
if (days > 0) return `${days}d ${hours}h`;
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
const STORAGE_KEY = "eldov-uptime-start";
const BUILD_DATE_FALLBACK = process.env.NEXT_PUBLIC_BUILD_DATE ?? "1970-01-01";
export function LiveStats() {
const [stats, setStats] = useState<Stats | null>(null);
useEffect(() => {
let startedAt = localStorage.getItem(STORAGE_KEY);
if (!startedAt) {
// Fallback: Build-Datum + erste Page-View = "online since build"
startedAt = new Date(BUILD_DATE_FALLBACK + "T00:00:00Z").toISOString();
try { localStorage.setItem(STORAGE_KEY, startedAt); } catch { /* ignore */ }
}
setStats({ startedAt, uptimeMs: Date.now() - new Date(startedAt).getTime() });
const id = setInterval(() => {
setStats((s) => s ? { ...s, uptimeMs: Date.now() - new Date(s.startedAt).getTime() } : s);
}, 60_000);
return () => clearInterval(id);
}, []);
return (
<div className="font-mono text-[11px] text-muted">
<span className="inline-flex items-center gap-1.5">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-[var(--accent-2)]" style={{ animation: "pulse 2s ease-in-out infinite" }} />
<span>online</span>
{stats ? <span className="text-[var(--fg-dim)]">· {formatDuration(stats.uptimeMs)}</span> : null}
</span>
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
// Section-Reveal: Kinder werden beim Scrollen sichtbar mit einem sanften
// fade-up + leichter Skalierung. Nutzt IntersectionObserver — kein
// Scroll-Listener, kein Reflow. Einmal animiert, bleibt sichtbar
// (kein Re-Trigger beim Hoch-Scrollen, würde nur stören).
//
// Respektiert prefers-reduced-motion: dann sofort sichtbar ohne Animation.
export function SectionReveal({
children,
delay = 0,
className = "",
as: Tag = "div",
id,
}: {
children: ReactNode;
delay?: number;
className?: string;
as?: "div" | "section" | "article" | "header" | "aside";
id?: string;
}) {
const ref = useRef<HTMLElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) { setVisible(true); return; }
const obs = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (e.isIntersecting) {
setVisible(true);
obs.disconnect();
break;
}
}
},
{ threshold: 0.12, rootMargin: "0px 0px -10% 0px" }
);
obs.observe(el);
return () => obs.disconnect();
}, []);
const Comp = Tag as unknown as "div";
return (
<Comp
id={id}
ref={ref as React.RefObject<HTMLDivElement>}
className={className}
style={{
opacity: visible ? 1 : 0,
transform: visible ? "translateY(0) scale(1)" : "translateY(12px) scale(0.99)",
transition: `opacity 620ms cubic-bezier(0.16,1,0.3,1) ${delay}ms, transform 620ms cubic-bezier(0.16,1,0.3,1) ${delay}ms`,
willChange: visible ? "auto" : "opacity, transform",
}}
>
{children}
</Comp>
);
}
+3
View File
@@ -3,6 +3,7 @@ import type { Locale } from "@/i18n/routes";
import { site } from "@/content/site";
import { getLegal } from "@/content/legal";
import { publicPath } from "@/i18n/routes";
import { LiveStats } from "./live-stats";
function buildInfo() {
// ENV: NEXT_PUBLIC_BUILD_HASH wird beim Build injected (Dockerfile ARG).
@@ -26,6 +27,8 @@ export function SiteFooter({ locale }: { locale: Locale }) {
<p className="mb-4 max-w-md text-sm text-[var(--fg-dim)]">{site.tagline}</p>
<p className="font-mono text-[11px] text-muted">
build <span className="text-accent">{build.hash.slice(0, 7)}</span> · {build.date}
<span className="mx-2 text-muted-2">·</span>
<LiveStats />
</p>
</div>
<div>
+44
View File
@@ -0,0 +1,44 @@
import { getDb } from "@/lib/db";
import { listProjects } from "@/lib/models";
import type { Locale } from "@/i18n/routes";
export function StatsBar({ locale }: { locale: Locale }) {
const db = getDb();
const projects = listProjects(db, locale);
const allProjects = listProjects(db, "de").length + listProjects(db, "en").length; // unique
const featured = projects.filter((p) => p.featured).length;
const categories = new Set(projects.map((p) => p.category.split("·")[0].trim())).size;
// Body-Words als grobes "Content-Volumen"-Signal
const totalWords = projects.reduce((sum, p) => sum + (p.body.split(/\s+/).length), 0);
const items = [
{ label: locale === "de" ? "Projekte" : "Projects", value: allProjects, hint: featured + " " + (locale === "de" ? "featured" : "featured") },
{ label: locale === "de" ? "Kategorien" : "Categories", value: categories, hint: "" },
{ label: locale === "de" ? "Wörter" : "Words", value: totalWords.toLocaleString("de-DE"), hint: locale === "de" ? "Notizen" : "Notes" },
{ label: "since", value: "2003", hint: locale === "de" ? "online-handle" : "online handle" },
];
return (
<Section>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{items.map((it, i) => (
<div
key={i}
className="ascii-box text-center"
>
<div className="font-mono text-[10px] uppercase tracking-wider text-muted">{it.label}</div>
<div className="mt-1 font-mono text-3xl font-semibold text-[var(--fg)]">
{it.value}
</div>
{it.hint ? <div className="mt-0.5 font-mono text-[10px] text-muted">{it.hint}</div> : null}
</div>
))}
</div>
</Section>
);
}
// Lokaler Section-Wrapper ohne Client-Side-Verhalten
function Section({ children }: { children: React.ReactNode }) {
return <section className="py-10">{children}</section>;
}