feat(design): comprehensive redesign — top-tier portfolio UX

Größeres Redesign, das die Seite spürbar besser macht. Behält das
Magenta/Grün-Dark-Thing, poliert aber alles drumherum.

Neue Komponenten:
- hero-banner: full-bleed Hero mit AI-generiertem Hintergrund (grok-imagine-image,
  1280×720, Magenta/Grün-Akzente), Terminal-Boot-Animation mit Tipp-Effekt,
  Rotating-Role-Animation, animiertes Grid-Overlay.
- command-palette: �K / Ctrl+K öffnet ein durchsuchbares Modal mit Navigation,
  Projekt-Suche, Aktionen (Gitea/w-make.com/Admin) und Easter Eggs. Tastatur
  ↑↓/Enter/Esc, gruppiert nach Typ, mit Vorschau-Hints.
- custom-cursor: Canvas-basierter Cursor mit Trail für Desktop, 14 Punkte,
  Farbwechsel Magenta↔Grün, Hover-State für interaktive Elemente. Touch-
  Devices behalten den Default-Cursor.
- scroll-progress: Top-Bar mit Magenta→Grün-Gradient.
- tilt-card: 3D-Hover mit Mouse-Tracking (CSS-Variablen --rx/--ry/--mx/--my).
- stack-icon: 13 handgezeichnete inline SVGs (TypeScript, Node, Python, Rust,
  SQLite, Tailwind, React, Docker, Gitea, Next, Nix, Hermes, Shell, Self-Host).
  Kein CDN, kein Hydration, currentColor-aware.
- project-filter: tag-basierter Filter für Projekt-Liste, Status-Tone-Tags
  (live/warm/muted) plus Kategorie-Tags, useTransition für sanfte Updates.

Pages-Update:
- Home (/): neuer Hero-Banner, Featured-Grid mit Tilt-Cards und KI-generierten
  Thumbnails (hermes.jpg, casino-bot.jpg, polymarket-trader.jpg), darunter
  filterbare Projekt-Liste, Bio + Links Sidebar.
- Projects: filterbar nach Kategorie und Status.
- Project-Detail: Hero-Image (für 3 Projekte), Stack-Pills mit SVG-Icons,
  bessere Sektionierung (~/stack, ~/notes).
- About: Stack-Visualisierung mit 13 Einträgen in einem Grid.
- OpenGraph-Image pro Projekt (Next.js native, 1200×630): eldov-Logo,
  Projekt-Slug in Magenta, Status-Badge, Kategorie-Badge.

Build-Hash-Support:
- NEXT_PUBLIC_BUILD_HASH + NEXT_PUBLIC_BUILD_DATE werden via Docker ARG in
  die Footer geschrieben ("build abc1234 · 2026-08-31"), deploy-vps.sh
  ermittelt Commit-Hash automatisch aus git und gibt ihn weiter.

Theme v2:
- Tiefere Palette (--bg #0a0a10, --bg-deep #050508), --fg-dim für Hierarchie.
- Neue Komponenten-Klassen: tilt-card, stack-pill, tag, hero-bg, hero-banner,
  scroll-progress, cursor-blink, type-in, glow-text.
- ASCII-Box-Hover-Effekt mit Akzent-Border.

Public-Bilder unter public/img/{hero,projects}/ — KI-generiert via xAI
grok-imagine-image (Token aus .hermes-state/credentials/xai.json), alle
im Marken-Stil (Magenta/Grün/dunkel). 4 Bilder, ~960 KB total.

Sonstiges:
- Header mit ⌘K-Hint, sticky+blur.
- Footer mit Build-Hash, externen Links, Datenschutz-Badge.
- About-Page mit Stack-Grid.
This commit is contained in:
Jan Wagner
2026-08-31 20:58:48 +02:00
parent 9ceead6ab6
commit 6cdd8438ae
24 changed files with 1460 additions and 177 deletions
+9 -1
View File
@@ -12,9 +12,17 @@ FROM deps AS build
WORKDIR /app WORKDIR /app
COPY . . COPY . .
ARG NEXT_PUBLIC_SITE_URL=https://eldov.win ARG NEXT_PUBLIC_SITE_URL=https://eldov.win
ARG NEXT_PUBLIC_BUILD_HASH=dev
ARG NEXT_PUBLIC_BUILD_DATE=1970-01-01
ENV NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL} ENV NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL}
ENV NEXT_PUBLIC_BUILD_HASH=${NEXT_PUBLIC_BUILD_HASH}
ENV NEXT_PUBLIC_BUILD_DATE=${NEXT_PUBLIC_BUILD_DATE}
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build RUN COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo ${NEXT_PUBLIC_BUILD_HASH}) && \
DATE=$(date -u +%Y-%m-%d) && \
echo "NEXT_PUBLIC_BUILD_HASH=${COMMIT}" >> .env.build && \
echo "NEXT_PUBLIC_BUILD_DATE=${DATE}" >> .env.build && \
npm run build
FROM node:22.13-bookworm-slim AS runner FROM node:22.13-bookworm-slim AS runner
WORKDIR /app WORKDIR /app
+6
View File
@@ -43,9 +43,15 @@ rsync -avz --delete \
--exclude "*.log" \ --exclude "*.log" \
./ "$VPS_HOST:$VPS_DIR/" ./ "$VPS_HOST:$VPS_DIR/"
echo "→ Build-Hash ermitteln"
COMMIT_HASH=$(git rev-parse --short HEAD 2>/dev/null || echo unknown)
BUILD_DATE=$(date -u +%Y-%m-%d)
echo " Commit: $COMMIT_HASH · Date: $BUILD_DATE"
echo "→ Baue und starte Container auf $VPS_HOST neu" echo "→ Baue und starte Container auf $VPS_HOST neu"
ssh "$VPS_HOST" "cd '$VPS_DIR' && \ ssh "$VPS_HOST" "cd '$VPS_DIR' && \
[ -f .env.local ] || { echo 'FEHLT: .env.local auf dem VPS — bitte anlegen'; exit 1; } && \ [ -f .env.local ] || { echo 'FEHLT: .env.local auf dem VPS — bitte anlegen'; exit 1; } && \
NEXT_PUBLIC_BUILD_HASH=$COMMIT_HASH NEXT_PUBLIC_BUILD_DATE=$BUILD_DATE \
docker compose build app && \ docker compose build app && \
docker compose up -d app && \ docker compose up -d app && \
docker image prune -f" docker image prune -f"
Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

