Initial commit: eldov.win portfolio with admin backend

Privates Portfolio von eldov (Jan Wagner). Next.js 16 / React 19 / Tailwind 4 /
SQLite. Eigenes Dark-/Terminal-Theme (Magenta+Grün-Akzente, ASCII-Boxen).

Public Routes (DE default, /en für Englisch):
- /, /ueber-mich, /projekte, /projekte/[slug], /kontakt, /impressum, /datenschutz
- Sitemap, robots, OG, JSON-LD

Admin (HMAC-Single-User, HttpOnly-Cookie):
- /admin/login, /admin/dashboard, /admin/profile, /admin/projects (CRUD)
- API: /api/admin/{login,logout,session,profile,projects,projects/[slug]}

Initial-Seed: Hermes, Casino-Bot, Polymarket-Trader, QuantMuse, Crypto-/Trade-Bot,
TS6-Bot, Freewarez-Landingpage, Minecraft-Portal, OSS-Themes, AI-Subscription-Manager,
LAMP-Link-Themes, Android. KEIN Batchmaker/W-Make (das gehört zu w-make.com).

Stack:
- better-sqlite3 mit Schema-Migration + idempotentem Seed
- HMAC-signiertes Session-Cookie mit timingSafeEqual
- Rate-Limit für /api/admin/login
- Docker + deploy-vps.sh analog w-make-com → free-warez.top
This commit is contained in:
Jan Wagner
2026-08-31 19:32:17 +02:00
commit ef0f4fbc82
74 changed files with 11605 additions and 0 deletions
+52
View File
@@ -0,0 +1,52 @@
import Link from "next/link";
import type { Locale } from "@/i18n/routes";
import { getDict } from "@/i18n/dictionaries";
import { LogoutButton } from "./logout-button";
export function AdminShell({
locale,
title,
active,
children,
}: {
locale: Locale;
title: string;
active: "dashboard" | "profile" | "projects" | "newProject" | "editProject";
children: React.ReactNode;
}) {
const dict = getDict(locale);
const items = [
{ id: "dashboard", href: "/admin/dashboard", label: dict.admin.dashboardTitle },
{ id: "profile", href: "/admin/profile", label: dict.admin.profileTitle },
{ id: "projects", href: "/admin/projects", label: dict.admin.projectsTitle },
] as const;
return (
<div className="min-h-screen bg-[var(--bg)] text-[var(--fg)]">
<header className="border-b border-hairline bg-[var(--bg-deep)]">
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-3">
<div className="flex items-center gap-4">
<Link href="/admin/dashboard" className="font-mono text-sm">
<span className="text-accent">$</span> eldov<span className="text-muted">.admin</span>
</Link>
<span className="font-mono text-xs text-muted">[{title}]</span>
</div>
<LogoutButton locale={locale} />
</div>
<nav className="mx-auto flex max-w-6xl gap-4 px-4 pb-2">
{items.map((it) => (
<Link
key={it.id}
href={it.href}
className={`border-b-2 px-2 pb-2 font-mono text-xs uppercase ${
active === it.id ? "border-accent text-accent" : "border-transparent text-muted hover:text-[var(--fg)]"
}`}
>
{it.label}
</Link>
))}
</nav>
</header>
<main className="mx-auto max-w-6xl px-4 py-8">{children}</main>
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import type { Locale } from "@/i18n/routes";
import { getDict } from "@/i18n/dictionaries";
export function LoginForm({ locale }: { locale: Locale }) {
const dict = getDict(locale);
const router = useRouter();
const [password, setPassword] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setSubmitting(true);
setError(null);
try {
const res = await fetch("/api/admin/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
if (res.status === 429) {
setError(dict.admin.errorRateLimited);
return;
}
if (!res.ok) {
setError(dict.admin.errorInvalid);
return;
}
router.push("/admin/dashboard");
router.refresh();
} catch {
setError(dict.admin.errorInvalid);
} finally {
setSubmitting(false);
}
}
return (
<form onSubmit={onSubmit} className="ascii-box space-y-4">
<label className="block">
<span className="mb-1 block font-mono text-xs uppercase text-muted">{dict.admin.passwordLabel}</span>
<input
type="password"
name="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={submitting}
className="w-full border border-hairline bg-[var(--bg-deep)] px-3 py-2 font-mono text-sm text-[var(--fg)] focus:border-accent focus:outline-none"
/>
</label>
<button
type="submit"
disabled={submitting}
className="border border-accent bg-accent px-4 py-2 font-mono text-sm uppercase text-[var(--bg)] disabled:opacity-50"
>
{submitting ? dict.admin.submitting : dict.admin.submit}
</button>
{error ? <p className="text-sm text-[var(--danger)]">{error}</p> : null}
</form>
);
}
+33
View File
@@ -0,0 +1,33 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import type { Locale } from "@/i18n/routes";
import { getDict } from "@/i18n/dictionaries";
export function LogoutButton({ locale }: { locale: Locale }) {
const dict = getDict(locale);
const router = useRouter();
const [busy, setBusy] = useState(false);
async function onClick() {
setBusy(true);
try {
await fetch("/api/admin/logout", { method: "POST" });
router.push("/admin/login");
router.refresh();
} finally {
setBusy(false);
}
}
return (
<button
type="button"
onClick={onClick}
disabled={busy}
className="border border-hairline bg-transparent px-3 py-1 font-mono text-xs uppercase text-muted hover:border-accent hover:text-accent disabled:opacity-50"
>
{dict.admin.logout}
</button>
);
}
+157
View File
@@ -0,0 +1,157 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import type { Locale } from "@/i18n/routes";
import { getDict } from "@/i18n/dictionaries";
import type { Profile, ProfileLink } from "@/lib/models";
type Initial = Profile & { factsDe: string[]; factsEn: string[]; bioDe: string; bioEn: string };
function linksToText(links: ProfileLink[]): string {
return links.map((l) => `${l.label}\t${l.href}\t${l.kind}`).join("\n");
}
function textToLinks(text: string): ProfileLink[] {
return text
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [label, href, kind] = line.split(/\t|\s{2,}/).map((s) => s.trim());
return { label: label ?? "", href: href ?? "", kind: kind ?? "external" } satisfies ProfileLink;
})
.filter((l) => l.label && l.href);
}
export function ProfileForm({
initial,
locale,
}: {
initial: Initial;
locale: Locale;
}) {
const dict = getDict(locale);
const router = useRouter();
const [handle, setHandle] = useState(initial.handle);
const [realName, setRealName] = useState(initial.realName);
const [role, setRole] = useState(initial.role);
const [location, setLocation] = useState(initial.location);
const [bioDe, setBioDe] = useState(initial.bioDe);
const [bioEn, setBioEn] = useState(initial.bioEn);
const [factsDe, setFactsDe] = useState(initial.factsDe.join("\n"));
const [factsEn, setFactsEn] = useState(initial.factsEn.join("\n"));
const [links, setLinks] = useState(linksToText(initial.links));
const [saving, setSaving] = useState(false);
const [status, setStatus] = useState<string | null>(null);
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setSaving(true);
setStatus(null);
try {
const res = await fetch("/api/admin/profile", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
handle,
realName,
role,
location,
bioDe,
bioEn,
factsDe: factsDe.split("\n").map((s) => s.trim()).filter(Boolean),
factsEn: factsEn.split("\n").map((s) => s.trim()).filter(Boolean),
links: textToLinks(links),
}),
});
if (!res.ok) {
setStatus("Error");
return;
}
setStatus(dict.admin.saved);
router.refresh();
} finally {
setSaving(false);
}
}
const inputClass =
"w-full border border-hairline bg-[var(--bg-deep)] px-3 py-2 font-mono text-sm text-[var(--fg)] focus:border-accent focus:outline-none";
return (
<form onSubmit={onSubmit} className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<label className="block">
<span className="mb-1 block font-mono text-xs uppercase text-muted">handle</span>
<input className={inputClass} value={handle} onChange={(e) => setHandle(e.target.value)} required />
</label>
<label className="block">
<span className="mb-1 block font-mono text-xs uppercase text-muted">real name</span>
<input className={inputClass} value={realName} onChange={(e) => setRealName(e.target.value)} required />
</label>
<label className="block">
<span className="mb-1 block font-mono text-xs uppercase text-muted">role</span>
<input className={inputClass} value={role} onChange={(e) => setRole(e.target.value)} required />
</label>
<label className="block">
<span className="mb-1 block font-mono text-xs uppercase text-muted">location</span>
<input className={inputClass} value={location} onChange={(e) => setLocation(e.target.value)} required />
</label>
</div>
<label className="block">
<span className="mb-1 block font-mono text-xs uppercase text-muted">bio (DE)</span>
<textarea
className={`${inputClass} min-h-[160px]`}
value={bioDe}
onChange={(e) => setBioDe(e.target.value)}
required
/>
</label>
<label className="block">
<span className="mb-1 block font-mono text-xs uppercase text-muted">bio (EN)</span>
<textarea
className={`${inputClass} min-h-[160px]`}
value={bioEn}
onChange={(e) => setBioEn(e.target.value)}
required
/>
</label>
<div className="grid gap-4 md:grid-cols-2">
<label className="block">
<span className="mb-1 block font-mono text-xs uppercase text-muted">facts (DE, eine pro Zeile)</span>
<textarea
className={`${inputClass} min-h-[120px]`}
value={factsDe}
onChange={(e) => setFactsDe(e.target.value)}
/>
</label>
<label className="block">
<span className="mb-1 block font-mono text-xs uppercase text-muted">facts (EN, eine pro Zeile)</span>
<textarea
className={`${inputClass} min-h-[120px]`}
value={factsEn}
onChange={(e) => setFactsEn(e.target.value)}
/>
</label>
</div>
<label className="block">
<span className="mb-1 block font-mono text-xs uppercase text-muted">links (label · TAB · href · TAB · kind pro Zeile)</span>
<textarea
className={`${inputClass} min-h-[120px]`}
value={links}
onChange={(e) => setLinks(e.target.value)}
/>
</label>
<div className="flex items-center gap-4">
<button
type="submit"
disabled={saving}
className="border border-accent bg-accent px-4 py-2 font-mono text-sm uppercase text-[var(--bg)] disabled:opacity-50"
>
{saving ? dict.admin.saving : dict.admin.save}
</button>
{status ? <span className="font-mono text-xs text-[var(--accent-2)]">{status}</span> : null}
</div>
</form>
);
}
+247
View File
@@ -0,0 +1,247 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import type { Locale } from "@/i18n/routes";
import { getDict } from "@/i18n/dictionaries";
import type { Project } from "@/lib/models";
type Initial = {
slug: string;
number: string;
status: string;
category: string;
stack: string;
href: string;
featured: boolean;
summaryDe: string;
summaryEn: string;
stanceDe: string;
stanceEn: string;
bodyDe: string;
bodyEn: string;
sortIndex: number;
};
export function ProjectForm({
initial,
locale,
isNew,
}: {
initial: Initial;
locale: Locale;
isNew: boolean;
}) {
const dict = getDict(locale);
const router = useRouter();
const [state, setState] = useState<Initial>(initial);
const [saving, setSaving] = useState(false);
const [status, setStatus] = useState<string | null>(null);
function update<K extends keyof Initial>(key: K, value: Initial[K]) {
setState((prev) => ({ ...prev, [key]: value }));
}
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setSaving(true);
setStatus(null);
try {
const url = isNew ? "/api/admin/projects" : `/api/admin/projects/${initial.slug}`;
const method = isNew ? "POST" : "PUT";
const body = {
slug: state.slug,
number: state.number,
status: state.status,
category: state.category,
stack: state.stack,
href: state.href || null,
featured: state.featured,
summaryDe: state.summaryDe,
summaryEn: state.summaryEn,
stanceDe: state.stanceDe,
stanceEn: state.stanceEn,
bodyDe: state.bodyDe,
bodyEn: state.bodyEn,
sortIndex: state.sortIndex,
};
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
setStatus(text || "Error");
return;
}
setStatus(dict.admin.saved);
if (isNew) {
router.push(`/admin/projects/${state.slug}`);
router.refresh();
} else {
router.refresh();
}
} finally {
setSaving(false);
}
}
async function onDelete() {
if (isNew) return;
if (!confirm(dict.admin.confirmDelete)) return;
setSaving(true);
try {
const res = await fetch(`/api/admin/projects/${initial.slug}`, { method: "DELETE" });
if (!res.ok) {
const text = await res.text();
setStatus(text || "Error");
return;
}
router.push("/admin/projects");
router.refresh();
} finally {
setSaving(false);
}
}
const inputClass =
"w-full border border-hairline bg-[var(--bg-deep)] px-3 py-2 font-mono text-sm text-[var(--fg)] focus:border-accent focus:outline-none";
return (
<form onSubmit={onSubmit} className="space-y-4">
<div className="grid gap-4 md:grid-cols-3">
<label className="block">
<span className="mb-1 block font-mono text-xs uppercase text-muted">slug</span>
<input
className={inputClass}
value={state.slug}
onChange={(e) => update("slug", e.target.value.toLowerCase().replace(/[^a-z0-9-]+/g, "-"))}
required
/>
<span className="mt-1 block font-mono text-[10px] text-muted">{dict.admin.slugHelp}</span>
</label>
<label className="block">
<span className="mb-1 block font-mono text-xs uppercase text-muted">number</span>
<input className={inputClass} value={state.number} onChange={(e) => update("number", e.target.value)} />
</label>
<label className="block">
<span className="mb-1 block font-mono text-xs uppercase text-muted">sort_index</span>
<input
type="number"
className={inputClass}
value={state.sortIndex}
onChange={(e) => update("sortIndex", Number(e.target.value))}
/>
</label>
</div>
<div className="grid gap-4 md:grid-cols-3">
<label className="block">
<span className="mb-1 block font-mono text-xs uppercase text-muted">status</span>
<input className={inputClass} value={state.status} onChange={(e) => update("status", e.target.value)} />
</label>
<label className="block">
<span className="mb-1 block font-mono text-xs uppercase text-muted">category</span>
<input className={inputClass} value={state.category} onChange={(e) => update("category", e.target.value)} />
</label>
<label className="block">
<span className="mb-1 block font-mono text-xs uppercase text-muted">stack</span>
<input className={inputClass} value={state.stack} onChange={(e) => update("stack", e.target.value)} />
</label>
</div>
<div className="grid gap-4 md:grid-cols-3">
<label className="block md:col-span-2">
<span className="mb-1 block font-mono text-xs uppercase text-muted">href</span>
<input
className={inputClass}
value={state.href}
onChange={(e) => update("href", e.target.value)}
placeholder="https://..."
/>
</label>
<label className="flex items-center gap-2 self-end pb-2">
<input
type="checkbox"
checked={state.featured}
onChange={(e) => update("featured", e.target.checked)}
className="h-4 w-4 accent-[var(--accent)]"
/>
<span className="font-mono text-xs uppercase text-muted">featured</span>
</label>
</div>
<div className="grid gap-4 md:grid-cols-2">
<label className="block">
<span className="mb-1 block font-mono text-xs uppercase text-muted">summary (DE)</span>
<textarea
className={`${inputClass} min-h-[80px]`}
value={state.summaryDe}
onChange={(e) => update("summaryDe", e.target.value)}
/>
</label>
<label className="block">
<span className="mb-1 block font-mono text-xs uppercase text-muted">summary (EN)</span>
<textarea
className={`${inputClass} min-h-[80px]`}
value={state.summaryEn}
onChange={(e) => update("summaryEn", e.target.value)}
/>
</label>
</div>
<div className="grid gap-4 md:grid-cols-2">
<label className="block">
<span className="mb-1 block font-mono text-xs uppercase text-muted">stance (DE)</span>
<textarea
className={`${inputClass} min-h-[80px]`}
value={state.stanceDe}
onChange={(e) => update("stanceDe", e.target.value)}
/>
</label>
<label className="block">
<span className="mb-1 block font-mono text-xs uppercase text-muted">stance (EN)</span>
<textarea
className={`${inputClass} min-h-[80px]`}
value={state.stanceEn}
onChange={(e) => update("stanceEn", e.target.value)}
/>
</label>
</div>
<div className="grid gap-4 md:grid-cols-2">
<label className="block">
<span className="mb-1 block font-mono text-xs uppercase text-muted">body (DE)</span>
<textarea
className={`${inputClass} min-h-[200px]`}
value={state.bodyDe}
onChange={(e) => update("bodyDe", e.target.value)}
/>
</label>
<label className="block">
<span className="mb-1 block font-mono text-xs uppercase text-muted">body (EN)</span>
<textarea
className={`${inputClass} min-h-[200px]`}
value={state.bodyEn}
onChange={(e) => update("bodyEn", e.target.value)}
/>
</label>
</div>
<div className="flex items-center gap-4">
<button
type="submit"
disabled={saving}
className="border border-accent bg-accent px-4 py-2 font-mono text-sm uppercase text-[var(--bg)] disabled:opacity-50"
>
{saving ? dict.admin.saving : dict.admin.save}
</button>
{!isNew ? (
<button
type="button"
onClick={onDelete}
disabled={saving}
className="border border-[var(--danger)] px-4 py-2 font-mono text-sm uppercase text-[var(--danger)] disabled:opacity-50"
>
Löschen
</button>
) : null}
{status ? <span className="font-mono text-xs text-[var(--accent-2)]">{status}</span> : null}
</div>
</form>
);
}
+39
View File
@@ -0,0 +1,39 @@
import type { Locale } from "@/i18n/routes";
import type { Profile } from "@/lib/models";
import { getDict } from "@/i18n/dictionaries";
export function BioSection({ profile, locale }: { profile: Profile; locale: Locale }) {
const dict = getDict(locale);
// Bio ist als \n\n-getrennter Text in der DB gespeichert.
const paragraphs = profile.bio.split(/\n\n+/).filter((p) => p.trim().length > 0);
return (
<section aria-labelledby="bio-heading" className="ascii-box">
<div className="section-heading">
<span className="section-heading__label">~/bio</span>
<h2 id="bio-heading" className="section-heading__title">{profile.handle}</h2>
</div>
<p className="mb-4 font-mono text-xs uppercase tracking-wider text-muted">
{profile.role} · {profile.location}
</p>
<div className="space-y-4 text-[var(--fg)]">
{paragraphs.map((p, i) => (
<p key={i}>{p}</p>
))}
</div>
{profile.facts.length > 0 ? (
<>
<h3 className="mt-6 font-mono text-xs uppercase tracking-wider text-muted">
{dict.about.factsHeading}
</h3>
<ul className="mt-2 space-y-1 font-mono text-sm text-[var(--fg)]">
{profile.facts.map((f, i) => (
<li key={i}>
<span className="text-accent"></span> {f}
</li>
))}
</ul>
</>
) : null}
</section>
);
}
+45
View File
@@ -0,0 +1,45 @@
import type { Profile, Project } from "@/lib/models";
import { site } from "@/content/site";
export function JsonLd({
profile,
featured,
}: {
profile: Profile;
featured: Project[];
}) {
const graph = {
"@context": "https://schema.org",
"@graph": [
{
"@type": "Person",
name: profile.realName,
alternateName: profile.handle,
url: site.url,
description: profile.role,
knowsAbout: featured.map((f) => f.category).filter((v, i, a) => a.indexOf(v) === i),
},
{
"@type": "WebSite",
url: site.url,
name: "eldov.win",
inLanguage: ["de", "en"],
author: { "@type": "Person", name: profile.realName },
},
...featured.map((f) => ({
"@type": "CreativeWork",
name: f.slug,
url: `${site.url}/projekte/${f.slug}`,
about: f.category,
keywords: f.stack,
abstract: f.summary,
})),
],
};
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(graph) }}
/>
);
}
+38
View File
@@ -0,0 +1,38 @@
"use client";
import Link from "next/link";
import type { Locale } from "@/i18n/routes";
import { switchLocale } from "@/i18n/routes";
export function LanguageSwitcher({
currentLocale,
className = "",
}: {
currentLocale: Locale;
className?: string;
}) {
// Die aktuelle Path wird über die Server-Komponente nicht direkt übergeben;
// stattdessen rendern wir einen Link, der die aktuelle URL auf die andere
// Locale umschreibt. Da der Switcher in jeder Page gesetzt wird, geben wir
// ein kleines Client-Skript mit, das die URL window.location.pathname
// zur Laufzeit austauscht.
const target: Locale = currentLocale === "de" ? "en" : "de";
return (
<Link
href="#"
data-locale-target={target}
data-locale-from={currentLocale}
aria-label={`Switch language to ${target}`}
className={`font-mono text-xs uppercase text-muted hover:text-accent ${className}`}
onClick={(e) => {
// Fallback, falls JS aus ist: statischer Link auf die andere Locale der Homepage.
e.preventDefault();
if (typeof window === "undefined") return;
const path = window.location.pathname;
const next = switchLocale(path, target) ?? (target === "en" ? "/en" : "/");
window.location.href = next;
}}
>
[{target}]
</Link>
);
}
+42
View File
@@ -0,0 +1,42 @@
import type { Locale } from "@/i18n/routes";
import type { Profile } from "@/lib/models";
import { getDict } from "@/i18n/dictionaries";
const KIND_LABEL: Record<string, { de: string; en: string }> = {
git: { de: "git", en: "git" },
service: { de: "service", en: "service" },
external: { de: "extern", en: "external" },
social: { de: "social", en: "social" },
};
export function LinksSection({ profile, locale }: { profile: Profile; locale: Locale }) {
const dict = getDict(locale);
if (profile.links.length === 0) return null;
return (
<section aria-labelledby="links-heading" className="ascii-box">
<div className="section-heading">
<span className="section-heading__label">~/links</span>
<h2 id="links-heading" className="section-heading__title">{dict.home.linksLabel}</h2>
</div>
<ul className="divide-y divide-[var(--hairline)]">
{profile.links.map((l, i) => {
const kindLabel = KIND_LABEL[l.kind]?.[locale] ?? l.kind;
const external = !l.href.startsWith("/") && !l.href.startsWith("#");
return (
<li key={i} className="flex items-center gap-3 py-2 font-mono text-sm">
<span className="w-20 text-xs uppercase text-muted">[{kindLabel}]</span>
<a
href={l.href}
className="flex-1 truncate text-[var(--fg)] hover:text-accent"
{...(external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
>
{l.label}
</a>
<span className="text-muted">{external ? "↗" : "→"}</span>
</li>
);
})}
</ul>
</section>
);
}
+66
View File
@@ -0,0 +1,66 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import type { Locale } from "@/i18n/routes";
import { switchLocale } from "@/i18n/routes";
type Item = { href: string; label: string };
export function MobileNav({
locale,
items,
menuLabel,
switchLabel,
}: {
locale: Locale;
items: Item[];
menuLabel: string;
switchLabel: string;
}) {
const [open, setOpen] = useState(false);
const target: Locale = locale === "de" ? "en" : "de";
return (
<div className="md:hidden">
<button
type="button"
aria-expanded={open}
aria-controls="mobile-nav"
onClick={() => setOpen((v) => !v)}
className="font-mono text-sm text-muted hover:text-accent"
>
[{open ? "x" : menuLabel}]
</button>
{open ? (
<nav id="mobile-nav" className="absolute left-0 right-0 z-10 mt-2 border-b border-hairline bg-[var(--bg-deep)]">
<ul className="mx-auto flex max-w-5xl flex-col gap-3 px-4 py-4">
{items.map((i) => (
<li key={i.href}>
<Link
href={i.href}
className="block font-mono text-sm text-[var(--fg)] hover:text-accent"
onClick={() => setOpen(false)}
>
{i.label}
</Link>
</li>
))}
<li>
<Link
href="#"
className="block font-mono text-xs uppercase text-muted hover:text-accent"
onClick={(e) => {
e.preventDefault();
if (typeof window === "undefined") return;
const next = switchLocale(window.location.pathname, target) ?? (target === "en" ? "/en" : "/");
window.location.href = next;
}}
>
{switchLabel}
</Link>
</li>
</ul>
</nav>
) : null}
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
import Link from "next/link";
import type { Locale } from "@/i18n/routes";
import { publicPath } from "@/i18n/routes";
import type { Project } from "@/lib/models";
import { getDict } from "@/i18n/dictionaries";
function statusTone(status: string): "live" | "warm" | "muted" {
const s = status.toLowerCase();
if (s.startsWith("aktiv") || s.startsWith("live") || s === "running") return "live";
if (s.includes("early") || s.includes("draft") || s.includes("hardening")) return "warm";
return "muted";
}
export function ProjectCard({
project,
locale,
showStance = false,
}: {
project: Project;
locale: Locale;
showStance?: boolean;
}) {
const dict = getDict(locale);
const href = publicPath(locale, "projectItem", project.slug);
const tone = statusTone(project.status);
return (
<article className="ascii-box group flex flex-col gap-3 transition-colors hover:border-accent">
<header className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-muted">{project.number}</span>
<span className={`badge badge--${tone}`}>{project.status}</span>
</div>
<span className="font-mono text-xs text-muted">{project.category}</span>
</header>
<h3 className="text-lg font-semibold">
<Link href={href} className="hover:text-accent">
{project.slug}
</Link>
</h3>
<p className="text-sm text-[var(--fg)]">{project.summary}</p>
{showStance && project.stance ? (
<p className="border-l-2 border-accent pl-3 text-xs italic text-muted">{project.stance}</p>
) : null}
<footer className="mt-auto flex flex-wrap items-center gap-2 pt-2 font-mono text-xs text-muted">
<span>{project.stack.split("·")[0]?.trim() ?? project.stack}</span>
<span className="ml-auto">
<Link href={href} className="text-accent hover:underline">
{dict.actions.openProject}
</Link>
</span>
</footer>
</article>
);
}
+30
View File
@@ -0,0 +1,30 @@
import type { Locale } from "@/i18n/routes";
import type { Project } from "@/lib/models";
import { ProjectCard } from "./project-card";
export function ProjectGrid({
projects,
locale,
showStance = false,
emptyLabel,
}: {
projects: Project[];
locale: Locale;
showStance?: boolean;
emptyLabel?: string;
}) {
if (projects.length === 0) {
return (
<p className="rounded border border-hairline bg-panel p-6 text-center font-mono text-sm text-muted">
{emptyLabel ?? "—"}
</p>
);
}
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{projects.map((p) => (
<ProjectCard key={p.slug} project={p} locale={locale} showStance={showStance} />
))}
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
import Link from "next/link";
import type { Locale } from "@/i18n/routes";
import { site } from "@/content/site";
import { getLegal } from "@/content/legal";
import { publicPath } from "@/i18n/routes";
export function SiteFooter({ locale }: { locale: Locale }) {
const legal = getLegal(locale);
const year = new Date().getFullYear();
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}
</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>
</div>
</footer>
);
}
+48
View File
@@ -0,0 +1,48 @@
import Link from "next/link";
import type { Locale } from "@/i18n/routes";
import { publicPath, defaultLocale } from "@/i18n/routes";
import { getDict } from "@/i18n/dictionaries";
import { LanguageSwitcher } from "./language-switcher";
import { MobileNav } from "./mobile-nav";
import { site } from "@/content/site";
export function SiteHeader({ locale }: { locale: Locale }) {
const dict = getDict(locale);
const nav = [
{ href: publicPath(locale, "about"), label: dict.nav.about },
{ href: publicPath(locale, "projects"), label: dict.nav.projects },
{ href: publicPath(locale, "contact"), label: dict.nav.contact },
];
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"
>
{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.
</div>
</div>
) : null}
</header>
);
}