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:
@@ -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>
|
||||
</div>
|
||||
<div className="text-xs text-muted">
|
||||
<p>
|
||||
{legal.name} · {year}
|
||||
<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>
|
||||
<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>
|
||||
<span className="text-[var(--fg)]">{site.handle}</span>
|
||||
<span className="text-muted">@eldov.win</span>
|
||||
</Link>
|
||||
<nav className="hidden gap-6 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"
|
||||
<>
|
||||
<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="hidden text-muted md:inline">@eldov.win</span>
|
||||
</Link>
|
||||
<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="rounded px-3 py-1.5 font-mono text-sm text-[var(--fg-dim)] transition-colors hover:bg-[color-mix(in_oklab,var(--accent)_8%,transparent)] hover:text-accent"
|
||||
data-cursor="hover"
|
||||
>
|
||||
{n.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<div className="hidden md:block">
|
||||
<LanguageSwitcher currentLocale={locale} />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Command palette öffnen"
|
||||
data-cursor="hover"
|
||||
onClick={() => {
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true }));
|
||||
}}
|
||||
className="flex items-center gap-1.5 rounded border border-hairline bg-[color-mix(in_oklab,var(--panel)_50%,transparent)] px-2.5 py-1.5 font-mono text-xs text-muted transition-colors hover:border-accent hover:text-[var(--fg)]"
|
||||
>
|
||||
{n.label}
|
||||
</Link>
|
||||
))}
|
||||
<LanguageSwitcher currentLocale={locale} />
|
||||
</nav>
|
||||
<MobileNav locale={locale} items={nav} menuLabel={dict.nav.menu} switchLabel={dict.actions.languageSwitch} />
|
||||
</div>
|
||||
{locale === defaultLocale ? (
|
||||
<div className="border-t border-hairline bg-[var(--bg-deep)]">
|
||||
<div className="mx-auto max-w-5xl px-4 py-1 font-mono text-xs text-muted">
|
||||
<span className="text-accent">note:</span> geschäftliche Anfragen bitte an w-make.com.
|
||||
<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>
|
||||
) : null}
|
||||
</header>
|
||||
{locale === defaultLocale ? (
|
||||
<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