feat(design): self-hosted Fonts, Kontrast-Fixes, Status-i18n und Feinschliff

- Inter + JetBrains Mono als WOFF2 self-hosted via next/font/local (keine Third-Party-Requests)
- --muted auf #8a8aa5 (WCAG-AA >= 4.5:1), --muted-2 als rein dekoratives Token
- formatStatus() in dictionaries: DB-Status bleibt deutsch, EN-Oberflaeche uebersetzt
- Featured-Dopplung auf der Startseite entfernt, Mobile-Card-Kategorie truncated,
  Sie->du vereinheitlicht, Glitch auf 3 Zyklen begrenzt, Reading-Time ab 300 Woertern
- Cleanup: tote Root-Page (proxy rewrited / -> /de) und verwaiste Komponenten
  (HeroBanner, HeroTilt, StatsBar, TiltCard) samt CSS und hero-bg.jpg entfernt
This commit is contained in:
Jan Wagner
2026-09-04 20:49:16 +02:00
parent ffe65eb8d7
commit cac603a89d
18 changed files with 70 additions and 523 deletions
+3
View File
@@ -14,3 +14,6 @@ data/*.db-*
tsconfig.tsbuildinfo
.graphify*
.graphify-out
# KDE-Metadaten
.directory
Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

+1 -1
View File
@@ -72,7 +72,7 @@ export default async function HomePage({
{dict.actions.viewProjects} →
</Link>
</div>
<ProjectGrid projects={all.slice(0, 6)} locale={locale} />
<ProjectGrid projects={all.filter((p) => !p.featured).slice(0, 6)} locale={locale} />
</div>
<aside className="space-y-6">
<BioSection profile={profile} locale={locale} />
+4 -2
View File
@@ -1,7 +1,7 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { isLocale, type Locale, publicPath } from "@/i18n/routes";
import { getDict } from "@/i18n/dictionaries";
import { getDict, formatStatus } from "@/i18n/dictionaries";
import { getDb } from "@/lib/db";
import { getProject } from "@/lib/models";
import { buildMetadata } from "@/lib/metadata";
@@ -69,12 +69,14 @@ export default async function ProjectDetailPage({
<SectionReveal as="header" className="ascii-box mb-8">
<div className="mb-3 flex flex-wrap items-center gap-2">
<span className="font-mono text-xs text-muted">{project.number}</span>
<span className={`badge ${project.featured ? "badge--live" : "badge--muted"}`}>{project.status}</span>
<span className={`badge ${project.featured ? "badge--live" : "badge--muted"}`}>{formatStatus(project.status, locale)}</span>
<span className="font-mono text-xs text-muted">{project.category}</span>
{project.featured ? <span className="badge badge--accent">★ featured</span> : null}
{reading.words >= 300 ? (
<span className="ml-auto font-mono text-[10px] uppercase tracking-wider text-muted">
⏱ {reading.minutes} min read · {reading.words} {locale === "de" ? "Wörter" : "words"}
</span>
) : null}
</div>
<h1 className="mb-3 text-4xl font-semibold tracking-tight">{project.slug}</h1>
<p className="text-lg text-[var(--fg-dim)]">{project.summary}</p>
+17 -1
View File
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import localFont from "next/font/local";
import { headers } from "next/headers";
import { isLocale } from "@/i18n/routes";
import { site } from "@/content/site";
@@ -23,11 +24,26 @@ export const metadata: Metadata = {
robots: { index: true, follow: true },
};
const inter = localFont({
src: [
{ path: "../fonts/inter-latin-wght-normal.woff2", style: "normal" },
{ path: "../fonts/inter-latin-wght-italic.woff2", style: "italic" },
],
variable: "--font-inter",
display: "swap",
});
const jetbrainsMono = localFont({
src: "../fonts/jetbrains-mono-latin-wght-normal.woff2",
variable: "--font-jetbrains-mono",
display: "swap",
});
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const h = await headers();
const lang = isLocale(h.get("x-locale") ?? "") ? (h.get("x-locale") as "de" | "en") : "de";
return (
<html lang={lang} className="h-full antialiased">
<html lang={lang} className={`h-full antialiased ${inter.variable} ${jetbrainsMono.variable}`}>
<body className="min-h-full">
<ScrollProgress />
<CustomCursor />
-128
View File
@@ -1,128 +0,0 @@
import { getDb } from "@/lib/db";
import { seedIfEmpty } from "@/lib/seed";
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";
export default async function RootIndex() {
const db = getDb();
seedIfEmpty(db);
const profile = getProfile(db, "de");
const featured = listFeaturedProjects(db, "de");
const all = listProjects(db, "de");
const dict = getDict("de");
return (
<>
<JsonLd profile={profile} featured={featured} />
<HeroBanner dict={dict.home} heroImage="/img/hero/hero-bg.jpg" />
<div className="mx-auto max-w-[var(--max-w)] px-4 md:px-6">
{/* FEATURED */}
<SectionReveal as="section" id="featured" className="py-16">
<div className="section-heading">
<span className="section-heading__label">~/featured</span>
<h2 className="section-heading__title">{dict.home.featuredLabel}</h2>
<Link href={publicPath("de", "projects")} className="ml-auto font-mono text-xs uppercase text-accent hover:underline" data-cursor="hover">
{dict.actions.viewProjects} →
</Link>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{featured.map((p, i) => (
<FeaturedTile key={p.slug} project={p} delay={i * 60} />
))}
</div>
</SectionReveal>
{/* ALL PROJECTS mit Filter */}
<section className="py-12">
<div className="section-heading">
<span className="section-heading__label">~/projects</span>
<h2 className="section-heading__title">{dict.home.allProjectsLabel}</h2>
</div>
<ProjectFilter projects={all} locale="de" emptyLabel={dict.projects.emptyState} />
</section>
{/* BIO + LINKS */}
<SectionReveal className="grid gap-6 py-12 lg:grid-cols-3">
<div className="lg:col-span-2">
<BioSection profile={profile} locale="de" />
</div>
<aside>
<LinksSection profile={profile} locale="de" />
</aside>
</SectionReveal>
{/* STATS — echte Zahlen aus der DB */}
<StatsBar locale="de" />
{/* CTA */}
<section className="ascii-box my-12">
<p className="font-mono text-xs uppercase tracking-wider text-accent">{dict.home.cta.title}</p>
<p className="mt-2 text-[var(--fg)]">{dict.home.cta.body}</p>
</section>
</div>
</>
);
}
import { TiltCard } from "@/components/tilt-card";
import { StackIcon } from "@/components/stack-icon";
import type { Project } from "@/lib/models";
function FeaturedTile({ project, delay = 0 }: { project: Project; delay?: number }) {
const hasThumb = ["hermes", "casino-bot", "polymarket-trader"].includes(project.slug);
return (
<SectionReveal delay={delay}>
<TiltCard href={`/projekte/${project.slug}`} className="ascii-box !p-0">
{hasThumb ? (
<div className="relative aspect-[16/9] overflow-hidden">
<img
src={`/img/projects/${project.slug}.jpg`}
alt=""
loading="lazy"
className="h-full w-full object-cover opacity-70 transition-opacity duration-300 group-hover:opacity-100"
style={{ filter: "saturate(0.85) contrast(1.05)" }}
/>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-[var(--panel)] via-transparent to-transparent" />
<span className="absolute right-2 top-2 font-mono text-[10px] uppercase tracking-wider text-accent mix-blend-screen">
{project.number}
</span>
</div>
) : (
<div className="flex aspect-[16/9] items-center justify-center bg-[var(--bg-glow)]">
<span className="font-mono text-6xl text-muted opacity-40">{project.number}</span>
</div>
)}
<div className="p-5">
<div className="mb-2 flex items-center justify-between gap-2">
<span className="font-mono text-[10px] uppercase tracking-wider text-muted">{project.category}</span>
<span className={`badge ${project.featured ? "badge--live" : "badge--muted"}`}>{project.status}</span>
</div>
<h3 className="mb-2 text-lg font-semibold">
<span className="hover:text-accent">{project.slug}</span>
</h3>
<p className="text-sm text-[var(--fg-dim)]">{project.summary}</p>
<div className="mt-4 flex flex-wrap gap-1.5">
{project.stack.split("·").slice(0, 4).map((s, i) => (
<span key={i} className="stack-pill">
<StackIcon name={s} />
{s.trim()}
</span>
))}
</div>
</div>
</TiltCard>
</SectionReveal>
);
}
+13 -56
View File
@@ -16,8 +16,8 @@
--panel-2: #1a1a26;
--fg: #e7e7ee;
--fg-dim: #b8b8c8;
--muted: #6e6e85;
--muted-2: #4a4a5c;
--muted: #8a8aa5; /* WCAG-AA auf --bg (>= 4.5:1) */
--muted-2: #66667d; /* nur dekorativ (Separatoren, Hairline-Text) */
--hairline: #232333;
--hairline-2: #1d1d2a;
--accent: #e84a8f; /* Magenta */
@@ -26,13 +26,13 @@
--accent-blue: #6cb8ff; /* Selten — für Links/Info */
--danger: #ff5c5c;
/* Typografie */
--font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI",
/* Typografie — Fonts werden via next/font/local in layout.tsx geladen
* (self-hosted WOFF2 in src/fonts/, keine Third-Party-Requests). */
--font-sans: var(--font-inter), ui-sans-serif, system-ui, -apple-system, "Segoe UI",
Roboto, "Helvetica Neue", Arial, sans-serif;
--font-display: "Inter Display", "Inter", ui-sans-serif, system-ui, sans-serif;
--font-mono: "JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular,
--font-display: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
--font-mono: var(--font-jetbrains-mono), ui-monospace, SFMono-Regular,
Menlo, Consolas, monospace;
--font-serif: "Newsreader", ui-serif, Georgia, serif;
/* Spacing & Layout */
--max-w: 72rem; /* 1152px */
@@ -199,7 +199,7 @@
mix-blend-mode: screen;
opacity: 0.55;
clip-path: polygon(0 0, 100% 0, 100% 45%, 0 45%);
animation: glitch-x 4s steps(2) infinite;
animation: glitch-x 4s steps(2) 3, glitch-fade 300ms var(--ease-out) 11.7s forwards;
}
.headline-glitch::after {
color: var(--accent-2);
@@ -207,7 +207,11 @@
mix-blend-mode: screen;
opacity: 0.55;
clip-path: polygon(0 55%, 100% 55%, 100% 100%, 0 100%);
animation: glitch-x 4s steps(2) infinite reverse;
animation: glitch-x 4s steps(2) 3 reverse, glitch-fade 300ms var(--ease-out) 11.7s forwards;
}
@keyframes glitch-fade {
to { opacity: 0; }
}
@keyframes glitch-x {
@@ -218,27 +222,6 @@
99% { transform: translate(3px, 1px); }
}
/* Tilt-Card — leichter 3D-Hover */
.tilt-card {
transform-style: preserve-3d;
transform: perspective(1000px) rotateX(0deg) rotateY(0deg) translateZ(0);
transition: transform 380ms var(--ease-out), border-color 220ms var(--ease-out);
}
.tilt-card:hover {
transform: perspective(1000px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)) translateZ(8px);
}
.tilt-card .tilt-shine {
position: absolute;
inset: 0;
pointer-events: none;
background: radial-gradient(600px circle at var(--mx, 50%) var(--my, 50%),
color-mix(in oklab, var(--accent) 18%, transparent) 0%,
transparent 60%);
opacity: 0;
transition: opacity 280ms var(--ease-out);
}
.tilt-card:hover .tilt-shine { opacity: 1; }
/* Marquee-Tag-Liste */
.tag {
display: inline-flex;
@@ -282,32 +265,6 @@
transition: width 80ms linear;
}
/* Hero-Image als Background mit Tilt-Pointer */
.hero-bg {
position: absolute;
inset: 0;
background-image: var(--hero-bg);
background-size: cover;
background-position: center;
opacity: var(--hero-opacity, 0.16);
mask-image: radial-gradient(ellipse 80% 80% at 50% 30%, black 0%, transparent 75%);
-webkit-mask-image: radial-gradient(ellipse 80% 80% at 50% 30%, black 0%, transparent 75%);
pointer-events: none;
}
/* Hero-Banner — full-bleed oben */
.hero-banner {
position: relative;
isolation: isolate;
overflow: hidden;
border-bottom: 1px solid var(--hairline);
}
/* Glow-Text */
.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;
+2 -1
View File
@@ -4,6 +4,7 @@ import { useRouter } from "next/navigation";
import Link from "next/link";
import type { Locale } from "@/i18n/routes";
import { publicPath } from "@/i18n/routes";
import { formatStatus } from "@/i18n/dictionaries";
type Item = {
id: string;
@@ -68,7 +69,7 @@ export function CommandPalette({
const projectItems: Item[] = projects.map((p) => ({
id: `proj-${p.slug}`,
label: p.slug,
hint: `${p.category} · ${p.status}`,
hint: `${p.category} · ${formatStatus(p.status, locale)}`,
group: "project",
action: closeAndGo(publicPath(locale, "projectItem", p.slug)),
}));
-167
View File
@@ -1,167 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { HeroTilt } from "./hero-tilt";
const BOOT_LINES = [
"> initializing eldov.win...",
"> loading context bus...",
"> scanning fleet...",
"> ready.",
];
const ROTATING = [
"Self-Hoster",
"Bot-Bauer",
"Multi-Agent-Fleet-Owner",
"Open-Source-Themer",
"Casino-Bot-Dev",
"Prediction-Market-Trader",
];
export function HeroBanner({ dict, heroImage }: { dict: { kicker: string; title: string; lede: string; cta: { title: string; body: string } }; heroImage: string }) {
const [lineIdx, setLineIdx] = useState(0);
const [typed, setTyped] = useState("");
const [done, setDone] = useState(false);
const [rotIdx, setRotIdx] = useState(0);
// Boot-Animation: zeilenweise tippen, 280ms pro Zeile.
useEffect(() => {
if (lineIdx >= BOOT_LINES.length) { setDone(true); return; }
const line = BOOT_LINES[lineIdx];
let i = 0;
const id = setInterval(() => {
i += 1;
setTyped(line.slice(0, i));
if (i >= line.length) {
clearInterval(id);
setTimeout(() => {
setLineIdx((v) => v + 1);
setTyped("");
}, 380);
}
}, 24);
return () => clearInterval(id);
}, [lineIdx]);
// Rotating titles (für die Unterzeile), 2.5s pro Wort.
useEffect(() => {
if (!done) return;
const id = setInterval(() => setRotIdx((v) => (v + 1) % ROTATING.length), 2500);
return () => clearInterval(id);
}, [done]);
return (
<section className="hero-banner">
<HeroTilt className="hero-tilt-wrap">
<div
className="hero-bg"
style={{
["--hero-bg" as string]: `url('${heroImage}')`,
}}
/>
{/* Grid-Linien (sehr subtil) */}
<div
aria-hidden
className="absolute inset-0 opacity-[0.04]"
style={{
backgroundImage:
"linear-gradient(var(--accent) 1px, transparent 1px), linear-gradient(90deg, var(--accent) 1px, transparent 1px)",
backgroundSize: "64px 64px",
maskImage: "linear-gradient(to bottom, black, transparent 80%)",
WebkitMaskImage: "linear-gradient(to bottom, black, transparent 80%)",
}}
/>
<div className="relative mx-auto max-w-[var(--max-w)] px-6 py-24 md:py-36 lg:py-48">
<div className="grid gap-12 lg:grid-cols-[1.4fr_1fr] lg:items-center">
<div>
<p className="mb-4 flex items-center gap-2 font-mono text-xs uppercase tracking-[0.18em] text-accent">
<span className="inline-block h-1 w-6 bg-accent" />
{dict.kicker}
</p>
<h1
className="headline-glitch glow-text mb-6 text-5xl font-semibold leading-[1.05] tracking-tight md:text-7xl lg:text-8xl"
data-text={dict.title}
>
{dict.title}
</h1>
<p className="mb-8 max-w-xl text-lg leading-relaxed text-[var(--fg-dim)] md:text-xl">
{dict.lede}
</p>
<div className="flex flex-wrap items-center gap-3">
<a
href="#featured"
className="group inline-flex items-center gap-2 border border-accent bg-accent px-5 py-2.5 font-mono text-sm uppercase tracking-wider text-[var(--bg)] transition-colors hover:bg-transparent hover:text-accent"
data-cursor="hover"
>
<span>{dict.cta.title}</span>
<span aria-hidden className="transition-transform group-hover:translate-x-0.5">→</span>
</a>
<span className="font-mono text-xs text-muted">
<kbd className="rounded border border-hairline px-1.5 py-0.5">⌘</kbd>
<kbd className="ml-0.5 rounded border border-hairline px-1.5 py-0.5">K</kbd>
<span className="ml-2">search</span>
</span>
</div>
</div>
<div className="hidden lg:block">
<BootSequence typed={typed} lineIdx={lineIdx} done={done} rotIdx={rotIdx} />
</div>
</div>
</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>
);
}
function BootSequence({ typed, lineIdx, done, rotIdx }: { typed: string; lineIdx: number; done: boolean; rotIdx: number }) {
return (
<div
aria-hidden
className="ascii-box font-mono text-sm leading-relaxed"
style={{ boxShadow: "0 24px 60px rgba(0,0,0,0.5)" }}
>
<div className="mb-3 flex items-center justify-between border-b border-hairline pb-2">
<span className="flex items-center gap-2 text-muted">
<span className="inline-block h-2 w-2 rounded-full bg-[color-mix(in_oklab,var(--accent)_50%,transparent)]" />
<span className="text-[10px] uppercase tracking-wider">terminal ~ eldov</span>
</span>
<span className="text-[10px] uppercase tracking-wider text-muted">v0.42.0</span>
</div>
<div className="space-y-1.5 text-[var(--fg-dim)]">
{BOOT_LINES.slice(0, lineIdx).map((l, i) => (
<div key={i}>
<span className="text-muted">{l}</span>
</div>
))}
{!done ? (
<div>
<span className="text-accent">{BOOT_LINES[lineIdx]?.slice(0, typed.length)}</span>
<span className="cursor-blink inline-block w-2 -mb-0.5 bg-accent align-baseline">&nbsp;</span>
</div>
) : (
<>
<div className="text-accent">{BOOT_LINES[BOOT_LINES.length - 1]}</div>
<div className="mt-3 text-[var(--fg)]">
<span className="text-muted">role:</span>{" "}
<span
className="inline-block min-w-[8ch] text-accent"
style={{ transition: "opacity 200ms" }}
>
{ROTATING[rotIdx]}
</span>
<span className="cursor-blink inline-block w-2 -mb-0.5 bg-accent align-baseline">&nbsp;</span>
</div>
<div className="text-[var(--fg-dim)]">
<span className="text-muted">since:</span> 2003 (online-handle)
</div>
<div className="text-[var(--fg-dim)]">
<span className="text-muted">now:</span> building things that survive contact with users
</div>
</>
)}
</div>
</div>
);
}
-51
View File
@@ -1,51 +0,0 @@
"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>
);
}
+4 -4
View File
@@ -2,7 +2,7 @@ import Link from "next/link";
import type { Locale } from "@/i18n/routes";
import { publicPath } from "@/i18n/routes";
import type { Project } from "@/lib/models";
import { getDict } from "@/i18n/dictionaries";
import { getDict, formatStatus } from "@/i18n/dictionaries";
function statusTone(status: string): "live" | "warm" | "muted" {
const s = status.toLowerCase();
@@ -26,11 +26,11 @@ export function ProjectCard({
return (
<article className="ascii-box group flex flex-col gap-3 transition-colors hover:border-accent">
<header className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<div className="flex shrink-0 items-center gap-2">
<span className="font-mono text-xs text-muted">{project.number}</span>
<span className={`badge badge--${tone}`}>{project.status}</span>
<span className={`badge badge--${tone}`}>{formatStatus(project.status, locale)}</span>
</div>
<span className="font-mono text-xs text-muted">{project.category}</span>
<span className="min-w-0 truncate text-right font-mono text-xs text-muted">{project.category}</span>
</header>
<h3 className="text-lg font-semibold">
<Link href={href} className="hover:text-accent">
-44
View File
@@ -1,44 +0,0 @@
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>;
}
-57
View File
@@ -1,57 +0,0 @@
"use client";
import { useRef, type ReactNode } from "react";
export function TiltCard({
children,
href,
maxDeg = 4,
className = "",
}: {
children: ReactNode;
href?: string;
maxDeg?: number;
className?: string;
}) {
const ref = useRef<HTMLDivElement>(null);
function onMove(e: React.MouseEvent<HTMLDivElement>) {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const cx = rect.width / 2;
const cy = rect.height / 2;
const rx = ((y - cy) / cy) * -maxDeg;
const ry = ((x - cx) / cx) * maxDeg;
el.style.setProperty("--rx", `${rx.toFixed(2)}deg`);
el.style.setProperty("--ry", `${ry.toFixed(2)}deg`);
el.style.setProperty("--mx", `${(x / rect.width) * 100}%`);
el.style.setProperty("--my", `${(y / rect.height) * 100}%`);
}
function onLeave() {
const el = ref.current;
if (!el) return;
el.style.setProperty("--rx", `0deg`);
el.style.setProperty("--ry", `0deg`);
}
return (
<div
ref={ref}
onMouseMove={onMove}
onMouseLeave={onLeave}
className={`tilt-card relative overflow-hidden ${className}`}
data-cursor="hover"
>
<div className="tilt-shine" />
{href ? (
<a href={href} className="block">
{children}
</a>
) : (
children
)}
</div>
);
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
+22 -7
View File
@@ -19,15 +19,15 @@ export const dictionaries = {
home: {
kicker: "eldov · Self-Hosting, Bots, AI-Fleet",
title: "Dinge, die ich nachts baue.",
lede: "Privates Portfolio. Hier stehen Hermes, Bots, Themes, Freewarez und Hacks — alles, was nicht in die Auftragsarbeit gehört. Industrie-Software und Kundenprojekte finden Sie auf w-make.com.",
lede: "Privates Portfolio. Hier stehen Hermes, Bots, Themes, Freewarez und Hacks — alles, was nicht in die Auftragsarbeit gehört. Industrie-Software und Kundenprojekte findest du auf w-make.com.",
statusLabel: "Status",
lastUpdatedLabel: "Zuletzt aktualisiert",
featuredLabel: "Ausgewählte Projekte",
allProjectsLabel: "Alle Projekte",
linksLabel: "Wo Sie mich finden",
allProjectsLabel: "Weitere Projekte",
linksLabel: "Wo du mich findest",
factsLabel: "Setup & Stack",
cta: {
title: "Wenn Sie bis hier gelesen haben:",
title: "Wenn du bis hier gelesen hast:",
body: "Mehr Kontext steht in den Notizen und im Quellcode. Fragen, die kein Kunde stellen würde, sind willkommen.",
},
},
@@ -50,7 +50,7 @@ export const dictionaries = {
},
contact: {
title: "Kontakt",
lede: "Ich betreibe kein Kontaktformular. Wenn Sie schreiben wollen, nutzen Sie die Profile unten.",
lede: "Ich betreibe kein Kontaktformular. Wenn du schreiben willst, nutze die Profile unten.",
channelsLabel: "Kanäle",
},
legal: {
@@ -72,7 +72,7 @@ export const dictionaries = {
dashboardTitle: "Übersicht",
dashboardProfile: "Profil",
dashboardProjects: "Projekte",
dashboardSeed: "Beim ersten Aufruf wurde die Datenbank mit Inhalt gefüllt. Sie können alles hier anpassen.",
dashboardSeed: "Beim ersten Aufruf wurde die Datenbank mit Inhalt gefüllt. Du kannst alles hier anpassen.",
profileTitle: "Profil bearbeiten",
projectsTitle: "Projekte",
newProject: "Neues Projekt",
@@ -106,7 +106,7 @@ export const dictionaries = {
statusLabel: "Status",
lastUpdatedLabel: "Last updated",
featuredLabel: "Featured projects",
allProjectsLabel: "All projects",
allProjectsLabel: "More projects",
linksLabel: "Where to find me",
factsLabel: "Setup & stack",
cta: {
@@ -172,3 +172,18 @@ export const dictionaries = {
export function getDict(locale: Locale) {
return dictionaries[locale];
}
const statusHeadTranslations: Record<string, string> = {
aktiv: "Active",
live: "Live",
"early stage": "Early Stage",
"stabilitäts-hardening": "Stability hardening",
};
/** Status-Strings liegen in der DB auf Deutsch; EN übersetzt den Kopf, der Detail-Suffix bleibt. */
export function formatStatus(status: string, locale: Locale): string {
if (locale === "de") return status;
const [head, ...rest] = status.split("·").map((s) => s.trim());
const translated = statusHeadTranslations[head.toLowerCase()] ?? head;
return [translated, ...rest].join(" · ");
}
+1 -1
View File
@@ -14,7 +14,7 @@ const PROFILE_DEFAULTS = {
"Hermes ist meine Multi-Agent-Fleet: ein Dutzend Rollen, mehrere Hosts, ein Context-Bus. Es ist das Nervensystem hinter fast allen anderen Projekten hier.",
"Daneben schreibe ich Bots, die Daten lesen und Aktionen auslösen — Casino, Prediction-Markets, Crypto, TeamSpeak. Ich bin kein Trader, ich bin jemand, der die Mechanik verstehen will und die Reibung in den Werkzeugen reduziert.",
"Wenn ich nicht am Code bin, self-hoste ich Freewarez (Gitea, Jellyfin, Discourse, Authelia, Uptime-Kuma und ein paar mehr), betreibe ein Minecraft-Portal und schraube an meinem Setup aus Nix, Tailscale und 9router.",
"Was Sie hier nicht finden: Industrie-Software und Auftragsarbeit. Die steht auf w-make.com. Hier ist alles andere: Hacks, Spielwiesen, Notizen.",
"Was du hier nicht findest: Industrie-Software und Auftragsarbeit. Die steht auf w-make.com. Hier ist alles andere: Hacks, Spielwiesen, Notizen.",
].join("\n\n"),
bio_en: [
"I build things I want to use myself — usually at night.",