diff --git a/Dockerfile b/Dockerfile index a12564d..48133dd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/deploy-vps.sh b/deploy-vps.sh index b756865..9b57902 100755 --- a/deploy-vps.sh +++ b/deploy-vps.sh @@ -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" diff --git a/public/img/hero/hero-bg.jpg b/public/img/hero/hero-bg.jpg new file mode 100644 index 0000000..45ebfd1 Binary files /dev/null and b/public/img/hero/hero-bg.jpg differ diff --git a/public/img/projects/casino-bot.jpg b/public/img/projects/casino-bot.jpg new file mode 100644 index 0000000..0bcf40b Binary files /dev/null and b/public/img/projects/casino-bot.jpg differ diff --git a/public/img/projects/hermes.jpg b/public/img/projects/hermes.jpg new file mode 100644 index 0000000..8b7c844 Binary files /dev/null and b/public/img/projects/hermes.jpg differ diff --git a/public/img/projects/polymarket-trader.jpg b/public/img/projects/polymarket-trader.jpg new file mode 100644 index 0000000..5e0ca01 Binary files /dev/null and b/public/img/projects/polymarket-trader.jpg differ diff --git a/src/app/[lang]/about/page.tsx b/src/app/[lang]/about/page.tsx index 8cb15e8..533f5aa 100644 --- a/src/app/[lang]/about/page.tsx +++ b/src/app/[lang]/about/page.tsx @@ -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 ( -
-
- +
+
+
+ + +
+
+ ~/stack +

+ {locale === "de" ? "Stack & Werkzeuge" : "Stack & tools"} +

+
+

