+
{project.slug}
-{project.summary}
+ ) : null} + +{project.slug}
+{project.summary}
-
-
- {dict.projectDetail.statusLabel} -
- {project.status} +
- {dict.projectDetail.categoryLabel} -
- {project.category} +
- {dict.projectDetail.stackLabel} -
- {project.stack} -
Stack
- - {dict.projectDetail.stanceLabel}: - -{project.stance}
++ {dict.projectDetail.stanceLabel}: +) : null} -{project.stance}
+ + @@ -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 ( - <> ++ ~/notes +{locale === "de" ? "Notizen" : "Notes"}
+{paragraphs.map((p, i) => ( -{p}
+{p}
))}); } 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} + +~/projects-{dict.projects.title}
{dict.projects.lede}
-- > + {dict.projects.lede}
++ + + {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 ? ( - <> -> ); } + +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 ( +- ~/featured --{dict.home.featuredLabel}
-- > - ) : null} - - ~/all -+{dict.home.allProjectsLabel}
- ++ {/* FEATURED */} +- -+ + ~/featured +-{dict.home.featuredLabel}
+ {dict.actions.viewProjects} →- + {featured.map((p) => ( ++ -+ ))} + - + {/* ALL PROJECTS mit Filter */} +{dict.home.cta.title}
-{dict.home.cta.body}
-+ + + {/* BIO + LINKS */} ++ ~/projects ++{dict.home.allProjectsLabel}
++ + + + {/* CTA */} +++ ++ + +{dict.home.cta.title}
+{dict.home.cta.body}
++ {hasThumb ? ( + + ); +} 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 ?++ ) : ( ++ + + {project.number} + +
+ {project.number} ++ )} ++++ {project.category} + {project.status} +++ {project.slug} +
+{project.summary}
++ {project.stack.split("·").slice(0, 4).map((s, i) => ( + +++ {s.trim()} + + ))} + 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)} + > + ++ ); +} 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 = useRefe.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"} + ++(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) */} + + + ); +} + +function BootSequence({ typed, lineIdx, done, rotIdx }: { typed: string; lineIdx: number; done: boolean; rotIdx: number }) { + return ( +++ {/* Subtle Bottom-Fade zu bg */} + ++++++ + {dict.kicker} +
++ {dict.title} +
++ {dict.lede} +
++ + {dict.cta.title} + → + + + ⌘ + K + search + +++++ ++ ); +} 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+ + + 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 ++ > + )} +(); + 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 ( + ++ ); +} 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 ( -+ 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) => ( ++ )} +++ ))} ++