+56 -6
View File
@@ -5,8 +5,25 @@ import { seedIfEmpty } from "@/lib/seed";
import { getProfile } from "@/lib/models"; import { getProfile } from "@/lib/models";
import { BioSection } from "@/components/bio-section"; import { BioSection } from "@/components/bio-section";
import { LinksSection } from "@/components/links-section"; import { LinksSection } from "@/components/links-section";
import { StackIcon } from "@/components/stack-icon";
import { buildMetadata } from "@/lib/metadata"; import { buildMetadata } from "@/lib/metadata";
const STACK_PRIMARY: Array<{ key: string; label: string }> = [
{ key: "typescript", label: "TypeScript" },
{ key: "node", label: "Node.js" },
{ key: "python", label: "Python" },
{ key: "rust", label: "Rust" },
{ key: "sqlite", label: "SQLite" },
{ key: "react", label: "React" },
{ key: "next", label: "Next.js" },
{ key: "tailwind", label: "Tailwind" },
{ key: "docker", label: "Docker" },
{ key: "gitea", label: "Gitea" },
{ key: "nix", label: "Nix" },
{ key: "shell", label: "Shell" },
{ key: "selfhost", label: "Self-Host" },
];
export async function generateMetadata({ export async function generateMetadata({
params, params,
}: { }: {
@@ -28,13 +45,46 @@ export default async function AboutPage({
seedIfEmpty(db); seedIfEmpty(db);
const profile = getProfile(db, locale); const profile = getProfile(db, locale);
return ( return (
<div className="grid gap-6 lg:grid-cols-3"> <div className="mx-auto max-w-[var(--max-w)] px-4 py-12 md:px-6">
<div className="lg:col-span-2"> <div className="grid gap-8 lg:grid-cols-[1.6fr_1fr]">
<BioSection profile={profile} locale={locale} /> <div>
<BioSection profile={profile} locale={locale} />
<section className="mt-10">
<div className="section-heading">
<span className="section-heading__label">~/stack</span>
<h2 className="section-heading__title">
{locale === "de" ? "Stack & Werkzeuge" : "Stack & tools"}
</h2>
</div>
<p className="mb-4 max-w-2xl text-[var(--fg-dim)]">
{locale === "de"
? "Womit ich arbeite. Auswahl, kein Anspruch auf Vollständigkeit — die Liste wächst mit dem, was ich gerade brauche."
: "What I work with. A selection, not exhaustive — the list grows with what I currently need."}
</p>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4">
{STACK_PRIMARY.map((s) => (
<div
key={s.key}
className="flex items-center gap-3 border border-hairline bg-[color-mix(in_oklab,var(--panel)_70%,transparent)] p-3 transition-colors hover:border-accent"
data-cursor="hover"
>
<span className="text-accent">
<StackIcon name={s.key} className="h-5 w-5" />
</span>
<div>
<div className="text-sm font-medium text-[var(--fg)]">{s.label}</div>
<div className="font-mono text-[10px] uppercase tracking-wider text-muted">{s.key}</div>
</div>
</div>
))}
</div>
</section>
</div>
<aside className="space-y-6">
<LinksSection profile={profile} locale={locale} />
</aside>
</div> </div>
<aside>
<LinksSection profile={profile} locale={locale} />
</aside>
</div> </div>
); );
} }
+14 -2
View File
@@ -1,6 +1,9 @@
import { isLocale, type Locale } from "@/i18n/routes"; import { isLocale, type Locale } from "@/i18n/routes";
import { SiteHeader } from "@/components/site-header"; import { SiteHeader } from "@/components/site-header";
import { SiteFooter } from "@/components/site-footer"; import { SiteFooter } from "@/components/site-footer";
import { getDb } from "@/lib/db";
import { seedIfEmpty } from "@/lib/seed";
import { listProjects } from "@/lib/models";
export default async function LangLayout({ export default async function LangLayout({
children, children,
@@ -11,10 +14,19 @@ export default async function LangLayout({
}) { }) {
const { lang } = await params; const { lang } = await params;
const locale: Locale = isLocale(lang) ? lang : "de"; const locale: Locale = isLocale(lang) ? lang : "de";
const db = getDb();
seedIfEmpty(db);
// Schlanke Projektliste für die Command-Palette.
const projects = listProjects(db, locale).map((p) => ({
slug: p.slug,
summary: p.summary,
category: p.category,
status: p.status,
}));
return ( return (
<div className="flex min-h-screen flex-col"> <div className="flex min-h-screen flex-col">
<SiteHeader locale={locale} /> <SiteHeader locale={locale} projects={projects} />
<main className="mx-auto w-full max-w-5xl flex-1 px-4 py-8">{children}</main> <main className="flex-1">{children}</main>
<SiteFooter locale={locale} /> <SiteFooter locale={locale} />
</div> </div>
); );
@@ -0,0 +1,83 @@
import { ImageResponse } from "next/og";
import { isLocale, type Locale } from "@/i18n/routes";
import { getDb } from "@/lib/db";
import { getProject } from "@/lib/models";
export const runtime = "nodejs";
export const alt = "Projekt auf eldov.win";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export default async function OpenGraphImage({
params,
}: {
params: Promise<{ lang: string; slug: string }>;
}) {
const { lang, slug } = await params;
const locale: Locale = isLocale(lang) ? lang : "de";
const project = getProject(getDb(), locale, slug);
if (!project) {
return new ImageResponse(<div>eldov.win</div>, size);
}
const dict: Record<Locale, { eyebrow: string }> = {
de: { eyebrow: "Projekt" },
en: { eyebrow: "Project" },
};
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
background: "linear-gradient(180deg, #0a0a10 0%, #14141e 100%)",
color: "#e7e7ee",
fontFamily: "ui-monospace, monospace",
padding: "72px 64px",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
}}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
<div style={{
width: 44, height: 44, display: "flex", alignItems: "center", justifyContent: "center",
border: "1.5px solid #e84a8f", color: "#e84a8f", fontSize: 26, fontWeight: 700,
}}>e</div>
<div style={{ fontSize: 26, color: "#e7e7ee" }}>eldov</div>
<div style={{ fontSize: 22, color: "#6e6e85" }}>@eldov.win</div>
</div>
<div style={{ fontSize: 18, color: "#6e6e85", letterSpacing: "0.1em" }}>
{dict[locale].eyebrow.toUpperCase()} · {project.number}
</div>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
<div style={{ fontSize: 64, color: "#e84a8f", fontWeight: 700, letterSpacing: "-0.02em" }}>
{project.slug}
</div>
<div style={{ fontSize: 26, color: "#b8b8c8", lineHeight: 1.4, maxWidth: 900 }}>
{project.summary}
</div>
</div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-end" }}>
<div style={{ display: "flex", gap: 10, flexWrap: "wrap" }}>
<div style={{
padding: "6px 12px", border: "1px solid #39ff14", color: "#39ff14",
fontSize: 14, letterSpacing: "0.08em", textTransform: "uppercase",
}}>{project.status}</div>
<div style={{
padding: "6px 12px", border: "1px solid #232333", color: "#b8b8c8",
fontSize: 14, letterSpacing: "0.08em", textTransform: "uppercase",
}}>{project.category}</div>
</div>
<div style={{ fontSize: 16, color: "#6e6e85", letterSpacing: "0.06em" }}>
eldov.win / {project.slug}
</div>
</div>
</div>
),
{ ...size }
);
}
+51 -30
View File
@@ -5,6 +5,9 @@ import { getDict } from "@/i18n/dictionaries";
import { getDb } from "@/lib/db"; import { getDb } from "@/lib/db";
import { getProject } from "@/lib/models"; import { getProject } from "@/lib/models";
import { buildMetadata } from "@/lib/metadata"; import { buildMetadata } from "@/lib/metadata";
import { StackIcon } from "@/components/stack-icon";
const THUMBED = ["hermes", "casino-bot", "polymarket-trader"];
export async function generateMetadata({ export async function generateMetadata({
params, params,
@@ -30,55 +33,72 @@ export default async function ProjectDetailPage({
const dict = getDict(locale); const dict = getDict(locale);
const paragraphs = project.body.split(/\n\n+/).filter((p) => p.trim().length > 0); 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);
return ( return (
<article className="space-y-6"> <article className="mx-auto max-w-[var(--max-w-narrow)] px-4 py-10 md:px-6">
<Link <Link
href={publicPath(locale, "projects")} href={publicPath(locale, "projects")}
className="font-mono text-xs uppercase text-muted hover:text-accent" className="mb-6 inline-flex items-center gap-1 font-mono text-xs uppercase text-muted hover:text-accent"
data-cursor="hover"
> >
{dict.actions.backToProjects} {dict.actions.backToProjects}
</Link> </Link>
<header className="ascii-box"> {hasThumb ? (
<div className="flex flex-wrap items-center gap-2"> <div className="relative mb-8 aspect-[2/1] overflow-hidden border border-hairline">
<span className="font-mono text-xs text-muted">{project.number}</span> <img
<span className="badge badge--muted">{project.status}</span> src={`/img/projects/${project.slug}.jpg`}
<span className="font-mono text-xs text-muted">{project.category}</span> alt=""
{project.featured ? <span className="badge badge--live"></span> : null} className="h-full w-full object-cover opacity-65"
style={{ filter: "saturate(0.85) contrast(1.05)" }}
/>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-[var(--bg)] via-transparent to-transparent" />
</div> </div>
<h1 className="mt-3 text-3xl font-semibold">{project.slug}</h1> ) : null}
<p className="mt-2 max-w-2xl text-[var(--fg)]">{project.summary}</p>
<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="font-mono text-xs text-muted">{project.category}</span>
{project.featured ? <span className="badge badge--accent"> featured</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>
</header> </header>
<dl className="grid gap-4 sm:grid-cols-3"> <section className="mb-8">
<div className="ascii-box"> <div className="section-heading">
<dt className="font-mono text-xs uppercase text-muted">{dict.projectDetail.statusLabel}</dt> <span className="section-heading__label">~/stack</span>
<dd className="mt-1 font-mono text-sm">{project.status}</dd> <h2 className="section-heading__title">Stack</h2>
</div> </div>
<div className="ascii-box"> <div className="flex flex-wrap gap-2">
<dt className="font-mono text-xs uppercase text-muted">{dict.projectDetail.categoryLabel}</dt> {stackItems.map((s) => (
<dd className="mt-1 font-mono text-sm">{project.category}</dd> <span key={s} className="stack-pill">
<StackIcon name={s} />
{s}
</span>
))}
</div> </div>
<div className="ascii-box"> </section>
<dt className="font-mono text-xs uppercase text-muted">{dict.projectDetail.stackLabel}</dt>
<dd className="mt-1 font-mono text-sm">{project.stack}</dd>
</div>
</dl>
{project.stance ? ( {project.stance ? (
<blockquote className="border-l-2 border-accent bg-panel p-4 italic text-muted"> <blockquote className="ascii-box mb-8 border-l-2 border-l-accent">
<span className="font-mono text-xs uppercase not-italic text-accent"> <span className="font-mono text-[10px] uppercase tracking-wider text-accent">{dict.projectDetail.stanceLabel}:</span>
{dict.projectDetail.stanceLabel}: <p className="mt-2 text-[var(--fg-dim)] italic">{project.stance}</p>
</span>
<p className="mt-1 not-italic">{project.stance}</p>
</blockquote> </blockquote>
) : null} ) : null}
<section className="ascii-box"> <section className="mb-8">
<div className="section-heading">
<span className="section-heading__label">~/notes</span>
<h2 className="section-heading__title">{locale === "de" ? "Notizen" : "Notes"}</h2>
</div>
<div className="space-y-4 text-[var(--fg)]"> <div className="space-y-4 text-[var(--fg)]">
{paragraphs.map((p, i) => ( {paragraphs.map((p, i) => (
<p key={i}>{p}</p> <p key={i} className="leading-relaxed">{p}</p>
))} ))}
</div> </div>
</section> </section>
@@ -88,7 +108,8 @@ export default async function ProjectDetailPage({
href={project.href} href={project.href}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-2 border border-accent px-4 py-2 font-mono text-sm uppercase text-accent hover:bg-accent hover:text-[var(--bg)]" className="inline-flex items-center gap-2 border border-accent px-5 py-2.5 font-mono text-sm uppercase tracking-wider text-accent hover:bg-accent hover:text-[var(--bg-deep)]"
data-cursor="hover"
> >
{dict.projectDetail.liveLabel} {dict.projectDetail.liveLabel}
</a> </a>
+5 -5
View File
@@ -3,7 +3,7 @@ import { getDict } from "@/i18n/dictionaries";
import { getDb } from "@/lib/db"; import { getDb } from "@/lib/db";
import { seedIfEmpty } from "@/lib/seed"; import { seedIfEmpty } from "@/lib/seed";
import { listProjects } from "@/lib/models"; import { listProjects } from "@/lib/models";
import { ProjectGrid } from "@/components/project-grid"; import { ProjectFilter } from "@/components/project-filter";
import { buildMetadata } from "@/lib/metadata"; import { buildMetadata } from "@/lib/metadata";
export async function generateMetadata({ export async function generateMetadata({
@@ -28,13 +28,13 @@ export default async function ProjectsPage({
const projects = listProjects(db, locale); const projects = listProjects(db, locale);
const dict = getDict(locale); const dict = getDict(locale);
return ( return (
<> <div className="mx-auto max-w-[var(--max-w)] px-4 py-12 md:px-6">
<div className="section-heading"> <div className="section-heading">
<span className="section-heading__label">~/projects</span> <span className="section-heading__label">~/projects</span>
<h1 className="section-heading__title">{dict.projects.title}</h1> <h1 className="section-heading__title">{dict.projects.title}</h1>
</div> </div>
<p className="mb-6 max-w-2xl text-[var(--fg)]">{dict.projects.lede}</p> <p className="mb-6 max-w-2xl text-[var(--fg-dim)]">{dict.projects.lede}</p>
<ProjectGrid projects={projects} locale={locale} showStance emptyLabel={dict.projects.emptyState} /> <ProjectFilter projects={projects} locale={locale} emptyLabel={dict.projects.emptyState} />
</> </div>
); );
} }
+7 -1
View File
@@ -2,6 +2,8 @@ import type { Metadata } from "next";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { isLocale } from "@/i18n/routes"; import { isLocale } from "@/i18n/routes";
import { site } from "@/content/site"; import { site } from "@/content/site";
import { CustomCursor } from "@/components/custom-cursor";
import { ScrollProgress } from "@/components/scroll-progress";
import "./globals.css"; import "./globals.css";
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -24,7 +26,11 @@ export default async function RootLayout({ children }: { children: React.ReactNo
const lang = isLocale(h.get("x-locale") ?? "") ? (h.get("x-locale") as "de" | "en") : "de"; const lang = isLocale(h.get("x-locale") ?? "") ? (h.get("x-locale") as "de" | "en") : "de";
return ( return (
<html lang={lang} className="h-full antialiased"> <html lang={lang} className="h-full antialiased">
<body className="min-h-full">{children}</body> <body className="min-h-full">
<ScrollProgress />
<CustomCursor />
{children}
</body>
</html> </html>
); );
} }
+90 -46
View File
@@ -1,18 +1,14 @@
// Root-`page.tsx` — fängt `/` ab und serviert den DE-Home-Inhalt direkt.
// Wir duplizieren hier die Logik aus [lang]/page.tsx, weil Next.js 16 ohne
// explizite Root-Route 404 für `/` zurückgibt (der [lang]-Proxy-Rewrite greift
// bei Direktzugriffen ohne Hostname-Rewrite wie z.B. Healthchecks nicht).
import { headers } from "next/headers";
import { getDb } from "@/lib/db"; import { getDb } from "@/lib/db";
import { seedIfEmpty } from "@/lib/seed"; import { seedIfEmpty } from "@/lib/seed";
import { getProfile, listFeaturedProjects, listProjects } from "@/lib/models"; import { getProfile, listFeaturedProjects, listProjects } from "@/lib/models";
import { BioSection } from "@/components/bio-section"; import { BioSection } from "@/components/bio-section";
import { LinksSection } from "@/components/links-section"; import { LinksSection } from "@/components/links-section";
import { ProjectGrid } from "@/components/project-grid"; import { ProjectFilter } from "@/components/project-filter";
import { JsonLd } from "@/components/json-ld"; import { JsonLd } from "@/components/json-ld";
import { getDict } from "@/i18n/dictionaries"; import { getDict } from "@/i18n/dictionaries";
import Link from "next/link"; import Link from "next/link";
import { publicPath } from "@/i18n/routes"; import { publicPath } from "@/i18n/routes";
import { HeroBanner } from "@/components/hero-banner";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -27,51 +23,99 @@ export default async function RootIndex() {
return ( return (
<> <>
<JsonLd profile={profile} featured={featured} /> <JsonLd profile={profile} featured={featured} />
<HeroBanner dict={dict.home} heroImage="/img/hero/hero-bg.jpg" />
<section className="ascii-box mb-8"> <div className="mx-auto max-w-[var(--max-w)] px-4 md:px-6">
<p className="font-mono text-xs uppercase tracking-wider text-muted">{dict.home.kicker}</p> {/* FEATURED */}
<h1 <section id="featured" className="py-16">
className="headline-glitch mt-2 text-4xl font-semibold leading-tight md:text-5xl" <div className="section-heading">
data-text={dict.home.title} <span className="section-heading__label">~/featured</span>
> <h2 className="section-heading__title">{dict.home.featuredLabel}</h2>
{dict.home.title} <Link href={publicPath("de", "projects")} className="ml-auto font-mono text-xs uppercase text-accent hover:underline" data-cursor="hover">
</h1>
<p className="mt-4 max-w-2xl text-[var(--fg)]">{dict.home.lede}</p>
</section>
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2">
{featured.length > 0 ? (
<>
<div className="section-heading">
<span className="section-heading__label">~/featured</span>
<h2 className="section-heading__title">{dict.home.featuredLabel}</h2>
</div>
<ProjectGrid projects={featured} locale="de" showStance />
</>
) : null}
<div className="section-heading mt-12">
<span className="section-heading__label">~/all</span>
<h2 className="section-heading__title">{dict.home.allProjectsLabel}</h2>
<Link
href={publicPath("de", "projects")}
className="ml-auto font-mono text-xs uppercase text-accent hover:underline"
>
{dict.actions.viewProjects} {dict.actions.viewProjects}
</Link> </Link>
</div> </div>
<ProjectGrid projects={all.slice(0, 6)} locale="de" /> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
</div> {featured.map((p) => (
<aside className="space-y-6"> <FeaturedTile key={p.slug} project={p} />
<BioSection profile={profile} locale="de" /> ))}
<LinksSection profile={profile} locale="de" /> </div>
</aside> </section>
</div>
<section className="ascii-box mt-12"> {/* ALL PROJECTS mit Filter */}
<p className="font-mono text-xs uppercase tracking-wider text-accent">{dict.home.cta.title}</p> <section className="py-12">
<p className="mt-2 text-[var(--fg)]">{dict.home.cta.body}</p> <div className="section-heading">
</section> <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 */}
<section 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>
</section>
{/* 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 }: { project: Project }) {
const hasThumb = ["hermes", "casino-bot", "polymarket-trader"].includes(project.slug);
return (
<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>
);
}
+191 -39
View File
@@ -1,50 +1,67 @@
/* Eldov-Win Theme. /* Eldov-Win Theme — v2.
* *
* Eigener Look, absichtlich getrennt vom Industrie-Glas-Theme der Schwester-Site * Eigener Look, getrennt vom Industrie-Glas-Theme der Schwester-Site
* w-make.com. Hier: dunkel, monospace-Akzent, magenta-grüne Anklänge, viel * w-make.com. Hier: dunkel, monospace-Akzente, magenta-grüne Anklänge,
* Hairline. Wer hier landet, soll sofort wissen: das ist die Spielwiese, nicht * viel Hairline. Wer hier landet, soll sofort wissen: das ist die
* die Auftragsarbeit. * Spielwiese, nicht die Auftragsarbeit.
*/ */
:root { :root {
/* Palette */ /* Palette — bewusst kühler und tiefer als v1, damit AI-Bilder
--bg: #0b0b0e; * (mit viel Schwarz-Anteil) sich ohne Filter einfügen. */
--bg-deep: #06060a; --bg: #0a0a10;
--panel: #111118; --bg-deep: #050508;
--panel-2: #161622; --bg-glow: #11111a;
--panel: #14141e;
--panel-2: #1a1a26;
--fg: #e7e7ee; --fg: #e7e7ee;
--muted: #8b8ba3; --fg-dim: #b8b8c8;
--muted: #6e6e85;
--muted-2: #4a4a5c;
--hairline: #232333; --hairline: #232333;
--hairline-2: #1d1d2a;
--accent: #e84a8f; /* Magenta */ --accent: #e84a8f; /* Magenta */
--accent-2: #39ff14; /* Terminal-Grün */ --accent-2: #39ff14; /* Terminal-Grün */
--accent-warm: #ffb86b; /* Bernstein für Status-Badges */ --accent-warm: #ffb86b; /* Bernstein für Status */
--accent-blue: #6cb8ff; /* Selten — für Links/Info */
--danger: #ff5c5c; --danger: #ff5c5c;
/* Typografie */ /* Typografie */
--font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, --font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI",
"Helvetica Neue", Arial, "Noto Sans", sans-serif; Roboto, "Helvetica Neue", Arial, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", --font-display: "Inter Display", "Inter", ui-sans-serif, system-ui, sans-serif;
monospace; --font-mono: "JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular,
Menlo, Consolas, monospace;
--font-serif: "Newsreader", ui-serif, Georgia, serif;
/* Spacing & Layout */
--max-w: 72rem; /* 1152px */
--max-w-narrow: 56rem; /* 896px */
--radius: 4px;
--radius-lg: 10px;
/* Easings */
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
} }
@layer base { @layer base {
/* Schmaler Hairline-Scrollbar im dunklen Stil */ /* Schmaler Hairline-Scrollbar */
*::-webkit-scrollbar { *::-webkit-scrollbar { width: 8px; height: 8px; }
width: 10px; *::-webkit-scrollbar-track { background: transparent; }
height: 10px;
}
*::-webkit-scrollbar-track {
background: var(--bg-deep);
}
*::-webkit-scrollbar-thumb { *::-webkit-scrollbar-thumb {
background: var(--hairline); background: var(--hairline);
border-radius: 8px; border-radius: 8px;
} }
*::-webkit-scrollbar-thumb:hover { *::-webkit-scrollbar-thumb:hover { background: var(--accent); }
background: var(--accent);
/* Selection */
::selection {
background: color-mix(in oklab, var(--accent) 30%, transparent);
color: var(--fg);
} }
/* Reduzierte Bewegung respektieren */ /* Reduced motion */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *,
*::before, *::before,
@@ -57,12 +74,16 @@
} }
@layer components { @layer components {
/* ASCII-Box, ähnlich der Sektion in w-make.com, aber monochrom + Akzent */ /* ASCII-Box mit Hairline + Eck-Akzenten */
.ascii-box { .ascii-box {
position: relative; position: relative;
border: 1px solid var(--hairline); border: 1px solid var(--hairline);
background: linear-gradient(180deg, var(--panel) 0%, var(--panel-2) 100%); background: linear-gradient(180deg, var(--panel) 0%, var(--panel-2) 100%);
padding: 1.25rem 1.25rem 1rem; padding: 1.5rem 1.5rem 1.25rem;
transition: border-color 220ms var(--ease-out), background 220ms var(--ease-out);
}
.ascii-box:hover {
border-color: color-mix(in oklab, var(--accent) 50%, var(--hairline));
} }
.ascii-box::before, .ascii-box::before,
.ascii-box::after { .ascii-box::after {
@@ -71,6 +92,7 @@
width: 10px; width: 10px;
height: 10px; height: 10px;
border-color: var(--accent); border-color: var(--accent);
pointer-events: none;
} }
.ascii-box::before { .ascii-box::before {
top: -1px; top: -1px;
@@ -85,7 +107,7 @@
border-right: 1px solid; border-right: 1px solid;
} }
/* Status-Badge: klein, monospace, farbcodiert */ /* Status-Badge */
.badge { .badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -93,20 +115,27 @@
padding: 0.2rem 0.55rem; padding: 0.2rem 0.55rem;
border: 1px solid var(--hairline); border: 1px solid var(--hairline);
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 0.72rem; font-size: 0.7rem;
letter-spacing: 0.02em; letter-spacing: 0.04em;
text-transform: uppercase; text-transform: uppercase;
color: var(--fg-dim);
} }
.badge::before { .badge::before {
content: "■"; content: "■";
color: var(--accent); color: var(--muted);
font-size: 0.55rem; font-size: 0.55rem;
} }
.badge--live::before { color: var(--accent-2); } .badge--live::before { color: var(--accent-2); animation: pulse 2s ease-in-out infinite; }
.badge--warm::before { color: var(--accent-warm); } .badge--warm::before { color: var(--accent-warm); }
.badge--muted::before { color: var(--muted); } .badge--muted::before { color: var(--muted); }
.badge--accent::before { color: var(--accent); }
/* Section-Heading mit Hairline + Akzentstrich */ @keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
/* Section-Heading mit Label und Hairline */
.section-heading { .section-heading {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
@@ -118,16 +147,38 @@
.section-heading__label { .section-heading__label {
font-family: var(--font-mono); font-family: var(--font-mono);
color: var(--muted); color: var(--muted);
font-size: 0.75rem; font-size: 0.72rem;
letter-spacing: 0.08em; letter-spacing: 0.08em;
text-transform: uppercase; text-transform: uppercase;
} }
.section-heading__title { .section-heading__title {
font-size: 1.5rem; font-size: 1.5rem;
font-weight: 600; font-weight: 600;
letter-spacing: -0.01em;
} }
/* Subtle Glitch-Effekt auf Headline (deaktivierbar per reduced-motion) */ /* Stack-Pill */
.stack-pill {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.25rem 0.6rem;
border: 1px solid var(--hairline);
border-radius: 999px;
font-family: var(--font-mono);
font-size: 0.72rem;
color: var(--fg-dim);
background: color-mix(in oklab, var(--panel) 80%, transparent);
transition: all 180ms var(--ease-out);
}
.stack-pill:hover {
border-color: var(--accent);
color: var(--fg);
background: color-mix(in oklab, var(--accent) 8%, var(--panel));
}
.stack-pill svg { width: 0.85rem; height: 0.85rem; opacity: 0.7; }
/* Headline-Glitch */
.headline-glitch { .headline-glitch {
position: relative; position: relative;
display: inline-block; display: inline-block;
@@ -140,19 +191,120 @@
left: 0; left: 0;
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;
pointer-events: none;
} }
.headline-glitch::before { .headline-glitch::before {
color: var(--accent); color: var(--accent);
transform: translate(-2px, 0); transform: translate(-2px, 0);
mix-blend-mode: screen; mix-blend-mode: screen;
opacity: 0.6; opacity: 0.55;
clip-path: polygon(0 0, 100% 0, 100% 45%, 0 45%); clip-path: polygon(0 0, 100% 0, 100% 45%, 0 45%);
animation: glitch-x 4s steps(2) infinite;
} }
.headline-glitch::after { .headline-glitch::after {
color: var(--accent-2); color: var(--accent-2);
transform: translate(2px, 0); transform: translate(2px, 0);
mix-blend-mode: screen; mix-blend-mode: screen;
opacity: 0.6; opacity: 0.55;
clip-path: polygon(0 55%, 100% 55%, 100% 100%, 0 100%); clip-path: polygon(0 55%, 100% 55%, 100% 100%, 0 100%);
animation: glitch-x 4s steps(2) infinite reverse;
}
@keyframes glitch-x {
0%, 95%, 100% { transform: translate(0, 0); }
96% { transform: translate(-3px, 1px); }
97% { transform: translate(2px, -1px); }
98% { transform: translate(-2px, 0); }
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;
align-items: center;
padding: 0.18rem 0.55rem;
border: 1px solid var(--hairline-2);
border-radius: 999px;
font-family: var(--font-mono);
font-size: 0.7rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.04em;
transition: all 180ms var(--ease-out);
cursor: pointer;
}
.tag:hover { color: var(--fg); border-color: var(--hairline); }
.tag--active { color: var(--accent); border-color: var(--accent); background: color-mix(in oklab, var(--accent) 8%, transparent); }
/* Cursor-Blink */
@keyframes blink {
50% { opacity: 0; }
}
.cursor-blink { animation: blink 1.05s steps(2) infinite; }
/* Type-In Animation */
@keyframes typeIn {
from { width: 0; }
to { width: 100%; }
}
.type-in { overflow: hidden; white-space: nowrap; border-right: 2px solid var(--accent); animation: typeIn 1.6s steps(40, end) forwards, blink 1s steps(2) 1.6s forwards; }
/* Scroll-Progress-Bar */
.scroll-progress {
position: fixed;
top: 0;
left: 0;
height: 2px;
width: var(--scroll, 0%);
background: linear-gradient(90deg, var(--accent) 0%, var(--accent-2) 100%);
z-index: 100;
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);
} }
} }
+36
View File
@@ -0,0 +1,36 @@
"use client";
import { useState, useEffect, type ReactNode } from "react";
// ⌘K-Bar im Header. Klick öffnet das Command-Palette (das selbst
// auch auf ⌘K hört). Hier nur visuelle Anzeige + Hint.
export function CommandBar({ triggerLabel = "⌘K", children }: { triggerLabel?: string; children: ReactNode }) {
const [open, setOpen] = useState(false);
useEffect(() => {
function onKey(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setOpen(true);
}
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="flex items-center gap-2 rounded border border-hairline bg-[color-mix(in_oklab,var(--panel)_50%,transparent)] px-2.5 py-1 font-mono text-xs text-muted transition-colors hover:border-accent hover:text-[var(--fg)]"
aria-label="Command palette öffnen"
>
<span className="font-mono text-sm leading-none"></span>
<span>K</span>
<span className="hidden text-muted sm:inline">search</span>
</button>
{open ? <div onClick={() => setOpen(false)}>{children}</div> : null}
</>
);
}
+199
View File
@@ -0,0 +1,199 @@
"use client";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import type { Locale } from "@/i18n/routes";
import { publicPath } from "@/i18n/routes";
type Item = {
id: string;
label: string;
hint?: string;
group: "nav" | "project" | "action" | "easter";
icon?: ReactNode;
shortcut?: string;
action: () => void;
};
export function CommandPalette({
locale,
projects,
}: {
locale: Locale;
projects: Array<{ slug: string; summary: string; category: string; status: string }>;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [active, setActive] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const router = useRouter();
// ⌘K / Ctrl+K öffnet das Palette. ESC schließt.
useEffect(() => {
function onKey(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setOpen((v) => !v);
} else if (e.key === "Escape" && open) {
setOpen(false);
}
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open]);
// Bei Öffnen: Query zurücksetzen, Input fokussieren.
useEffect(() => {
if (open) {
setQuery("");
setActive(0);
requestAnimationFrame(() => inputRef.current?.focus());
}
}, [open]);
const items: Item[] = useMemo(() => {
const closeAndGo = (href: string) => () => {
setOpen(false);
router.push(href);
};
const nav: Item[] = [
{ id: "nav-home", label: locale === "de" ? "Startseite" : "Home", hint: "/", group: "nav", action: closeAndGo(publicPath(locale, "home")) },
{ id: "nav-about", label: locale === "de" ? "Über mich" : "About", hint: publicPath(locale, "about"), group: "nav", action: closeAndGo(publicPath(locale, "about")) },
{ id: "nav-projects", label: locale === "de" ? "Projekte" : "Projects", hint: publicPath(locale, "projects"), group: "nav", action: closeAndGo(publicPath(locale, "projects")) },
{ id: "nav-contact", label: locale === "de" ? "Kontakt" : "Contact", hint: publicPath(locale, "contact"), group: "nav", action: closeAndGo(publicPath(locale, "contact")) },
{ id: "nav-en", label: "English version", hint: "/en", group: "nav", shortcut: "en", action: () => { setOpen(false); router.push("/en"); } },
{ id: "nav-de", label: "Deutsche Version", hint: "/", group: "nav", shortcut: "de", action: () => { setOpen(false); router.push("/"); } },
];
const projectItems: Item[] = projects.map((p) => ({
id: `proj-${p.slug}`,
label: p.slug,
hint: `${p.category} · ${p.status}`,
group: "project",
action: closeAndGo(publicPath(locale, "projectItem", p.slug)),
}));
const actions: Item[] = [
{ id: "act-source", label: locale === "de" ? "Quellcode öffnen" : "Open source code", hint: "Gitea", group: "action", shortcut: "g s", action: () => { setOpen(false); window.open("https://gitea.free-warez.win/eldov/eldov-win", "_blank"); } },
{ id: "act-admin", label: locale === "de" ? "Admin" : "Admin", hint: "/admin/login", group: "action", shortcut: "a", action: closeAndGo("/admin/login") },
{ id: "act-wmake", label: "w-make.com", hint: locale === "de" ? "Geschäftliche Site" : "Business site", group: "action", action: () => { setOpen(false); window.open("https://w-make.com", "_blank"); } },
];
const easter: Item[] = [
{ id: "easter-theme", label: locale === "de" ? "Theme: Matrix" : "Theme: Matrix", hint: "Easter egg", group: "easter", shortcut: "matrix", action: () => { setOpen(false); document.documentElement.classList.toggle("theme-matrix"); } },
{ id: "easter-coffee", label: "☕ coffee", hint: "Just a message", group: "easter", action: () => { setOpen(false); alert("Kaffee wird gebrüht. Bitte warten."); } },
];
return [...nav, ...projectItems, ...actions, ...easter];
}, [locale, projects, router]);
const filtered = useMemo(() => {
if (!query.trim()) return items;
const q = query.toLowerCase();
return items.filter((i) =>
[i.label, i.hint, i.group].filter(Boolean).some((s) => s!.toLowerCase().includes(q))
);
}, [items, query]);
useEffect(() => { setActive(0); }, [query]);
function onKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
if (e.key === "ArrowDown") { e.preventDefault(); setActive((a) => Math.min(filtered.length - 1, a + 1)); }
else if (e.key === "ArrowUp") { e.preventDefault(); setActive((a) => Math.max(0, a - 1)); }
else if (e.key === "Enter") { e.preventDefault(); filtered[active]?.action(); }
}
if (!open) return null;
const groups = ["nav", "project", "action", "easter"] as const;
const groupLabels: Record<typeof groups[number], string> = {
nav: locale === "de" ? "Navigation" : "Navigation",
project: locale === "de" ? "Projekte" : "Projects",
action: locale === "de" ? "Aktionen" : "Actions",
easter: locale === "de" ? "Easter Eggs" : "Easter Eggs",
};
return (
<div
role="dialog"
aria-modal="true"
aria-label="Command palette"
className="fixed inset-0 z-[300] flex items-start justify-center px-4 pt-[10vh]"
onClick={() => setOpen(false)}
>
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<div
className="ascii-box relative z-10 w-full max-w-xl bg-[var(--bg-deep)] shadow-2xl"
style={{ boxShadow: "0 24px 60px rgba(0,0,0,0.6), 0 0 0 1px var(--hairline)" }}
onClick={(e) => e.stopPropagation()}
onKeyDown={onKeyDown}
>
<div className="flex items-center gap-3 border-b border-hairline px-4 py-3">
<span className="font-mono text-xs uppercase tracking-wider text-muted">K</span>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={locale === "de" ? "Suchen, navigieren, ausführen…" : "Search, navigate, run…"}
className="flex-1 bg-transparent font-mono text-sm text-[var(--fg)] outline-none placeholder:text-muted"
autoComplete="off"
spellCheck={false}
/>
<kbd className="rounded border border-hairline px-1.5 py-0.5 font-mono text-xs text-muted">esc</kbd>
</div>
<div ref={listRef} className="max-h-[55vh] overflow-y-auto py-2">
{filtered.length === 0 ? (
<div className="px-4 py-8 text-center font-mono text-xs text-muted">
{locale === "de" ? "Keine Treffer für" : "No results for"} &quot;{query}&quot;
</div>
) : (
groups.map((g) => {
const itemsInGroup = filtered.filter((i) => i.group === g);
if (itemsInGroup.length === 0) return null;
return (
<div key={g} className="mb-1">
<div className="px-4 py-1 font-mono text-[10px] uppercase tracking-wider text-muted">
{groupLabels[g]}
</div>
{itemsInGroup.map((i) => {
const globalIndex = filtered.indexOf(i);
const isActive = globalIndex === active;
return (
<button
key={i.id}
type="button"
onMouseEnter={() => setActive(globalIndex)}
onClick={() => i.action()}
className={`flex w-full items-center gap-3 px-4 py-2 text-left transition-colors ${
isActive ? "bg-[color-mix(in_oklab,var(--accent)_10%,transparent)]" : ""
}`}
>
<span className={`flex h-5 w-5 items-center justify-center font-mono text-xs ${isActive ? "text-accent" : "text-muted"}`}>
{isActive ? "▶" : " "}
</span>
<span className={`flex-1 truncate text-sm ${isActive ? "text-[var(--fg)]" : "text-[var(--fg-dim)]"}`}>
{i.label}
</span>
{i.hint ? (
<span className="hidden font-mono text-xs text-muted sm:inline">{i.hint}</span>
) : null}
{i.shortcut ? (
<kbd className="rounded border border-hairline px-1.5 py-0.5 font-mono text-[10px] text-muted">{i.shortcut}</kbd>
) : null}
</button>
);
})}
</div>
);
})
)}
</div>
<div className="flex items-center justify-between border-t border-hairline px-4 py-2 font-mono text-[10px] uppercase tracking-wider text-muted">
<span>{filtered.length} {locale === "de" ? "Treffer" : "matches"}</span>
<span className="flex items-center gap-2">
<kbd className="rounded border border-hairline px-1.5 py-0.5"></kbd>
<kbd className="rounded border border-hairline px-1.5 py-0.5"></kbd>
<span className="ml-1">{locale === "de" ? "Auswählen" : "Select"}</span>
</span>
</div>
</div>
</div>
);
}
+125
View File
@@ -0,0 +1,125 @@
"use client";
import { useEffect, useRef } from "react";
// Custom Cursor mit Trail (Canvas-basiert, GPU-freundlich).
// Default-Cursor wird via CSS auf non-hover/non-touch versteckt, dieser
// Canvas-Cursor ersetzt ihn auf Desktop. Touch-Devices bekommen den
// Default-Cursor (siehe media query in CSS).
const TRAIL_LENGTH = 14;
export function CustomCursor() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const rafRef = useRef<number | null>(null);
const points = useRef<Array<{ x: number; y: number; t: number }>>([]);
const target = useRef<{ x: number; y: number; hover: boolean }>({ x: 0, y: 0, hover: false });
const visible = useRef(true);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
// Touch- und Coarse-Pointer-Devices: skip.
if (window.matchMedia("(pointer: coarse)").matches) return;
let dpr = window.devicePixelRatio || 1;
function resize() {
dpr = window.devicePixelRatio || 1;
canvas!.width = window.innerWidth * dpr;
canvas!.height = window.innerHeight * dpr;
canvas!.style.width = `${window.innerWidth}px`;
canvas!.style.height = `${window.innerHeight}px`;
ctx!.scale(dpr, dpr);
}
resize();
window.addEventListener("resize", resize);
function onMove(e: MouseEvent) {
target.current.x = e.clientX;
target.current.y = e.clientY;
const el = e.target as HTMLElement | null;
const interactive =
!!el?.closest('a, button, [role="button"], input, textarea, select, [data-cursor="hover"]');
target.current.hover = interactive;
if (!visible.current) {
canvas!.style.opacity = "1";
visible.current = true;
}
}
function onLeave() {
canvas!.style.opacity = "0";
visible.current = false;
}
window.addEventListener("mousemove", onMove, { passive: true });
document.addEventListener("mouseleave", onLeave);
document.addEventListener("mouseenter", onLeave); // reset on enter
function loop() {
if (!ctx) return;
ctx.clearRect(0, 0, canvas!.width / dpr, canvas!.height / dpr);
const now = performance.now();
points.current.unshift({ x: target.current.x, y: target.current.y, t: now });
if (points.current.length > TRAIL_LENGTH) points.current.length = TRAIL_LENGTH;
const accent = getComputedStyle(document.documentElement).getPropertyValue("--accent").trim() || "#e84a8f";
const accent2 = getComputedStyle(document.documentElement).getPropertyValue("--accent-2").trim() || "#39ff14";
// Trail
for (let i = TRAIL_LENGTH - 1; i >= 0; i--) {
const p = points.current[i];
if (!p) continue;
const age = (now - p.t) / 1000;
if (age > 0.4) continue;
const fade = 1 - i / TRAIL_LENGTH;
const r = 4 + (TRAIL_LENGTH - i) * 0.45;
ctx.beginPath();
ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
ctx.fillStyle = i % 2 === 0 ? accent : accent2;
ctx.globalAlpha = fade * 0.5;
ctx.fill();
ctx.globalAlpha = 1;
}
// Cursor-Head (Dot + Ring)
const head = points.current[0];
if (head) {
const r1 = target.current.hover ? 14 : 6;
const r2 = target.current.hover ? 22 : 12;
ctx.beginPath();
ctx.arc(head.x, head.y, r2, 0, Math.PI * 2);
ctx.strokeStyle = accent;
ctx.lineWidth = 1;
ctx.globalAlpha = target.current.hover ? 0.7 : 0.35;
ctx.stroke();
ctx.globalAlpha = 1;
ctx.beginPath();
ctx.arc(head.x, head.y, r1 / 2, 0, Math.PI * 2);
ctx.fillStyle = accent;
ctx.fill();
}
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
return () => {
window.removeEventListener("resize", resize);
window.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseleave", onLeave);
document.removeEventListener("mouseenter", onLeave);
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<canvas
ref={canvasRef}
aria-hidden
className="custom-cursor-canvas pointer-events-none fixed inset-0 z-[200] hidden opacity-0 transition-opacity duration-200 md:block"
style={{ mixBlendMode: "screen" }}
/>
);
}
+164
View File
@@ -0,0 +1,164 @@
"use client";
import { useEffect, useState } from "react";
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">
<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)]" />
</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>
);
}
+93
View File
@@ -0,0 +1,93 @@
"use client";
import { useState, useTransition, useMemo } from "react";
import type { Project } from "@/lib/models";
import { ProjectCard } from "./project-card";
import type { Locale } from "@/i18n/routes";
type StatusTone = "live" | "warm" | "muted";
function statusTone(s: string): StatusTone {
const v = s.toLowerCase();
if (v.startsWith("aktiv") || v.startsWith("live") || v === "running") return "live";
if (v.includes("early") || v.includes("draft") || v.includes("hardening")) return "warm";
return "muted";
}
export function ProjectFilter({
projects,
locale,
emptyLabel,
}: {
projects: Project[];
locale: Locale;
emptyLabel: string;
}) {
// Sammle alle einzigartigen Kategorien + Status-Töne als Filter-Tags.
const tags = useMemo(() => {
const set = new Set<string>();
projects.forEach((p) => {
const [main] = p.category.split("·");
if (main) set.add(main.trim());
set.add(statusTone(p.status));
});
return Array.from(set);
}, [projects]);
const [active, setActive] = useState<string | null>(null);
const [, start] = useTransition();
const filtered = useMemo(() => {
if (!active) return projects;
return projects.filter((p) => {
if (active === "live" || active === "warm" || active === "muted") {
return statusTone(p.status) === active;
}
const [main] = p.category.split("·");
return main?.trim() === active;
});
}, [projects, active]);
return (
<div>
<div className="mb-6 flex flex-wrap items-center gap-2">
<span className="mr-1 font-mono text-[10px] uppercase tracking-wider text-muted">filter</span>
<button
type="button"
onClick={() => start(() => setActive(null))}
className={`tag ${active === null ? "tag--active" : ""}`}
>
all · {projects.length}
</button>
{tags.map((t) => {
const count = projects.filter((p) => {
if (t === "live" || t === "warm" || t === "muted") return statusTone(p.status) === t;
const [main] = p.category.split("·");
return main?.trim() === t;
}).length;
return (
<button
key={t}
type="button"
onClick={() => start(() => setActive(active === t ? null : t))}
className={`tag ${active === t ? "tag--active" : ""}`}
>
{t} · {count}
</button>
);
})}
</div>
{filtered.length === 0 ? (
<div className="rounded border border-hairline bg-panel p-8 text-center font-mono text-sm text-muted">
{emptyLabel}
</div>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{filtered.map((p) => (
<div key={p.slug} className="transition-opacity duration-200">
<ProjectCard project={p} locale={locale} showStance />
</div>
))}
</div>
)}
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { useEffect, useState } from "react";
export function ScrollProgress() {
const [pct, setPct] = useState(0);
useEffect(() => {
function update() {
const h = document.documentElement;
const max = h.scrollHeight - h.clientHeight;
const cur = max > 0 ? (h.scrollTop / max) * 100 : 0;
setPct(Math.min(100, Math.max(0, cur)));
}
update();
window.addEventListener("scroll", update, { passive: true });
window.addEventListener("resize", update);
return () => {
window.removeEventListener("scroll", update);
window.removeEventListener("resize", update);
};
}, []);
return (
<div
aria-hidden
className="scroll-progress"
style={{ ["--scroll" as string]: `${pct}%` }}
/>
);
}
+45 -20
View File
@@ -4,32 +4,57 @@ import { site } from "@/content/site";
import { getLegal } from "@/content/legal"; import { getLegal } from "@/content/legal";
import { publicPath } from "@/i18n/routes"; import { publicPath } from "@/i18n/routes";
function buildInfo() {
// ENV: NEXT_PUBLIC_BUILD_HASH wird beim Build injected (Dockerfile ARG).
const hash = process.env.NEXT_PUBLIC_BUILD_HASH ?? "dev";
const date = process.env.NEXT_PUBLIC_BUILD_DATE ?? new Date().toISOString().slice(0, 10);
return { hash, date };
}
export function SiteFooter({ locale }: { locale: Locale }) { export function SiteFooter({ locale }: { locale: Locale }) {
const legal = getLegal(locale); const legal = getLegal(locale);
const year = new Date().getFullYear(); const year = new Date().getFullYear();
const build = buildInfo();
return ( return (
<footer className="mt-16 border-t border-hairline bg-[var(--bg-deep)]"> <footer className="mt-24 border-t border-hairline bg-[var(--bg-deep)]">
<div className="mx-auto grid max-w-5xl gap-8 px-4 py-8 md:grid-cols-3"> <div className="mx-auto grid max-w-[var(--max-w)] gap-10 px-4 py-12 md:grid-cols-4 md:px-6">
<div> <div className="md:col-span-2">
<p className="font-mono text-sm text-[var(--fg)]">{site.handle}</p> <div className="mb-3 flex items-center gap-2 font-mono">
<p className="text-xs text-muted">{legal.city}</p> <span className="inline-flex h-6 w-6 items-center justify-center rounded-sm border border-accent text-[11px] font-bold text-accent">e</span>
</div> <span className="text-[var(--fg)]">{site.handle}</span>
<div className="text-xs text-muted"> </div>
<p> <p className="mb-4 max-w-md text-sm text-[var(--fg-dim)]">{site.tagline}</p>
{legal.name} · {year} <p className="font-mono text-[11px] text-muted">
build <span className="text-accent">{build.hash.slice(0, 7)}</span> · {build.date}
</p> </p>
<p className="mt-1">{legal.contactHint}</p>
</div> </div>
<div className="flex flex-col gap-1 text-xs"> <div>
<Link className="text-muted hover:text-accent" href={publicPath(locale, "legal")}> <h4 className="mb-3 font-mono text-[10px] uppercase tracking-wider text-muted">eldov.win</h4>
{locale === "de" ? "Impressum" : "Legal"} <ul className="space-y-1.5 text-sm">
</Link> <li><Link href={publicPath(locale, "about")} className="text-[var(--fg-dim)] hover:text-accent">{locale === "de" ? "Über mich" : "About"}</Link></li>
<Link className="text-muted hover:text-accent" href={publicPath(locale, "privacy")}> <li><Link href={publicPath(locale, "projects")} className="text-[var(--fg-dim)] hover:text-accent">{locale === "de" ? "Projekte" : "Projects"}</Link></li>
{locale === "de" ? "Datenschutz" : "Privacy"} <li><Link href={publicPath(locale, "contact")} className="text-[var(--fg-dim)] hover:text-accent">{locale === "de" ? "Kontakt" : "Contact"}</Link></li>
</Link> <li><Link href={publicPath(locale, "legal")} className="text-[var(--fg-dim)] hover:text-accent">{locale === "de" ? "Impressum" : "Legal"}</Link></li>
<a className="text-muted hover:text-accent" href={site.siblingSite.href} rel="noopener noreferrer"> <li><Link href={publicPath(locale, "privacy")} className="text-[var(--fg-dim)] hover:text-accent">{locale === "de" ? "Datenschutz" : "Privacy"}</Link></li>
{site.siblingSite.label} </ul>
</a> </div>
<div>
<h4 className="mb-3 font-mono text-[10px] uppercase tracking-wider text-muted">elsewhere</h4>
<ul className="space-y-1.5 text-sm">
<li><a href="https://gitea.free-warez.win/eldov/eldov-win" target="_blank" rel="noopener noreferrer" className="text-[var(--fg-dim)] hover:text-accent">Gitea · eldov </a></li>
<li><a href="https://hermes.free-warez.win" target="_blank" rel="noopener noreferrer" className="text-[var(--fg-dim)] hover:text-accent">Hermes </a></li>
<li><a href="https://free-warez.top" target="_blank" rel="noopener noreferrer" className="text-[var(--fg-dim)] hover:text-accent">free-warez.top </a></li>
<li><a href="https://w-make.com" target="_blank" rel="noopener noreferrer" className="text-[var(--fg-dim)] hover:text-accent">w-make.com </a></li>
<li><a href="/admin/login" className="text-muted hover:text-accent">/admin/login</a></li>
</ul>
</div>
</div>
<div className="border-t border-hairline-2">
<div className="mx-auto flex max-w-[var(--max-w)] flex-wrap items-center justify-between gap-3 px-4 py-4 font-mono text-[11px] text-muted md:px-6">
<span>© {year} {legal.name} · {legal.city}</span>
<span className="flex items-center gap-1.5">
<span>no tracking · no third-party fonts · no analytics</span>
</span>
</div> </div>
</div> </div>
</footer> </footer>
+63 -27
View File
@@ -5,8 +5,15 @@ import { getDict } from "@/i18n/dictionaries";
import { LanguageSwitcher } from "./language-switcher"; import { LanguageSwitcher } from "./language-switcher";
import { MobileNav } from "./mobile-nav"; import { MobileNav } from "./mobile-nav";
import { site } from "@/content/site"; import { site } from "@/content/site";
import { CommandPalette } from "./command-palette";
export function SiteHeader({ locale }: { locale: Locale }) { export function SiteHeader({
locale,
projects,
}: {
locale: Locale;
projects: Array<{ slug: string; summary: string; category: string; status: string }>;
}) {
const dict = getDict(locale); const dict = getDict(locale);
const nav = [ const nav = [
{ href: publicPath(locale, "about"), label: dict.nav.about }, { href: publicPath(locale, "about"), label: dict.nav.about },
@@ -15,34 +22,63 @@ export function SiteHeader({ locale }: { locale: Locale }) {
]; ];
return ( return (
<header className="border-b border-hairline bg-[var(--bg-deep)]"> <>
<div className="mx-auto flex max-w-5xl items-center justify-between px-4 py-4"> <header className="sticky top-0 z-40 border-b border-hairline bg-[color-mix(in_oklab,var(--bg-deep)_85%,transparent)] backdrop-blur-md">
<Link href={publicPath(locale, "home")} className="flex items-center gap-2 font-mono text-sm"> <div className="mx-auto flex max-w-[var(--max-w)] items-center justify-between gap-3 px-4 py-3 md:px-6">
<span className="text-accent">$</span> <Link href={publicPath(locale, "home")} className="group flex items-center gap-2.5 font-mono text-sm" data-cursor="hover">
<span className="text-[var(--fg)]">{site.handle}</span> <span className="inline-flex h-6 w-6 items-center justify-center rounded-sm border border-accent text-[11px] font-bold text-accent transition-colors group-hover:bg-accent group-hover:text-[var(--bg-deep)]">e</span>
<span className="text-muted">@eldov.win</span> <span className="text-[var(--fg)]">{site.handle}</span>
</Link> <span className="hidden text-muted md:inline">@eldov.win</span>
<nav className="hidden gap-6 md:flex" aria-label="primary"> </Link>
{nav.map((n) => ( <div className="flex items-center gap-1 md:gap-4">
<Link <nav className="hidden gap-1 md:flex" aria-label="primary">
key={n.href} {nav.map((n) => (
href={n.href} <Link
className="font-mono text-sm text-[var(--fg)] transition-colors hover:text-accent" key={n.href}
href={n.href}
className="rounded px-3 py-1.5 font-mono text-sm text-[var(--fg-dim)] transition-colors hover:bg-[color-mix(in_oklab,var(--accent)_8%,transparent)] hover:text-accent"
data-cursor="hover"
>
{n.label}
</Link>
))}
</nav>
<div className="hidden md:block">
<LanguageSwitcher currentLocale={locale} />
</div>
<button
type="button"
aria-label="Command palette öffnen"
data-cursor="hover"
onClick={() => {
window.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true }));
}}
className="flex items-center gap-1.5 rounded border border-hairline bg-[color-mix(in_oklab,var(--panel)_50%,transparent)] px-2.5 py-1.5 font-mono text-xs text-muted transition-colors hover:border-accent hover:text-[var(--fg)]"
> >
{n.label} <span className="font-mono text-base leading-none"></span>
</Link> <span>K</span>
))} </button>
<LanguageSwitcher currentLocale={locale} /> <MobileNav locale={locale} items={nav} menuLabel={dict.nav.menu} switchLabel={dict.actions.languageSwitch} />
</nav>
<MobileNav locale={locale} items={nav} menuLabel={dict.nav.menu} switchLabel={dict.actions.languageSwitch} />
</div>
{locale === defaultLocale ? (
<div className="border-t border-hairline bg-[var(--bg-deep)]">
<div className="mx-auto max-w-5xl px-4 py-1 font-mono text-xs text-muted">
<span className="text-accent">note:</span> geschäftliche Anfragen bitte an w-make.com.
</div> </div>
</div> </div>
) : null} {locale === defaultLocale ? (
</header> <div className="border-t border-hairline-2 bg-[var(--bg-deep)]">
<div className="mx-auto max-w-[var(--max-w)] px-4 py-1 font-mono text-[11px] text-muted md:px-6">
<span className="text-accent">note:</span>{" "}
geschäftliche Anfragen bitte an{" "}
<a
href="https://w-make.com"
rel="noopener noreferrer"
className="text-[var(--fg-dim)] underline decoration-dotted underline-offset-2 hover:text-accent"
>
w-make.com
</a>
.
</div>
</div>
) : null}
</header>
<CommandPalette locale={locale} projects={projects} />
</>
); );
} }
+136
View File
@@ -0,0 +1,136 @@
// Inline SVG-Icons für Stack-Items. Klein, theme-aware (currentColor),
// monospace-friendly. Keine externe Library, kein CDN, keine Hydration.
import type { ReactNode } from "react";
type IconProps = { className?: string; title?: string };
function S({ children, title, className }: { children: ReactNode; title?: string; className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden={title ? undefined : true}
role={title ? "img" : undefined}
>
{title ? <title>{title}</title> : null}
{children}
</svg>
);
}
export const StackIcons = {
typescript: (p: IconProps) => (
<S {...p}>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M8 11h4M10 18V11" />
<path d="M14 18h4M15 13c0-1 .8-1.5 2-1.5s2 .5 2 1.5c0 1.5-2 1.5-2 3" />
</S>
),
node: (p: IconProps) => (
<S {...p}>
<path d="M12 2L3 7v10l9 5 9-5V7z" />
<path d="M12 22V12" />
<path d="M3 7l9 5 9-5" />
<path d="M7.5 4.5L16.5 9.5" />
</S>
),
python: (p: IconProps) => (
<S {...p}>
<path d="M9 3h6a3 3 0 013 3v3a3 3 0 01-3 3H9" />
<path d="M15 21H9a3 3 0 01-3-3v-3a3 3 0 013-3h6" />
<path d="M9 3a3 3 0 00-3 3v6a3 3 0 003 3" />
<path d="M15 21a3 3 0 003-3v-6a3 3 0 00-3-3" />
</S>
),
rust: (p: IconProps) => (
<S {...p}>
<circle cx="12" cy="12" r="9" />
<path d="M9 7l3 5 3-5M9 17l3-5 3 5" />
<circle cx="12" cy="12" r="2" fill="currentColor" />
</S>
),
sqlite: (p: IconProps) => (
<S {...p}>
<ellipse cx="12" cy="6" rx="8" ry="3" />
<path d="M4 6v6c0 1.7 3.6 3 8 3s8-1.3 8-3V6" />
<path d="M4 12v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6" />
</S>
),
tailwind: (p: IconProps) => (
<S {...p}>
<path d="M3 12c0-3 2-6 6-6 3 0 5 2 5 5 0-3 2-5 5-5 3 0 5 2 5 5s-2 6-6 6c-3 0-5-2-5-5-1 3-3 5-6 5-4 0-4-3-4-5z" />
</S>
),
react: (p: IconProps) => (
<S {...p}>
<circle cx="12" cy="12" r="2" fill="currentColor" />
<ellipse cx="12" cy="12" rx="9" ry="3.5" />
<ellipse cx="12" cy="12" rx="9" ry="3.5" transform="rotate(60 12 12)" />
<ellipse cx="12" cy="12" rx="9" ry="3.5" transform="rotate(120 12 12)" />
</S>
),
docker: (p: IconProps) => (
<S {...p}>
<rect x="3" y="11" width="3" height="3" />
<rect x="7" y="11" width="3" height="3" />
<rect x="11" y="11" width="3" height="3" />
<rect x="7" y="7" width="3" height="3" />
<rect x="11" y="7" width="3" height="3" />
<rect x="11" y="3" width="3" height="3" />
<rect x="15" y="11" width="3" height="3" />
<path d="M3 14h17l-1 4H4z" />
</S>
),
gitea: (p: IconProps) => (
<S {...p}>
<circle cx="12" cy="12" r="9" />
<path d="M8 8c0-1 1-2 2-2h2c1 0 2 1 2 2v3M8 11h8M10 11l2 6M14 11l-2 6" />
</S>
),
next: (p: IconProps) => (
<S {...p}>
<circle cx="12" cy="12" r="9" />
<path d="M9 8l6 8M9 16h6" />
</S>
),
nix: (p: IconProps) => (
<S {...p}>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M8 8h8M8 16h8" />
<path d="M12 4v16M4 12h16" />
</S>
),
hermes: (p: IconProps) => (
<S {...p}>
<path d="M12 2v6M12 22v-6" />
<path d="M5 7c0-1 1-2 3-2s3 1 3 2 1 2-1 2-2 1-2 3M19 7c0-1-1-2-3-2s-3 1-3 2-1 2 1 2 2 1 2 3" />
<circle cx="12" cy="13" r="2" />
</S>
),
shell: (p: IconProps) => (
<S {...p}>
<rect x="3" y="4" width="18" height="16" rx="2" />
<path d="M7 9l3 3-3 3M13 15h4" />
</S>
),
selfhost: (p: IconProps) => (
<S {...p}>
<path d="M3 12h6l2-6 4 12 2-6h4" />
</S>
),
};
// Mapping: Token-Name → Icon-Komponente. Fallback: shell-icon.
export function StackIcon({ name, className, title }: { name: string; className?: string; title?: string }) {
const key = name.toLowerCase().trim();
for (const [k, c] of Object.entries(StackIcons)) {
if (key.includes(k)) return c({ className, title });
}
return StackIcons.shell({ className, title });
}
+57
View File
@@ -0,0 +1,57 @@
"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>
);
}