+ {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."} +

+
+ {STACK_PRIMARY.map((s) => ( +
+ + + +
+
{s.label}
+
{s.key}
+
+
+ ))} +
+
+
+
-
); } diff --git a/src/app/[lang]/layout.tsx b/src/app/[lang]/layout.tsx index f2a8527..a222420 100644 --- a/src/app/[lang]/layout.tsx +++ b/src/app/[lang]/layout.tsx @@ -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 (
- -
{children}
+ +
{children}
); diff --git a/src/app/[lang]/projects/[slug]/opengraph-image.tsx b/src/app/[lang]/projects/[slug]/opengraph-image.tsx new file mode 100644 index 0000000..c4b2e06 --- /dev/null +++ b/src/app/[lang]/projects/[slug]/opengraph-image.tsx @@ -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(
eldov.win
, size); + } + const dict: Record = { + de: { eyebrow: "Projekt" }, + en: { eyebrow: "Project" }, + }; + return new ImageResponse( + ( +
+
+
+
e
+
eldov
+
@eldov.win
+
+
+ {dict[locale].eyebrow.toUpperCase()} · {project.number} +
+
+ +
+
+ {project.slug} +
+
+ {project.summary} +
+
+ +
+
+
{project.status}
+
{project.category}
+
+
+ eldov.win / {project.slug} +
+
+
+ ), + { ...size } + ); +} diff --git a/src/app/[lang]/projects/[slug]/page.tsx b/src/app/[lang]/projects/[slug]/page.tsx index debbf15..7b93bd4 100644 --- a/src/app/[lang]/projects/[slug]/page.tsx +++ b/src/app/[lang]/projects/[slug]/page.tsx @@ -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 ( -
+
← {dict.actions.backToProjects} -
-
- {project.number} - {project.status} - {project.category} - {project.featured ? : null} + {hasThumb ? ( +
+ +
-

{project.slug}

-

{project.summary}

+ ) : null} + +
+
+ {project.number} + {project.status} + {project.category} + {project.featured ? ★ featured : null} +
+

{project.slug}

+

{project.summary}

-
-
-
{dict.projectDetail.statusLabel}
-
{project.status}
+
+
+ ~/stack +

Stack

-
-
{dict.projectDetail.categoryLabel}
-
{project.category}
+
+ {stackItems.map((s) => ( + + + {s} + + ))}
-
-
{dict.projectDetail.stackLabel}
-
{project.stack}
-
-
+ {project.stance ? ( -
- - {dict.projectDetail.stanceLabel}: - -

{project.stance}

+
+ {dict.projectDetail.stanceLabel}: +

{project.stance}

) : null} -
+
+
+ ~/notes +

{locale === "de" ? "Notizen" : "Notes"}

+
{paragraphs.map((p, i) => ( -

{p}

+

{p}

))}
@@ -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} ↗ diff --git a/src/app/[lang]/projects/page.tsx b/src/app/[lang]/projects/page.tsx index 75886eb..57ad698 100644 --- a/src/app/[lang]/projects/page.tsx +++ b/src/app/[lang]/projects/page.tsx @@ -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 ( - <> +
~/projects

{dict.projects.title}

-

{dict.projects.lede}

- - +

{dict.projects.lede}

+ +
); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index cb12532..21d21c4 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 ( - {children} + + + + {children} + ); } diff --git a/src/app/page.tsx b/src/app/page.tsx index 01b8a49..69d6799 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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 ( <> + -
-

{dict.home.kicker}

-

- {dict.home.title} -

-

{dict.home.lede}

-
- -
-
- {featured.length > 0 ? ( - <> -
- ~/featured -

{dict.home.featuredLabel}

-
- - - ) : null} -
- ~/all -

{dict.home.allProjectsLabel}

- +
+ {/* FEATURED */} +
- -
+
+ {featured.map((p) => ( + + ))} +
+
-
-

{dict.home.cta.title}

-

{dict.home.cta.body}

-
+ {/* ALL PROJECTS mit Filter */} +
+
+ ~/projects +

{dict.home.allProjectsLabel}

+
+ +
+ + {/* BIO + LINKS */} +
+
+ +
+ +
+ + {/* CTA */} +
+

{dict.home.cta.title}

+

{dict.home.cta.body}

+
+
); } + +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 ( + + {hasThumb ? ( +
+ +
+ + {project.number} + +
+ ) : ( +
+ {project.number} +
+ )} +
+
+ {project.category} + {project.status} +
+

+ {project.slug} +

+

{project.summary}

+
+ {project.stack.split("·").slice(0, 4).map((s, i) => ( + + + {s.trim()} + + ))} +
+
+ + ); +} diff --git a/src/app/theme.css b/src/app/theme.css index 0c07813..3437f00 100644 --- a/src/app/theme.css +++ b/src/app/theme.css @@ -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); } } diff --git a/src/components/command-bar.tsx b/src/components/command-bar.tsx new file mode 100644 index 0000000..7aea750 --- /dev/null +++ b/src/components/command-bar.tsx @@ -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 ( + <> + + {open ?
setOpen(false)}>{children}
: null} + + ); +} diff --git a/src/components/command-palette.tsx b/src/components/command-palette.tsx new file mode 100644 index 0000000..08698d7 --- /dev/null +++ b/src/components/command-palette.tsx @@ -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(null); + const listRef = useRef(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) { + 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 = { + nav: locale === "de" ? "Navigation" : "Navigation", + project: locale === "de" ? "Projekte" : "Projects", + action: locale === "de" ? "Aktionen" : "Actions", + easter: locale === "de" ? "Easter Eggs" : "Easter Eggs", + }; + + return ( +
setOpen(false)} + > +
+
e.stopPropagation()} + onKeyDown={onKeyDown} + > +
+ ⌘K + 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} + /> + esc +
+
+ {filtered.length === 0 ? ( +
+ {locale === "de" ? "Keine Treffer für" : "No results for"} "{query}" +
+ ) : ( + groups.map((g) => { + const itemsInGroup = filtered.filter((i) => i.group === g); + if (itemsInGroup.length === 0) return null; + return ( +
+
+ {groupLabels[g]} +
+ {itemsInGroup.map((i) => { + const globalIndex = filtered.indexOf(i); + const isActive = globalIndex === active; + return ( + + ); + })} +
+ ); + }) + )} +
+
+ {filtered.length} {locale === "de" ? "Treffer" : "matches"} + + ↑↓ + + {locale === "de" ? "Auswählen" : "Select"} + +
+
+
+ ); +} diff --git a/src/components/custom-cursor.tsx b/src/components/custom-cursor.tsx new file mode 100644 index 0000000..8925bd9 --- /dev/null +++ b/src/components/custom-cursor.tsx @@ -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(null); + const rafRef = useRef(null); + const points = useRef>([]); + 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 ( + + ); +} diff --git a/src/components/hero-banner.tsx b/src/components/hero-banner.tsx new file mode 100644 index 0000000..c254f3b --- /dev/null +++ b/src/components/hero-banner.tsx @@ -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 ( +
+
+ {/* Grid-Linien (sehr subtil) */} +
+
+
+
+

+ + {dict.kicker} +

+

+ {dict.title} +

+

+ {dict.lede} +

+
+ + {dict.cta.title} + + + + + K + search + +
+
+
+ +
+
+
+ {/* Subtle Bottom-Fade zu bg */} +
+
+ ); +} + +function BootSequence({ typed, lineIdx, done, rotIdx }: { typed: string; lineIdx: number; done: boolean; rotIdx: number }) { + return ( +
+
+ + + terminal ~ eldov + + v0.42.0 +
+
+ {BOOT_LINES.slice(0, lineIdx).map((l, i) => ( +
+ {l} +
+ ))} + {!done ? ( +
+ {BOOT_LINES[lineIdx]?.slice(0, typed.length)} +   +
+ ) : ( + <> +
{BOOT_LINES[BOOT_LINES.length - 1]}
+
+ role:{" "} + + {ROTATING[rotIdx]} + +   +
+
+ since: 2003 (online-handle) +
+
+ now: building things that survive contact with users +
+ + )} +
+
+ ); +} diff --git a/src/components/project-filter.tsx b/src/components/project-filter.tsx new file mode 100644 index 0000000..bd88fe1 --- /dev/null +++ b/src/components/project-filter.tsx @@ -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(); + 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(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 ( +
+
+ filter + + {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 ( + + ); + })} +
+ {filtered.length === 0 ? ( +
+ {emptyLabel} +
+ ) : ( +
+ {filtered.map((p) => ( +
+ +
+ ))} +
+ )} +
+ ); +} diff --git a/src/components/scroll-progress.tsx b/src/components/scroll-progress.tsx new file mode 100644 index 0000000..ab9fd29 --- /dev/null +++ b/src/components/scroll-progress.tsx @@ -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 ( +
+ ); +} diff --git a/src/components/site-footer.tsx b/src/components/site-footer.tsx index 0f0e667..579c12f 100644 --- a/src/components/site-footer.tsx +++ b/src/components/site-footer.tsx @@ -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 ( -
-
-
-

{site.handle}

-

{legal.city}

-
-
-

- {legal.name} · {year} +

+
+
+
+ e + {site.handle} +
+

{site.tagline}

+

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

-

{legal.contactHint}

-
- - {locale === "de" ? "Impressum" : "Legal"} - - - {locale === "de" ? "Datenschutz" : "Privacy"} - - - {site.siblingSite.label} ↗ - +
+

eldov.win

+
    +
  • {locale === "de" ? "Über mich" : "About"}
  • +
  • {locale === "de" ? "Projekte" : "Projects"}
  • +
  • {locale === "de" ? "Kontakt" : "Contact"}
  • +
  • {locale === "de" ? "Impressum" : "Legal"}
  • +
  • {locale === "de" ? "Datenschutz" : "Privacy"}
  • +
+
+ +
+
+
+ © {year} {legal.name} · {legal.city} + + no tracking · no third-party fonts · no analytics +
diff --git a/src/components/site-header.tsx b/src/components/site-header.tsx index 52c73e4..f2f569e 100644 --- a/src/components/site-header.tsx +++ b/src/components/site-header.tsx @@ -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 ( -
-
- - $ - {site.handle} - @eldov.win - - - -
- {locale === defaultLocale ? ( -
-
- note: geschäftliche Anfragen bitte an w-make.com. + + K + +
- ) : null} -
+ {locale === defaultLocale ? ( +
+
+ note:{" "} + geschäftliche Anfragen bitte an{" "} + + w-make.com + + . +
+
+ ) : null} +
+ + ); } diff --git a/src/components/stack-icon.tsx b/src/components/stack-icon.tsx new file mode 100644 index 0000000..1b5910a --- /dev/null +++ b/src/components/stack-icon.tsx @@ -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 ( + + {title ? {title} : null} + {children} + + ); +} + +export const StackIcons = { + typescript: (p: IconProps) => ( + + + + + + ), + node: (p: IconProps) => ( + + + + + + + ), + python: (p: IconProps) => ( + + + + + + + ), + rust: (p: IconProps) => ( + + + + + + ), + sqlite: (p: IconProps) => ( + + + + + + ), + tailwind: (p: IconProps) => ( + + + + ), + react: (p: IconProps) => ( + + + + + + + ), + docker: (p: IconProps) => ( + + + + + + + + + + + ), + gitea: (p: IconProps) => ( + + + + + ), + next: (p: IconProps) => ( + + + + + ), + nix: (p: IconProps) => ( + + + + + + ), + hermes: (p: IconProps) => ( + + + + + + ), + shell: (p: IconProps) => ( + + + + + ), + selfhost: (p: IconProps) => ( + + + + ), +}; + +// 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 }); +} diff --git a/src/components/tilt-card.tsx b/src/components/tilt-card.tsx new file mode 100644 index 0000000..1391e47 --- /dev/null +++ b/src/components/tilt-card.tsx @@ -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(null); + + function onMove(e: React.MouseEvent) { + 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 ( +
+
+ {href ? ( + + {children} + + ) : ( + children + )} +
+ ); +}