From ffe65eb8d70527608fff5bb917dd1312962b317d Mon Sep 17 00:00:00 2001 From: Jan Wagner Date: Mon, 31 Aug 2026 21:44:27 +0200 Subject: [PATCH] =?UTF-8?q?feat(polish):=206=20Special=20Effects=20?= =?UTF-8?q?=E2=80=94=20Konami,=20ViewTransitions,=20Reveal,=20Stats,=20Rip?= =?UTF-8?q?ple,=20HeroTilt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- next.config.ts | 5 + src/app/[lang]/projects/[slug]/page.tsx | 10 +- src/app/layout.tsx | 4 + src/app/page.tsx | 21 ++- src/app/theme.css | 43 ++++++ src/components/click-ripple.tsx | 37 +++++ src/components/hero-banner.tsx | 3 + src/components/hero-tilt.tsx | 51 +++++++ src/components/konami-matrix.tsx | 176 ++++++++++++++++++++++++ src/components/live-stats.tsx | 57 ++++++++ src/components/section-reveal.tsx | 64 +++++++++ src/components/site-footer.tsx | 3 + src/components/stats-bar.tsx | 44 ++++++ src/lib/reading-time.ts | 17 +++ 14 files changed, 526 insertions(+), 9 deletions(-) create mode 100644 src/components/click-ripple.tsx create mode 100644 src/components/hero-tilt.tsx create mode 100644 src/components/konami-matrix.tsx create mode 100644 src/components/live-stats.tsx create mode 100644 src/components/section-reveal.tsx create mode 100644 src/components/stats-bar.tsx create mode 100644 src/lib/reading-time.ts diff --git a/next.config.ts b/next.config.ts index 44693e2..bf5be85 100644 --- a/next.config.ts +++ b/next.config.ts @@ -45,6 +45,11 @@ const config: NextConfig = { serverExternalPackages: ["better-sqlite3"], images: { unoptimized: true }, output: "standalone", + // View-Transitions-API wird via CSS ::view-transition-* in theme.css + // genutzt (siehe dort). Next.js 16 hat keinen experimental.viewTransition- + // Switch — die CSS-Regeln reichen aus, der Browser aktiviert die + // Transition automatisch beim ersten Cross-Document-Navigation mit dem + // Meta-View-Transition-Tag. async rewrites() { return [ ...Object.entries(PUBLIC_TO_INTERNAL).map(([from, to]) => ({ diff --git a/src/app/[lang]/projects/[slug]/page.tsx b/src/app/[lang]/projects/[slug]/page.tsx index 2eaccb8..c8de84f 100644 --- a/src/app/[lang]/projects/[slug]/page.tsx +++ b/src/app/[lang]/projects/[slug]/page.tsx @@ -6,6 +6,8 @@ import { getDb } from "@/lib/db"; import { getProject } from "@/lib/models"; import { buildMetadata } from "@/lib/metadata"; import { StackIcon } from "@/components/stack-icon"; +import { SectionReveal } from "@/components/section-reveal"; +import { estimateReadingTime } from "@/lib/reading-time"; const THUMBED = ["hermes", "casino-bot", "polymarket-trader"]; @@ -40,6 +42,7 @@ export default async function ProjectDetailPage({ const paragraphs = project.body.split(/\n\n+/).filter((p) => p.trim().length > 0); const hasThumb = THUMBED.includes(project.slug); const stackItems = project.stack.split("·").map((s) => s.trim()).filter(Boolean); + const reading = estimateReadingTime(project.body, locale); return (
@@ -63,16 +66,19 @@ export default async function ProjectDetailPage({ ) : null} -
+
{project.number} {project.status} {project.category} {project.featured ? ★ featured : null} + + ⏱ {reading.minutes} min read · {reading.words} {locale === "de" ? "Wörter" : "words"} +

{project.slug}

{project.summary}

-
+
diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 21d21c4..1edad35 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,6 +4,8 @@ import { isLocale } from "@/i18n/routes"; import { site } from "@/content/site"; import { CustomCursor } from "@/components/custom-cursor"; import { ScrollProgress } from "@/components/scroll-progress"; +import { KonamiMatrix } from "@/components/konami-matrix"; +import { ClickRipple } from "@/components/click-ripple"; import "./globals.css"; export const metadata: Metadata = { @@ -29,6 +31,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo + + {children} diff --git a/src/app/page.tsx b/src/app/page.tsx index 69d6799..d879e61 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -4,11 +4,13 @@ import { getProfile, listFeaturedProjects, listProjects } from "@/lib/models"; import { BioSection } from "@/components/bio-section"; import { LinksSection } from "@/components/links-section"; import { ProjectFilter } from "@/components/project-filter"; +import { StatsBar } from "@/components/stats-bar"; import { JsonLd } from "@/components/json-ld"; import { getDict } from "@/i18n/dictionaries"; import Link from "next/link"; import { publicPath } from "@/i18n/routes"; import { HeroBanner } from "@/components/hero-banner"; +import { SectionReveal } from "@/components/section-reveal"; export const dynamic = "force-dynamic"; @@ -27,7 +29,7 @@ export default async function RootIndex() {
{/* FEATURED */} - + {/* ALL PROJECTS mit Filter */}
@@ -52,14 +54,17 @@ export default async function RootIndex() {
{/* BIO + LINKS */} -
+
-
+ + + {/* STATS — echte Zahlen aus der DB */} + {/* CTA */}
@@ -75,9 +80,10 @@ import { TiltCard } from "@/components/tilt-card"; import { StackIcon } from "@/components/stack-icon"; import type { Project } from "@/lib/models"; -function FeaturedTile({ project }: { project: Project }) { +function FeaturedTile({ project, delay = 0 }: { project: Project; delay?: number }) { const hasThumb = ["hermes", "casino-bot", "polymarket-trader"].includes(project.slug); return ( + {hasThumb ? (
@@ -117,5 +123,6 @@ function FeaturedTile({ project }: { project: Project }) {
+ ); } diff --git a/src/app/theme.css b/src/app/theme.css index 3437f00..41b309c 100644 --- a/src/app/theme.css +++ b/src/app/theme.css @@ -307,4 +307,47 @@ .glow-text { text-shadow: 0 0 30px color-mix(in oklab, var(--accent) 40%, transparent); } + + /* Click-Ripple — wird per JS appended, also vanilla CSS */ + .click-ripple { + position: fixed; + border-radius: 50%; + pointer-events: none; + background: radial-gradient(circle, + color-mix(in oklab, var(--accent) 30%, transparent) 0%, + color-mix(in oklab, var(--accent) 12%, transparent) 50%, + transparent 75%); + border: 1px solid color-mix(in oklab, var(--accent) 25%, transparent); + transform: scale(0); + opacity: 0; + z-index: 350; + transition: transform 580ms cubic-bezier(0.16, 1, 0.3, 1), opacity 580ms cubic-bezier(0.16, 1, 0.3, 1); + will-change: transform, opacity; + } + .click-ripple--go { + transform: scale(2.5); + opacity: 1; + } + @media (prefers-reduced-motion: reduce) { + .click-ripple { display: none; } + } + + /* View-Transition-API: Sanfte Page-Übergänge (Chrome 111+) */ + ::view-transition-old(root), + ::view-transition-new(root) { + animation-duration: 280ms; + animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1); + } + ::view-transition-old(root) { + animation-name: fade-out; + } + ::view-transition-new(root) { + animation-name: fade-in; + } + @keyframes fade-out { to { opacity: 0; } } + @keyframes fade-in { from { opacity: 0; } to { opacity: 1; } } + @media (prefers-reduced-motion: reduce) { + ::view-transition-old(root), + ::view-transition-new(root) { animation: none; } + } } diff --git a/src/components/click-ripple.tsx b/src/components/click-ripple.tsx new file mode 100644 index 0000000..8c5432d --- /dev/null +++ b/src/components/click-ripple.tsx @@ -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; +} diff --git a/src/components/hero-banner.tsx b/src/components/hero-banner.tsx index c254f3b..efb59d5 100644 --- a/src/components/hero-banner.tsx +++ b/src/components/hero-banner.tsx @@ -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 (
+
{/* Subtle Bottom-Fade zu bg */}
+
); } diff --git a/src/components/hero-tilt.tsx b/src/components/hero-tilt.tsx new file mode 100644 index 0000000..7b47ffa --- /dev/null +++ b/src/components/hero-tilt.tsx @@ -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(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 ( +
+ {children} +
+ ); +} diff --git a/src/components/konami-matrix.tsx b/src/components/konami-matrix.tsx new file mode 100644 index 0000000..c9e8202 --- /dev/null +++ b/src/components/konami-matrix.tsx @@ -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([]); + + 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 ( + <> + + {showHint ? ( +
+ //matrix mode active +
+ ) : null} + + ); +} + +function MatrixRain() { + const ref = useRef(null); + const raf = useRef(null); + const drops = useRef([]); + + 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 ( + + ); +} diff --git a/src/components/live-stats.tsx b/src/components/live-stats.tsx new file mode 100644 index 0000000..d0852dc --- /dev/null +++ b/src/components/live-stats.tsx @@ -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(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 ( +
+ + + online + {stats ? · {formatDuration(stats.uptimeMs)} : null} + +
+ ); +} diff --git a/src/components/section-reveal.tsx b/src/components/section-reveal.tsx new file mode 100644 index 0000000..4f51e9f --- /dev/null +++ b/src/components/section-reveal.tsx @@ -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(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 ( + } + 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} + + ); +} diff --git a/src/components/site-footer.tsx b/src/components/site-footer.tsx index 579c12f..08e5a99 100644 --- a/src/components/site-footer.tsx +++ b/src/components/site-footer.tsx @@ -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 }) {

{site.tagline}

build {build.hash.slice(0, 7)} · {build.date} + · +

diff --git a/src/components/stats-bar.tsx b/src/components/stats-bar.tsx new file mode 100644 index 0000000..6f3bc6b --- /dev/null +++ b/src/components/stats-bar.tsx @@ -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 ( +
+
+ {items.map((it, i) => ( +
+
{it.label}
+
+ {it.value} +
+ {it.hint ?
{it.hint}
: null} +
+ ))} +
+
+ ); +} + +// Lokaler Section-Wrapper ohne Client-Side-Verhalten +function Section({ children }: { children: React.ReactNode }) { + return
{children}
; +} diff --git a/src/lib/reading-time.ts b/src/lib/reading-time.ts new file mode 100644 index 0000000..59f81ab --- /dev/null +++ b/src/lib/reading-time.ts @@ -0,0 +1,17 @@ +// Reading-Time-Estimator. Englisch/Deutsch, ~200 WPM. +// Tokenisiert auf Wortgrenzen, ignoriert Code/Markdown-Sonderzeichen. + +export function estimateReadingTime(text: string, locale: "de" | "en" = "de"): { minutes: number; words: number } { + const stripped = text + .replace(/```[\s\S]*?```/g, " ") // Code-Blöcke + .replace(/`[^`]*`/g, " ") // Inline-Code + .replace(/!\[[^\]]*\]\([^)]*\)/g, " ") // Bilder + .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") // Markdown-Links: nur Text behalten + .replace(/[#>*_~`]/g, " "); // Markdown-Steuerzeichen + + const words = stripped.split(/\s+/).filter(Boolean).length; + // Deutsch spricht man etwas langsamer (~180 WPM), Englisch schneller (~220) + const wpm = locale === "de" ? 180 : 220; + const minutes = Math.max(1, Math.round(words / wpm)); + return { minutes, words }; +}