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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user