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:
+9
-1
@@ -12,9 +12,17 @@ FROM deps AS build
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
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_BUILD_HASH=${NEXT_PUBLIC_BUILD_HASH}
|
||||
ENV NEXT_PUBLIC_BUILD_DATE=${NEXT_PUBLIC_BUILD_DATE}
|
||||
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
|
||||
WORKDIR /app
|
||||
|
||||
@@ -43,9 +43,15 @@ rsync -avz --delete \
|
||||
--exclude "*.log" \
|
||||
./ "$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"
|
||||
ssh "$VPS_HOST" "cd '$VPS_DIR' && \
|
||||
[ -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 up -d app && \
|
||||
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 |
@@ -5,8 +5,25 @@ import { seedIfEmpty } from "@/lib/seed";
|
||||
import { getProfile } from "@/lib/models";
|
||||
import { BioSection } from "@/components/bio-section";
|
||||
import { LinksSection } from "@/components/links-section";
|
||||
import { StackIcon } from "@/components/stack-icon";
|
||||
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({
|
||||
params,
|
||||
}: {
|
||||
@@ -28,13 +45,46 @@ export default async function AboutPage({
|
||||
seedIfEmpty(db);
|
||||
const profile = getProfile(db, locale);
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2">
|
||||
<div className="mx-auto max-w-[var(--max-w)] px-4 py-12 md:px-6">
|
||||
<div className="grid gap-8 lg:grid-cols-[1.6fr_1fr]">
|
||||
<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>
|
||||
<aside>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { isLocale, type Locale } from "@/i18n/routes";
|
||||
import { SiteHeader } from "@/components/site-header";
|
||||
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({
|
||||
children,
|
||||
@@ -11,10 +14,19 @@ export default async function LangLayout({
|
||||
}) {
|
||||
const { lang } = await params;
|
||||
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 (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<SiteHeader locale={locale} />
|
||||
<main className="mx-auto w-full max-w-5xl flex-1 px-4 py-8">{children}</main>
|
||||
<SiteHeader locale={locale} projects={projects} />
|
||||
<main className="flex-1">{children}</main>
|
||||
<SiteFooter locale={locale} />
|
||||
</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 }
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,9 @@ import { getDict } from "@/i18n/dictionaries";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { getProject } from "@/lib/models";
|
||||
import { buildMetadata } from "@/lib/metadata";
|
||||
import { StackIcon } from "@/components/stack-icon";
|
||||
|
||||
const THUMBED = ["hermes", "casino-bot", "polymarket-trader"];
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
@@ -30,55 +33,72 @@ export default async function ProjectDetailPage({
|
||||
const dict = getDict(locale);
|
||||
|
||||
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 (
|
||||
<article className="space-y-6">
|
||||
<article className="mx-auto max-w-[var(--max-w-narrow)] px-4 py-10 md:px-6">
|
||||
<Link
|
||||
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}
|
||||
</Link>
|
||||
|
||||
<header className="ascii-box">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-xs text-muted">{project.number}</span>
|
||||
<span className="badge badge--muted">{project.status}</span>
|
||||
<span className="font-mono text-xs text-muted">{project.category}</span>
|
||||
{project.featured ? <span className="badge badge--live">★</span> : null}
|
||||
{hasThumb ? (
|
||||
<div className="relative mb-8 aspect-[2/1] overflow-hidden border border-hairline">
|
||||
<img
|
||||
src={`/img/projects/${project.slug}.jpg`}
|
||||
alt=""
|
||||
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>
|
||||
<h1 className="mt-3 text-3xl font-semibold">{project.slug}</h1>
|
||||
<p className="mt-2 max-w-2xl text-[var(--fg)]">{project.summary}</p>
|
||||
) : null}
|
||||
|
||||
<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>
|
||||
|
||||
<dl className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="ascii-box">
|
||||
<dt className="font-mono text-xs uppercase text-muted">{dict.projectDetail.statusLabel}</dt>
|
||||
<dd className="mt-1 font-mono text-sm">{project.status}</dd>
|
||||
<section className="mb-8">
|
||||
<div className="section-heading">
|
||||
<span className="section-heading__label">~/stack</span>
|
||||
<h2 className="section-heading__title">Stack</h2>
|
||||
</div>
|
||||
<div className="ascii-box">
|
||||
<dt className="font-mono text-xs uppercase text-muted">{dict.projectDetail.categoryLabel}</dt>
|
||||
<dd className="mt-1 font-mono text-sm">{project.category}</dd>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{stackItems.map((s) => (
|
||||
<span key={s} className="stack-pill">
|
||||
<StackIcon name={s} />
|
||||
{s}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="ascii-box">
|
||||
<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>
|
||||
</section>
|
||||
|
||||
{project.stance ? (
|
||||
<blockquote className="border-l-2 border-accent bg-panel p-4 italic text-muted">
|
||||
<span className="font-mono text-xs uppercase not-italic text-accent">
|
||||
{dict.projectDetail.stanceLabel}:
|
||||
</span>
|
||||
<p className="mt-1 not-italic">{project.stance}</p>
|
||||
<blockquote className="ascii-box mb-8 border-l-2 border-l-accent">
|
||||
<span className="font-mono text-[10px] uppercase tracking-wider text-accent">{dict.projectDetail.stanceLabel}:</span>
|
||||
<p className="mt-2 text-[var(--fg-dim)] italic">{project.stance}</p>
|
||||
</blockquote>
|
||||
) : 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)]">
|
||||
{paragraphs.map((p, i) => (
|
||||
<p key={i}>{p}</p>
|
||||
<p key={i} className="leading-relaxed">{p}</p>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
@@ -88,7 +108,8 @@ export default async function ProjectDetailPage({
|
||||
href={project.href}
|
||||
target="_blank"
|
||||
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} ↗
|
||||
</a>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getDict } from "@/i18n/dictionaries";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { seedIfEmpty } from "@/lib/seed";
|
||||
import { listProjects } from "@/lib/models";
|
||||
import { ProjectGrid } from "@/components/project-grid";
|
||||
import { ProjectFilter } from "@/components/project-filter";
|
||||
import { buildMetadata } from "@/lib/metadata";
|
||||
|
||||
export async function generateMetadata({
|
||||
@@ -28,13 +28,13 @@ export default async function ProjectsPage({
|
||||
const projects = listProjects(db, locale);
|
||||
const dict = getDict(locale);
|
||||
return (
|
||||
<>
|
||||
<div className="mx-auto max-w-[var(--max-w)] px-4 py-12 md:px-6">
|
||||
<div className="section-heading">
|
||||
<span className="section-heading__label">~/projects</span>
|
||||
<h1 className="section-heading__title">{dict.projects.title}</h1>
|
||||
</div>
|
||||
<p className="mb-6 max-w-2xl text-[var(--fg)]">{dict.projects.lede}</p>
|
||||
<ProjectGrid projects={projects} locale={locale} showStance emptyLabel={dict.projects.emptyState} />
|
||||
</>
|
||||
<p className="mb-6 max-w-2xl text-[var(--fg-dim)]">{dict.projects.lede}</p>
|
||||
<ProjectFilter projects={projects} locale={locale} emptyLabel={dict.projects.emptyState} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+7
-1
@@ -2,6 +2,8 @@ import type { Metadata } from "next";
|
||||
import { headers } from "next/headers";
|
||||
import { isLocale } from "@/i18n/routes";
|
||||
import { site } from "@/content/site";
|
||||
import { CustomCursor } from "@/components/custom-cursor";
|
||||
import { ScrollProgress } from "@/components/scroll-progress";
|
||||
import "./globals.css";
|
||||
|
||||
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";
|
||||
return (
|
||||
<html lang={lang} className="h-full antialiased">
|
||||
<body className="min-h-full">{children}</body>
|
||||
<body className="min-h-full">
|
||||
<ScrollProgress />
|
||||
<CustomCursor />
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
+80
-36
@@ -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 { seedIfEmpty } from "@/lib/seed";
|
||||
import { getProfile, listFeaturedProjects, listProjects } from "@/lib/models";
|
||||
import { BioSection } from "@/components/bio-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 { getDict } from "@/i18n/dictionaries";
|
||||
import Link from "next/link";
|
||||
import { publicPath } from "@/i18n/routes";
|
||||
import { HeroBanner } from "@/components/hero-banner";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -27,51 +23,99 @@ export default async function RootIndex() {
|
||||
return (
|
||||
<>
|
||||
<JsonLd profile={profile} featured={featured} />
|
||||
<HeroBanner dict={dict.home} heroImage="/img/hero/hero-bg.jpg" />
|
||||
|
||||
<section className="ascii-box mb-8">
|
||||
<p className="font-mono text-xs uppercase tracking-wider text-muted">{dict.home.kicker}</p>
|
||||
<h1
|
||||
className="headline-glitch mt-2 text-4xl font-semibold leading-tight md:text-5xl"
|
||||
data-text={dict.home.title}
|
||||
>
|
||||
{dict.home.title}
|
||||
</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="mx-auto max-w-[var(--max-w)] px-4 md:px-6">
|
||||
{/* FEATURED */}
|
||||
<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>
|
||||
</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"
|
||||
>
|
||||
<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>
|
||||
<ProjectGrid projects={all.slice(0, 6)} locale="de" />
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{featured.map((p) => (
|
||||
<FeaturedTile key={p.slug} project={p} />
|
||||
))}
|
||||
</div>
|
||||
<aside className="space-y-6">
|
||||
</section>
|
||||
|
||||
{/* 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 */}
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="ascii-box mt-12">
|
||||
{/* 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
@@ -1,50 +1,67 @@
|
||||
/* Eldov-Win Theme.
|
||||
/* Eldov-Win Theme — v2.
|
||||
*
|
||||
* Eigener Look, absichtlich getrennt vom Industrie-Glas-Theme der Schwester-Site
|
||||
* w-make.com. Hier: dunkel, monospace-Akzent, magenta-grüne Anklänge, viel
|
||||
* Hairline. Wer hier landet, soll sofort wissen: das ist die Spielwiese, nicht
|
||||
* die Auftragsarbeit.
|
||||
* Eigener Look, getrennt vom Industrie-Glas-Theme der Schwester-Site
|
||||
* w-make.com. Hier: dunkel, monospace-Akzente, magenta-grüne Anklänge,
|
||||
* viel Hairline. Wer hier landet, soll sofort wissen: das ist die
|
||||
* Spielwiese, nicht die Auftragsarbeit.
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* Palette */
|
||||
--bg: #0b0b0e;
|
||||
--bg-deep: #06060a;
|
||||
--panel: #111118;
|
||||
--panel-2: #161622;
|
||||
/* Palette — bewusst kühler und tiefer als v1, damit AI-Bilder
|
||||
* (mit viel Schwarz-Anteil) sich ohne Filter einfügen. */
|
||||
--bg: #0a0a10;
|
||||
--bg-deep: #050508;
|
||||
--bg-glow: #11111a;
|
||||
--panel: #14141e;
|
||||
--panel-2: #1a1a26;
|
||||
--fg: #e7e7ee;
|
||||
--muted: #8b8ba3;
|
||||
--fg-dim: #b8b8c8;
|
||||
--muted: #6e6e85;
|
||||
--muted-2: #4a4a5c;
|
||||
--hairline: #232333;
|
||||
--hairline-2: #1d1d2a;
|
||||
--accent: #e84a8f; /* Magenta */
|
||||
--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;
|
||||
|
||||
/* Typografie */
|
||||
--font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, "Noto Sans", sans-serif;
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono",
|
||||
monospace;
|
||||
--font-sans: "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,
|
||||
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 {
|
||||
/* Schmaler Hairline-Scrollbar im dunklen Stil */
|
||||
*::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
*::-webkit-scrollbar-track {
|
||||
background: var(--bg-deep);
|
||||
}
|
||||
/* Schmaler Hairline-Scrollbar */
|
||||
*::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
*::-webkit-scrollbar-track { background: transparent; }
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: var(--hairline);
|
||||
border-radius: 8px;
|
||||
}
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--accent);
|
||||
*::-webkit-scrollbar-thumb:hover { 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) {
|
||||
*,
|
||||
*::before,
|
||||
@@ -57,12 +74,16 @@
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* ASCII-Box, ähnlich der Sektion in w-make.com, aber monochrom + Akzent */
|
||||
/* ASCII-Box mit Hairline + Eck-Akzenten */
|
||||
.ascii-box {
|
||||
position: relative;
|
||||
border: 1px solid var(--hairline);
|
||||
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::after {
|
||||
@@ -71,6 +92,7 @@
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-color: var(--accent);
|
||||
pointer-events: none;
|
||||
}
|
||||
.ascii-box::before {
|
||||
top: -1px;
|
||||
@@ -85,7 +107,7 @@
|
||||
border-right: 1px solid;
|
||||
}
|
||||
|
||||
/* Status-Badge: klein, monospace, farbcodiert */
|
||||
/* Status-Badge */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -93,20 +115,27 @@
|
||||
padding: 0.2rem 0.55rem;
|
||||
border: 1px solid var(--hairline);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.02em;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
.badge::before {
|
||||
content: "■";
|
||||
color: var(--accent);
|
||||
color: var(--muted);
|
||||
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--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 {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
@@ -118,16 +147,38 @@
|
||||
.section-heading__label {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.section-heading__title {
|
||||
font-size: 1.5rem;
|
||||
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 {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
@@ -140,19 +191,120 @@
|
||||
left: 0;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
.headline-glitch::before {
|
||||
color: var(--accent);
|
||||
transform: translate(-2px, 0);
|
||||
mix-blend-mode: screen;
|
||||
opacity: 0.6;
|
||||
opacity: 0.55;
|
||||
clip-path: polygon(0 0, 100% 0, 100% 45%, 0 45%);
|
||||
animation: glitch-x 4s steps(2) infinite;
|
||||
}
|
||||
.headline-glitch::after {
|
||||
color: var(--accent-2);
|
||||
transform: translate(2px, 0);
|
||||
mix-blend-mode: screen;
|
||||
opacity: 0.6;
|
||||
opacity: 0.55;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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"} "{query}"
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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"> </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"> </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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}%` }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -4,32 +4,57 @@ import { site } from "@/content/site";
|
||||
import { getLegal } from "@/content/legal";
|
||||
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 }) {
|
||||
const legal = getLegal(locale);
|
||||
const year = new Date().getFullYear();
|
||||
const build = buildInfo();
|
||||
return (
|
||||
<footer className="mt-16 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>
|
||||
<p className="font-mono text-sm text-[var(--fg)]">{site.handle}</p>
|
||||
<p className="text-xs text-muted">{legal.city}</p>
|
||||
<footer className="mt-24 border-t border-hairline bg-[var(--bg-deep)]">
|
||||
<div className="mx-auto grid max-w-[var(--max-w)] gap-10 px-4 py-12 md:grid-cols-4 md:px-6">
|
||||
<div className="md:col-span-2">
|
||||
<div className="mb-3 flex items-center gap-2 font-mono">
|
||||
<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>
|
||||
<span className="text-[var(--fg)]">{site.handle}</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted">
|
||||
<p>
|
||||
{legal.name} · {year}
|
||||
<p className="mb-4 max-w-md text-sm text-[var(--fg-dim)]">{site.tagline}</p>
|
||||
<p className="font-mono text-[11px] text-muted">
|
||||
build <span className="text-accent">{build.hash.slice(0, 7)}</span> · {build.date}
|
||||
</p>
|
||||
<p className="mt-1">{legal.contactHint}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 text-xs">
|
||||
<Link className="text-muted hover:text-accent" href={publicPath(locale, "legal")}>
|
||||
{locale === "de" ? "Impressum" : "Legal"}
|
||||
</Link>
|
||||
<Link className="text-muted hover:text-accent" href={publicPath(locale, "privacy")}>
|
||||
{locale === "de" ? "Datenschutz" : "Privacy"}
|
||||
</Link>
|
||||
<a className="text-muted hover:text-accent" href={site.siblingSite.href} rel="noopener noreferrer">
|
||||
{site.siblingSite.label} ↗
|
||||
</a>
|
||||
<div>
|
||||
<h4 className="mb-3 font-mono text-[10px] uppercase tracking-wider text-muted">eldov.win</h4>
|
||||
<ul className="space-y-1.5 text-sm">
|
||||
<li><Link href={publicPath(locale, "about")} className="text-[var(--fg-dim)] hover:text-accent">{locale === "de" ? "Über mich" : "About"}</Link></li>
|
||||
<li><Link href={publicPath(locale, "projects")} className="text-[var(--fg-dim)] hover:text-accent">{locale === "de" ? "Projekte" : "Projects"}</Link></li>
|
||||
<li><Link href={publicPath(locale, "contact")} className="text-[var(--fg-dim)] hover:text-accent">{locale === "de" ? "Kontakt" : "Contact"}</Link></li>
|
||||
<li><Link href={publicPath(locale, "legal")} className="text-[var(--fg-dim)] hover:text-accent">{locale === "de" ? "Impressum" : "Legal"}</Link></li>
|
||||
<li><Link href={publicPath(locale, "privacy")} className="text-[var(--fg-dim)] hover:text-accent">{locale === "de" ? "Datenschutz" : "Privacy"}</Link></li>
|
||||
</ul>
|
||||
</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>
|
||||
</footer>
|
||||
|
||||
@@ -5,8 +5,15 @@ import { getDict } from "@/i18n/dictionaries";
|
||||
import { LanguageSwitcher } from "./language-switcher";
|
||||
import { MobileNav } from "./mobile-nav";
|
||||
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 nav = [
|
||||
{ href: publicPath(locale, "about"), label: dict.nav.about },
|
||||
@@ -15,34 +22,63 @@ export function SiteHeader({ locale }: { locale: Locale }) {
|
||||
];
|
||||
|
||||
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">
|
||||
<Link href={publicPath(locale, "home")} className="flex items-center gap-2 font-mono text-sm">
|
||||
<span className="text-accent">$</span>
|
||||
<>
|
||||
<header className="sticky top-0 z-40 border-b border-hairline bg-[color-mix(in_oklab,var(--bg-deep)_85%,transparent)] backdrop-blur-md">
|
||||
<div className="mx-auto flex max-w-[var(--max-w)] items-center justify-between gap-3 px-4 py-3 md:px-6">
|
||||
<Link href={publicPath(locale, "home")} className="group flex items-center gap-2.5 font-mono text-sm" data-cursor="hover">
|
||||
<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-[var(--fg)]">{site.handle}</span>
|
||||
<span className="text-muted">@eldov.win</span>
|
||||
<span className="hidden text-muted md:inline">@eldov.win</span>
|
||||
</Link>
|
||||
<nav className="hidden gap-6 md:flex" aria-label="primary">
|
||||
<div className="flex items-center gap-1 md:gap-4">
|
||||
<nav className="hidden gap-1 md:flex" aria-label="primary">
|
||||
{nav.map((n) => (
|
||||
<Link
|
||||
key={n.href}
|
||||
href={n.href}
|
||||
className="font-mono text-sm text-[var(--fg)] transition-colors hover:text-accent"
|
||||
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>
|
||||
))}
|
||||
<LanguageSwitcher currentLocale={locale} />
|
||||
</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)]"
|
||||
>
|
||||
<span className="font-mono text-base leading-none">⌘</span>
|
||||
<span>K</span>
|
||||
</button>
|
||||
<MobileNav locale={locale} items={nav} menuLabel={dict.nav.menu} switchLabel={dict.actions.languageSwitch} />
|
||||
</div>
|
||||
</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 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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user