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
+13
View File
@@ -0,0 +1,13 @@
# Beispiel-Konfiguration für lokale Entwicklung. Kopiere nach .env.local und passe an.
# Pflicht: Admin-Login (mindestens 16 Zeichen, kein Dictionary-Wort)
ADMIN_PASSWORD=changeme-to-a-long-random-string
# Pflicht: HMAC-Secret für Session-Cookies (mindestens 32 Byte Hex)
SESSION_SECRET=0000000000000000000000000000000000000000000000000000000000000000
# Optional: Pfad zur SQLite-DB. Default: ./data/eldov.db
# DB_PATH=./data/eldov.db
# Optional: Öffentliche URL, wird für OG/Canonical genutzt
# NEXT_PUBLIC_SITE_URL=https://eldov.win
+16
View File
@@ -0,0 +1,16 @@
node_modules
.next
out
dist
.env
.env.local
.env.*.local
data/*.db
data/*.db-*
.DS_Store
*.log
.vscode
.idea
tsconfig.tsbuildinfo
.graphify*
.graphify-out
+42
View File
@@ -0,0 +1,42 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
# eldov.win — Agenten-Notizen
Privates Portfolio von „eldov" (Jan Wagner), Domain `eldov.win`. Gegenstück zur geschäftlichen Präsenz `w-make.com`: hier stehen Selbsthosting, Bots, AI-Fleet und Hacks — dort Industrie-Software.
## Welche Inhalte hier hingehören
- Hermes / Multi-Agent-Fleet, Casino-Bot, Polymarket-Trader, Crypto-/Trade-Bot, TS6-Bot, QuantMuse
- Freewarez-Landingpage, Open-Source-Themes (Jellyfin, Gitea, Discourse, Kavita, Authelia, Uptime-Kuma, Erugo, LAMP, Stirling-PDF, Element)
- Minecraft-Portal, ai-subscription-manager, Android-Projekte, LAMP-Link-Themes
- Persönliche Bio, Hobbies, eigene Meinung, Blog/Notes
## Welche Inhalte NICHT hierhingehören
- **Batchmaker / W-Make Batch / W-Make Studio** — Industrie, gehört zu `w-make.com`. Selbst Erwähnungen, Screenshots oder Kundenkontext sind hier tabu.
- Live-Performance-Claims für Trading/Casino („X % ROI pro Monat"). Wir zeigen das System, nicht die Quoten.
- Konkrete VPS-Internas, IPs, Secrets, API-Keys — weder in Markdown noch in Bildern.
## Architektur-Kurzfassung
- **Stack:** Next.js 16 (App Router) · React 19 · Tailwind 4 · TypeScript · better-sqlite3
- **Rendering:** SSR. Public Routes lesen Content aus SQLite; Admin schreibt.
- **i18n:** DE als Default (ohne Prefix), EN unter `/en`. Routing über `proxy.ts` analog `w-make-com`.
- **DB:** SQLite unter `DB_PATH` (Default `data/eldov.db`). Schema in `src/lib/schema.sql`, Seed in `src/lib/seed.ts`. Wird beim ersten Request automatisch initialisiert.
- **Auth:** Single-User-Admin via `ADMIN_PASSWORD` + `SESSION_SECRET` (HMAC-signiertes HttpOnly-Cookie). `proxy.ts` schützt `/admin/*` und `/api/admin/*` (außer `/admin/login`, `/api/admin/login`).
- **Deploy:** `deploy-vps.sh` synct in Container `/opt/containers/eldov-portfolio` auf `free-warez.top`, 9router-Eintrag `eldov.win` → Container.
## Konventionen
- Komponenten klein, eine Datei pro Sache, keine „Utils-Bibliothek".
- Texte liegen in `src/content/*.ts` (statisch) oder in der DB (editierbar). Niemals Strings hartcodiert in Komponenten.
- UI-Strings Deutsch/Englisch in `src/i18n/dictionaries.ts`.
- Keine TODO-/FIXME-Kommentare ins Repo, stattdessen Issue oder Note.
+35
View File
@@ -0,0 +1,35 @@
# Multi-Stage Build: deps → build → runner.
# Analog w-make-com, aber standalone mit bereits gebauter Next.js App.
FROM node:22.13-bookworm-slim AS deps
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends python3 build-essential ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json* ./
RUN npm ci
FROM deps AS build
WORKDIR /app
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM node:22.13-bookworm-slim AS runner
WORKDIR /app
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=3000
# Non-Root
RUN groupadd --system --gid 1001 nodejs \
&& useradd --system --uid 1001 --gid nodejs eldov
# Standalone-Output kopieren (Next.js legt ihn unter .next/standalone an,
# wenn output: 'standalone' gesetzt ist; hier vorerst klassischer next start).
COPY --from=build /app/.next ./.next
COPY --from=build /app/public ./public
COPY --from=build /app/src ./src
COPY --from=build /app/package.json ./package.json
COPY --from=build /app/next.config.ts ./next.config.ts
COPY --from=build /app/proxy.ts ./proxy.ts
COPY --from=build /app/node_modules ./node_modules
RUN mkdir -p /app/data && chown -R eldov:nodejs /app
USER eldov
EXPOSE 3000
CMD ["npm", "run", "start"]
+85
View File
@@ -0,0 +1,85 @@
# eldov.win
Privates Portfolio für „eldov" — Self-Hosting, Bots, AI-Fleet, Hacks. Domain `eldov.win`.
Das ist das Gegenstück zu [`w-make.com`](../w-make-com/README.md). Dort steht die industrielle Arbeit (Behälterglas, W-Make Batch, Batchmaker Studio). Hier stehen die Dinge, die mich abseits von Auftragsarbeit umtreiben: Hermes, Casino-/Trading-Bots, Freewarez-Selfhosting, Themes für Open-Source-Software.
## Stack
Next.js 16 (App Router) · React 19 · Tailwind 4 · TypeScript · SQLite (`better-sqlite3`). SSR mit dynamischem Content aus DB.
## Lokal
```bash
npm install
npm run dev # http://localhost:3000 (lädt .env.local automatisch)
npm run build
npm test # i18n + auth
npm run test:db # DB-Smoke (Schema, Seed, CRUD)
```
`.env.local` (nicht committen, von .env.local.example kopieren):
```
ADMIN_PASSWORD=<starkes-passwort>
SESSION_SECRET=<mindestens-32-byte-hex>
DB_PATH=./data/eldov.db
NEXT_PUBLIC_SITE_URL=http://localhost:3000
```
> Wichtig für Production: `next start` lädt `.env.local` **nicht** automatisch.
> env vars müssen vor dem Start gesetzt sein — typischerweise via Docker
> `env_file` (siehe `docker-compose.yml`) oder systemd `Environment=`.
> Für lokale Production-Smoke-Tests:
>
> ```bash
> ADMIN_PASSWORD=... SESSION_SECRET=... npm run start
> ```
## Routen
| Pfad | Zweck |
|---|---|
| `/` (DE) · `/en` | Startseite |
| `/ueber-mich` · `/en/about` | Bio, Skills, Setup |
| `/projekte` · `/en/projects` | Projektübersicht |
| `/projekte/[slug]` · `/en/projects/[slug]` | Projekt-Detail |
| `/kontakt` · `/en/contact` | Linksammlung statt Kontaktformular |
| `/impressum` · `/en/legal` | Impressum |
| `/datenschutz` · `/en/privacy` | Datenschutz |
| `/admin/login` | Login |
| `/admin/dashboard` | Übersicht |
| `/admin/profile` | Bio/Person editieren |
| `/admin/projects` · `/admin/projects/new` · `/admin/projects/[slug]` | Projekte-CRUD |
API (alle außer `/api/admin/login` erfordern Auth-Cookie):
| Methode · Pfad | Effekt |
|---|---|
| `POST /api/admin/login` | Body `{password}` → setzt Session-Cookie |
| `POST /api/admin/logout` | löscht Cookie |
| `GET /api/admin/session` | `{authenticated: boolean}` |
| `GET /api/admin/profile` | aktuelles Profil |
| `PUT /api/admin/profile` | Profil aktualisieren |
| `GET /api/admin/projects` | Liste |
| `POST /api/admin/projects` | neues Projekt |
| `GET /api/admin/projects/[slug]` | Detail |
| `PUT /api/admin/projects/[slug]` | aktualisieren |
| `DELETE /api/admin/projects/[slug]` | löschen |
## Datenmodell
SQLite, Schema in `src/lib/schema.sql`. Zwei Haupttabellen:
- `profile` (singletone, id=1): `handle`, `real_name`, `role`, `location`, `bio_de`, `bio_en`, `links_json`, `updated_at`
- `projects` (`slug` PK): `slug`, `number`, `status`, `category`, `stack`, `href`, `featured` (0/1), `summary_de`, `summary_en`, `body_de`, `body_en`, `created_at`, `updated_at`
Beim ersten Request ohne DB wird automatisch migriert und mit dem Seed aus `src/lib/seed.ts` gefüllt (handle, bio, initiale Projekte aus dem Workspace).
## Deployment
`./deploy-vps.sh` synct nach `free-warez.top:/opt/containers/eldov-portfolio`. Build und Restart des Containers passieren dort. 9router-Eintrag `eldov.win` → Container-Internes `3000`.
## DSGVO-Hinweis
Das Admin-Backend speichert keine personenbezogenen Daten außer dem Profil des Betreibers. Login-Cookie ist HMAC-signiert, HttpOnly, SameSite=Lax, 7 Tage gültig. Es findet kein Tracking, keine externen Skripte, keine Schriftarten von Drittanbietern statt.
Executable
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# deploy-vps.sh — Synchronisiert den eldov-win Workspace auf den VPS-Container.
# Host: free-warez.top · Container-Pfad: /opt/containers/eldov-portfolio
#
# Voraussetzungen:
# - ssh-Zugang zu free-warez.top ist konfiguriert
# - .env.local ist lokal vorhanden (wird NICHT übertragen)
# - auf dem VPS liegt bereits ein Container namens 'eldov-portfolio'
set -euo pipefail
VPS_HOST="${VPS_HOST:-free-warez.top}"
VPS_DIR="${VPS_DIR:-/opt/containers/eldov-portfolio}"
echo "→ Prüfe SSH-Verbindung zu $VPS_HOST..."
ssh -o BatchMode=yes -o ConnectTimeout=10 "$VPS_HOST" "echo OK" >/dev/null
echo "→ Synchronisiere Quelldateien nach $VPS_HOST:$VPS_DIR"
rsync -avz --delete \
--exclude ".git" \
--exclude "node_modules" \
--exclude ".next" \
--exclude "data" \
--exclude ".env.local" \
--exclude "*.log" \
./ "$VPS_HOST:$VPS_DIR/"
echo "→ Baue und starte Container auf $VPS_HOST neu"
ssh "$VPS_HOST" "cd '$VPS_DIR' && \
[ -f .env.local ] || { echo 'FEHLT: .env.local auf dem VPS — bitte anlegen'; exit 1; } && \
docker compose build app && \
docker compose up -d app && \
docker image prune -f"
echo "✓ Deploy abgeschlossen. Healthcheck in 10 s…"
sleep 10
ssh "$VPS_HOST" "docker inspect --format='{{.State.Health.Status}}' eldov-portfolio || echo 'no healthcheck yet'"
+30
View File
@@ -0,0 +1,30 @@
# Compose-Service für eldov.win.
# Host-übergreifend konsistent mit w-make-com: data-Volume für die SQLite-DB,
# .env für Secrets, kein externer Port.
services:
app:
build:
context: .
dockerfile: Dockerfile
image: eldov-portfolio:latest
container_name: eldov-portfolio
restart: unless-stopped
env_file:
- .env.local
environment:
- NODE_ENV=production
- NEXT_TELEMETRY_DISABLED=1
- DB_PATH=/app/data/eldov.db
volumes:
- eldov_data:/app/data
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 30s
timeout: 5s
retries: 5
start_period: 30s
volumes:
eldov_data:
name: eldov_data
+12
View File
@@ -0,0 +1,12 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({ baseDirectory: __dirname });
const eslintConfig = [...compat.extends("next/core-web-vitals", "next/typescript")];
export default eslintConfig;
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+15
View File
@@ -0,0 +1,15 @@
import type { NextConfig } from "next";
const config: NextConfig = {
// better-sqlite3 ist ein nativer Server-only Import. Wir halten ihn aus dem
// Client-Bundle, indem wir ihn ausschließlich in `src/lib/db/**` und Server-
// Komponenten verwenden — keine externen Imports in `src/app/**/page.tsx`
// ohne expliziten server boundary.
serverExternalPackages: ["better-sqlite3"],
// Statische Assets lokal ausliefern, kein CDN.
images: {
unoptimized: true,
},
};
export default config;
+7340
View File
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
{
"name": "eldov-win",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"node": ">=22.13"
},
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"test": "node --experimental-strip-types --test src/i18n/routes.test.ts src/lib/auth/session.test.ts",
"test:db": "node --experimental-strip-types src/lib/db/smoke.ts",
"seed": "node --experimental-strip-types src/lib/db/seed-cli.ts"
},
"dependencies": {
"better-sqlite3": "^11.5.0",
"next": "16.3.1",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/better-sqlite3": "^7.6.11",
"@types/node": "^22",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.3.1",
"tailwindcss": "^4",
"typescript": "^5"
},
"allowScripts": {
"better-sqlite3@11.10.0": true,
"unrs-resolver@1.12.2": true
}
}
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+76
View File
@@ -0,0 +1,76 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { defaultLocale, isLocale, resolvePublicPath, toInternalPath, toPublicFromInternal, switchLocale } from "./src/i18n/routes";
import { readCookieFromHeader } from "./src/lib/auth/cookie";
import { verifySession } from "./src/lib/auth/session";
const PUBLIC_FILE = /\.(?:svg|png|jpg|jpeg|gif|webp|ico|txt|xml|json|css|js|map)$/;
function isAdminLoginPath(pathname: string): boolean {
return pathname === "/admin/login" || pathname.startsWith("/admin/login/");
}
function isAdminPath(pathname: string): boolean {
return pathname === "/admin" || pathname.startsWith("/admin/");
}
function isAdminApiPath(pathname: string): boolean {
return pathname === "/api/admin/login" || pathname.startsWith("/api/admin/");
}
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
const headers = new Headers(request.headers);
// Locale-Auflösung
const segment = pathname.split("/")[1] ?? "";
const locale = isLocale(segment) ? segment : defaultLocale;
headers.set("x-locale", locale);
// Admin-Auth
const cookieHeader = request.headers.get("cookie");
const sessionToken = readCookieFromHeader(cookieHeader);
const authenticated = verifySession(sessionToken);
if (isAdminApiPath(pathname) && !pathname.startsWith("/api/admin/login")) {
if (!authenticated) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
}
if (isAdminPath(pathname) && !isAdminLoginPath(pathname)) {
if (!authenticated) {
const url = request.nextUrl.clone();
url.pathname = "/admin/login";
url.searchParams.set("next", pathname);
return NextResponse.redirect(url);
}
}
// /de/... → public
if (pathname === "/de" || pathname.startsWith("/de/")) {
const publicPath = toPublicFromInternal(pathname);
if (publicPath && publicPath !== pathname) {
const url = request.nextUrl.clone();
url.pathname = publicPath;
return NextResponse.redirect(url);
}
}
// public → /<locale>/...
const internal = toInternalPath(pathname);
if (internal && internal !== pathname) {
const url = request.nextUrl.clone();
url.pathname = internal;
return NextResponse.rewrite(url, { request: { headers } });
}
return NextResponse.next({ request: { headers } });
}
export const config = {
matcher: [
// Alles außer Next.js-interne Pfade, statische Files, OG/Robots/Sitemap,
// sowie /admin/* und /api/admin/* (Auth passiert vor Locale-Rewrite).
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|txt|xml)$).*)",
],
};
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<rect width="64" height="64" rx="10" fill="#06060a"/>
<rect x="4" y="4" width="56" height="56" rx="8" stroke="#e84a8f" stroke-width="1.5" fill="none"/>
<text x="32" y="42" font-family="ui-monospace, monospace" font-size="34" font-weight="700" fill="#e84a8f" text-anchor="middle">e</text>
<rect x="42" y="20" width="6" height="6" fill="#39ff14"/>
</svg>

After

Width:  |  Height:  |  Size: 434 B

+40
View File
@@ -0,0 +1,40 @@
import { isLocale, type Locale } from "@/i18n/routes";
import { getDict } from "@/i18n/dictionaries";
import { getDb } from "@/lib/db";
import { seedIfEmpty } from "@/lib/seed";
import { getProfile } from "@/lib/models";
import { BioSection } from "@/components/bio-section";
import { LinksSection } from "@/components/links-section";
import { buildMetadata } from "@/lib/metadata";
export async function generateMetadata({
params,
}: {
params: Promise<{ lang: string }>;
}) {
const { lang } = await params;
const locale: Locale = isLocale(lang) ? lang : "de";
return buildMetadata(locale, getDict(locale).about.title, "Über eldov");
}
export default async function AboutPage({
params,
}: {
params: Promise<{ lang: string }>;
}) {
const { lang } = await params;
const locale: Locale = isLocale(lang) ? lang : "de";
const db = getDb();
seedIfEmpty(db);
const profile = getProfile(db, locale);
return (
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2">
<BioSection profile={profile} locale={locale} />
</div>
<aside>
<LinksSection profile={profile} locale={locale} />
</aside>
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { isLocale, type Locale } from "@/i18n/routes";
import { getDict } from "@/i18n/dictionaries";
import { LinksSection } from "@/components/links-section";
import { getDb } from "@/lib/db";
import { seedIfEmpty } from "@/lib/seed";
import { getProfile } from "@/lib/models";
import { buildMetadata } from "@/lib/metadata";
export async function generateMetadata({
params,
}: {
params: Promise<{ lang: string }>;
}) {
const { lang } = await params;
const locale: Locale = isLocale(lang) ? lang : "de";
return buildMetadata(locale, getDict(locale).contact.title, getDict(locale).contact.lede);
}
export default async function ContactPage({
params,
}: {
params: Promise<{ lang: string }>;
}) {
const { lang } = await params;
const locale: Locale = isLocale(lang) ? lang : "de";
const db = getDb();
seedIfEmpty(db);
const profile = getProfile(db, locale);
const dict = getDict(locale);
return (
<>
<div className="section-heading">
<span className="section-heading__label">~/contact</span>
<h1 className="section-heading__title">{dict.contact.title}</h1>
</div>
<p className="mb-6 max-w-2xl text-[var(--fg)]">{dict.contact.lede}</p>
<LinksSection profile={profile} locale={locale} />
</>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { isLocale, type Locale } from "@/i18n/routes";
import { SiteHeader } from "@/components/site-header";
import { SiteFooter } from "@/components/site-footer";
export default async function LangLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ lang: string }>;
}) {
const { lang } = await params;
const locale: Locale = isLocale(lang) ? lang : "de";
return (
<div className="flex min-h-screen flex-col">
<SiteHeader locale={locale} />
<main className="mx-auto w-full max-w-5xl flex-1 px-4 py-8">{children}</main>
<SiteFooter locale={locale} />
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { isLocale, type Locale } from "@/i18n/routes";
import { getDict } from "@/i18n/dictionaries";
import { getLegal } from "@/content/legal";
import { buildMetadata } from "@/lib/metadata";
export async function generateMetadata({
params,
}: {
params: Promise<{ lang: string }>;
}) {
const { lang } = await params;
const locale: Locale = isLocale(lang) ? lang : "de";
return buildMetadata(locale, getDict(locale).legal.title, "Impressum");
}
export default async function LegalPage({
params,
}: {
params: Promise<{ lang: string }>;
}) {
const { lang } = await params;
const locale: Locale = isLocale(lang) ? lang : "de";
const dict = getDict(locale);
const legal = getLegal(locale);
return (
<article className="ascii-box mx-auto max-w-3xl space-y-4">
<div className="section-heading">
<span className="section-heading__label">~/legal</span>
<h1 className="section-heading__title">{dict.legal.title}</h1>
</div>
<p>
<strong>{legal.name}</strong>
<br />
{legal.city}
</p>
<p>{legal.contactHint}</p>
</article>
);
}
+23
View File
@@ -0,0 +1,23 @@
import Link from "next/link";
import { isLocale, type Locale, publicPath } from "@/i18n/routes";
import { getDict } from "@/i18n/dictionaries";
export default async function NotFound() {
// Die [lang]-not-found Route bekommt in Next.js 16 mitunter kein `params`.
// Wir fallen deshalb auf DE zurück, wenn nichts gesetzt ist.
const locale: Locale = "de";
const dict = getDict(locale);
return (
<div className="ascii-box mx-auto max-w-xl text-center">
<p className="font-mono text-xs uppercase text-muted">404 · not found</p>
<h1 className="mt-2 text-2xl font-semibold">{dict.projects.title}</h1>
<p className="mt-4 text-[var(--fg)]">
Diese Seite existiert nicht.
<br />
<Link href={publicPath(locale, "home")} className="text-accent hover:underline">
{dict.actions.backToProjects}
</Link>
</p>
</div>
);
}
+89
View File
@@ -0,0 +1,89 @@
import { isLocale, type Locale } from "@/i18n/routes";
import { getDict } from "@/i18n/dictionaries";
import { getDb } from "@/lib/db";
import { seedIfEmpty } from "@/lib/seed";
import { getProfile, listFeaturedProjects, listProjects } from "@/lib/models";
import { BioSection } from "@/components/bio-section";
import { LinksSection } from "@/components/links-section";
import { ProjectGrid } from "@/components/project-grid";
import { JsonLd } from "@/components/json-ld";
import { buildMetadata } from "@/lib/metadata";
import Link from "next/link";
import { publicPath } from "@/i18n/routes";
export async function generateMetadata({
params,
}: {
params: Promise<{ lang: string }>;
}) {
const { lang } = await params;
const locale: Locale = isLocale(lang) ? lang : "de";
const dict = getDict(locale);
return buildMetadata(locale, dict.home.title, dict.home.lede);
}
export default async function HomePage({
params,
}: {
params: Promise<{ lang: string }>;
}) {
const { lang } = await params;
const locale: Locale = isLocale(lang) ? lang : "de";
const db = getDb();
seedIfEmpty(db);
const profile = getProfile(db, locale);
const featured = listFeaturedProjects(db, locale);
const all = listProjects(db, locale);
const dict = getDict(locale);
return (
<>
<JsonLd profile={profile} featured={featured} />
<section className="ascii-box mb-8">
<p className="font-mono text-xs uppercase tracking-wider text-muted">{dict.home.kicker}</p>
<h1
className="headline-glitch mt-2 text-4xl font-semibold leading-tight md:text-5xl"
data-text={dict.home.title}
>
{dict.home.title}
</h1>
<p className="mt-4 max-w-2xl text-[var(--fg)]">{dict.home.lede}</p>
</section>
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2">
{featured.length > 0 ? (
<>
<div className="section-heading">
<span className="section-heading__label">~/featured</span>
<h2 className="section-heading__title">{dict.home.featuredLabel}</h2>
</div>
<ProjectGrid projects={featured} locale={locale} showStance />
</>
) : null}
<div className="section-heading mt-12">
<span className="section-heading__label">~/all</span>
<h2 className="section-heading__title">{dict.home.allProjectsLabel}</h2>
<Link
href={publicPath(locale, "projects")}
className="ml-auto font-mono text-xs uppercase text-accent hover:underline"
>
{dict.actions.viewProjects}
</Link>
</div>
<ProjectGrid projects={all.slice(0, 6)} locale={locale} />
</div>
<aside className="space-y-6">
<BioSection profile={profile} locale={locale} />
<LinksSection profile={profile} locale={locale} />
</aside>
</div>
<section className="ascii-box mt-12">
<p className="font-mono text-xs uppercase tracking-wider text-accent">{dict.home.cta.title}</p>
<p className="mt-2 text-[var(--fg)]">{dict.home.cta.body}</p>
</section>
</>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { isLocale, type Locale } from "@/i18n/routes";
import { getDict } from "@/i18n/dictionaries";
import { buildMetadata } from "@/lib/metadata";
export async function generateMetadata({
params,
}: {
params: Promise<{ lang: string }>;
}) {
const { lang } = await params;
const locale: Locale = isLocale(lang) ? lang : "de";
return buildMetadata(locale, getDict(locale).privacy.title, "Datenschutz");
}
export default async function PrivacyPage({
params,
}: {
params: Promise<{ lang: string }>;
}) {
const { lang } = await params;
const locale: Locale = isLocale(lang) ? lang : "de";
const dict = getDict(locale);
return (
<article className="ascii-box mx-auto max-w-3xl space-y-4">
<div className="section-heading">
<span className="section-heading__label">~/privacy</span>
<h1 className="section-heading__title">{dict.privacy.title}</h1>
</div>
<p>{dict.privacy.body}</p>
</article>
);
}
+98
View File
@@ -0,0 +1,98 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { isLocale, type Locale, publicPath } from "@/i18n/routes";
import { getDict } from "@/i18n/dictionaries";
import { getDb } from "@/lib/db";
import { getProject } from "@/lib/models";
import { buildMetadata } from "@/lib/metadata";
export async function generateMetadata({
params,
}: {
params: Promise<{ lang: string; slug: string }>;
}) {
const { lang, slug } = await params;
const locale: Locale = isLocale(lang) ? lang : "de";
const project = getProject(getDb(), locale, slug);
if (!project) return buildMetadata(locale, slug, "Projekt auf eldov.win");
return buildMetadata(locale, `${project.slug} · ${project.category}`, project.summary);
}
export default async function ProjectDetailPage({
params,
}: {
params: Promise<{ lang: string; slug: string }>;
}) {
const { lang, slug } = await params;
const locale: Locale = isLocale(lang) ? lang : "de";
const project = getProject(getDb(), locale, slug);
if (!project) notFound();
const dict = getDict(locale);
const paragraphs = project.body.split(/\n\n+/).filter((p) => p.trim().length > 0);
return (
<article className="space-y-6">
<Link
href={publicPath(locale, "projects")}
className="font-mono text-xs uppercase text-muted hover:text-accent"
>
{dict.actions.backToProjects}
</Link>
<header className="ascii-box">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-xs text-muted">{project.number}</span>
<span className="badge badge--muted">{project.status}</span>
<span className="font-mono text-xs text-muted">{project.category}</span>
{project.featured ? <span className="badge badge--live"></span> : null}
</div>
<h1 className="mt-3 text-3xl font-semibold">{project.slug}</h1>
<p className="mt-2 max-w-2xl text-[var(--fg)]">{project.summary}</p>
</header>
<dl className="grid gap-4 sm:grid-cols-3">
<div className="ascii-box">
<dt className="font-mono text-xs uppercase text-muted">{dict.projectDetail.statusLabel}</dt>
<dd className="mt-1 font-mono text-sm">{project.status}</dd>
</div>
<div className="ascii-box">
<dt className="font-mono text-xs uppercase text-muted">{dict.projectDetail.categoryLabel}</dt>
<dd className="mt-1 font-mono text-sm">{project.category}</dd>
</div>
<div className="ascii-box">
<dt className="font-mono text-xs uppercase text-muted">{dict.projectDetail.stackLabel}</dt>
<dd className="mt-1 font-mono text-sm">{project.stack}</dd>
</div>
</dl>
{project.stance ? (
<blockquote className="border-l-2 border-accent bg-panel p-4 italic text-muted">
<span className="font-mono text-xs uppercase not-italic text-accent">
{dict.projectDetail.stanceLabel}:
</span>
<p className="mt-1 not-italic">{project.stance}</p>
</blockquote>
) : null}
<section className="ascii-box">
<div className="space-y-4 text-[var(--fg)]">
{paragraphs.map((p, i) => (
<p key={i}>{p}</p>
))}
</div>
</section>
{project.href ? (
<a
href={project.href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 border border-accent px-4 py-2 font-mono text-sm uppercase text-accent hover:bg-accent hover:text-[var(--bg)]"
>
{dict.projectDetail.liveLabel}
</a>
) : null}
</article>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { isLocale, type Locale } from "@/i18n/routes";
import { getDict } from "@/i18n/dictionaries";
import { getDb } from "@/lib/db";
import { seedIfEmpty } from "@/lib/seed";
import { listProjects } from "@/lib/models";
import { ProjectGrid } from "@/components/project-grid";
import { buildMetadata } from "@/lib/metadata";
export async function generateMetadata({
params,
}: {
params: Promise<{ lang: string }>;
}) {
const { lang } = await params;
const locale: Locale = isLocale(lang) ? lang : "de";
return buildMetadata(locale, getDict(locale).projects.title, getDict(locale).projects.lede);
}
export default async function ProjectsPage({
params,
}: {
params: Promise<{ lang: string }>;
}) {
const { lang } = await params;
const locale: Locale = isLocale(lang) ? lang : "de";
const db = getDb();
seedIfEmpty(db);
const projects = listProjects(db, locale);
const dict = getDict(locale);
return (
<>
<div className="section-heading">
<span className="section-heading__label">~/projects</span>
<h1 className="section-heading__title">{dict.projects.title}</h1>
</div>
<p className="mb-6 max-w-2xl text-[var(--fg)]">{dict.projects.lede}</p>
<ProjectGrid projects={projects} locale={locale} showStance emptyLabel={dict.projects.emptyState} />
</>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { isLocale, type Locale } from "@/i18n/routes";
import { getDict } from "@/i18n/dictionaries";
import { getDb } from "@/lib/db";
import { listProjects, getProfile } from "@/lib/models";
import { AdminShell } from "@/components/admin/admin-shell";
export default async function DashboardPage() {
const locale: Locale = "de"; // Admin ist sprach-neutral über die Texte in DE/EN, Render in DE.
const dict = getDict(locale);
const db = getDb();
const projects = listProjects(db, locale);
const profile = getProfile(db, locale);
const featured = projects.filter((p) => p.featured).length;
return (
<AdminShell locale={locale} title={dict.admin.dashboardTitle} active="dashboard">
<div className="grid gap-4 md:grid-cols-3">
<div className="ascii-box">
<p className="font-mono text-xs uppercase text-muted">{dict.admin.dashboardProfile}</p>
<p className="mt-2 font-mono text-sm">{profile.handle} · {profile.realName}</p>
<p className="mt-1 text-xs text-muted">{profile.role}</p>
</div>
<div className="ascii-box">
<p className="font-mono text-xs uppercase text-muted">{dict.admin.dashboardProjects}</p>
<p className="mt-2 font-mono text-2xl">{projects.length}</p>
<p className="mt-1 text-xs text-muted">{featured} featured</p>
</div>
<div className="ascii-box">
<p className="font-mono text-xs uppercase text-muted">{dict.home.lastUpdatedLabel}</p>
<p className="mt-2 font-mono text-sm">{profile.updatedAt}</p>
</div>
</div>
<p className="mt-6 text-sm text-muted">{dict.admin.dashboardSeed}</p>
</AdminShell>
);
}
+7
View File
@@ -0,0 +1,7 @@
import { isLocale, type Locale } from "@/i18n/routes";
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
// locale wird über Header geliefert; das Admin-Layout setzt keine Locale-Trennung um.
const lang = isLocale("de") ? "de" : ("de" as Locale);
return <div data-locale={lang}>{children}</div>;
}
+36
View File
@@ -0,0 +1,36 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { getDict, type dictionaries } from "@/i18n/dictionaries";
import { LoginForm } from "@/components/admin/login-form";
import { isLocale } from "@/i18n/routes";
type Dict = (typeof dictionaries)[keyof typeof dictionaries];
export default async function LoginPage({
searchParams,
}: {
searchParams: Promise<{ next?: string }>;
}) {
// Wenn bereits eingeloggt → direkt weiterleiten.
// (Die Auth-Prüfung passiert im Proxy; hier machen wir nur eine UI-Annahme.)
const sp = await searchParams;
if (sp.next && sp.next.startsWith("/admin") && !sp.next.startsWith("/admin/login")) {
// Wenn Proxy uns hierher geschickt hat, ist die Auth klar gescheitert.
}
const h = await headers();
const locale = isLocale(h.get("x-locale") ?? "") ? (h.get("x-locale") as "de" | "en") : "de";
const dict: Dict = getDict(locale);
return (
<div className="flex min-h-screen items-center justify-center bg-[var(--bg)] px-4">
<div className="w-full max-w-md space-y-6">
<div>
<p className="font-mono text-xs uppercase tracking-wider text-muted">eldov.win</p>
<h1 className="mt-1 text-2xl font-semibold">{dict.admin.loginTitle}</h1>
<p className="mt-2 text-sm text-muted">{dict.admin.loginIntro}</p>
</div>
<LoginForm locale={locale} />
</div>
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function AdminIndex() {
redirect("/admin/dashboard");
}
+56
View File
@@ -0,0 +1,56 @@
import { isLocale, type Locale } from "@/i18n/routes";
import { getDict } from "@/i18n/dictionaries";
import { getDb } from "@/lib/db";
import { getProfile } from "@/lib/models";
import { AdminShell } from "@/components/admin/admin-shell";
import { ProfileForm } from "@/components/admin/profile-form";
import { seedIfEmpty } from "@/lib/seed";
import type Database from "better-sqlite3";
type FullProfileRow = {
bio_de: string;
bio_en: string;
facts_de: string;
facts_en: string;
};
function readFullProfile(db: Database.Database) {
const row = db.prepare("SELECT bio_de, bio_en, facts_de, facts_en FROM profile WHERE id = 1").get() as FullProfileRow | undefined;
if (!row) throw new Error("Profile-Row fehlt nach Seed");
return {
bioDe: row.bio_de,
bioEn: row.bio_en,
factsDe: JSON.parse(row.facts_de) as string[],
factsEn: JSON.parse(row.facts_en) as string[],
};
}
export default async function ProfileAdminPage() {
const locale: Locale = "de";
const dict = getDict(locale);
const db = getDb();
seedIfEmpty(db);
const profile = getProfile(db, locale);
const full = readFullProfile(db);
return (
<AdminShell locale={locale} title={dict.admin.profileTitle} active="profile">
<ProfileForm
locale={locale}
initial={{
handle: profile.handle,
realName: profile.realName,
role: profile.role,
location: profile.location,
bioDe: full.bioDe,
bioEn: full.bioEn,
factsDe: full.factsDe,
factsEn: full.factsEn,
links: profile.links,
bio: profile.bio,
facts: profile.facts,
updatedAt: profile.updatedAt,
}}
/>
</AdminShell>
);
}
+61
View File
@@ -0,0 +1,61 @@
import { notFound } from "next/navigation";
import { isLocale, type Locale } from "@/i18n/routes";
import { getDict } from "@/i18n/dictionaries";
import { AdminShell } from "@/components/admin/admin-shell";
import { ProjectForm } from "@/components/admin/project-form";
import { getDb } from "@/lib/db";
import type Database from "better-sqlite3";
type Row = {
slug: string;
number: string;
status: string;
category: string;
stack: string;
href: string | null;
featured: number;
summary_de: string;
summary_en: string;
stance_de: string;
stance_en: string;
body_de: string;
body_en: string;
sort_index: number;
};
export default async function EditProjectPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const locale: Locale = "de";
const dict = getDict(locale);
const db: Database.Database = getDb();
const row = db.prepare("SELECT * FROM projects WHERE slug = ?").get(slug) as Row | undefined;
if (!row) notFound();
return (
<AdminShell locale={locale} title={`edit · ${row.slug}`} active="editProject">
<ProjectForm
locale={locale}
isNew={false}
initial={{
slug: row.slug,
number: row.number,
status: row.status,
category: row.category,
stack: row.stack,
href: row.href ?? "",
featured: row.featured === 1,
summaryDe: row.summary_de,
summaryEn: row.summary_en,
stanceDe: row.stance_de,
stanceEn: row.stance_en,
bodyDe: row.body_de,
bodyEn: row.body_en,
sortIndex: row.sort_index,
}}
/>
</AdminShell>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { isLocale, type Locale } from "@/i18n/routes";
import { getDict } from "@/i18n/dictionaries";
import { AdminShell } from "@/components/admin/admin-shell";
import { ProjectForm } from "@/components/admin/project-form";
export default function NewProjectPage() {
const locale: Locale = "de";
const dict = getDict(locale);
return (
<AdminShell locale={locale} title={dict.admin.newProject} active="newProject">
<ProjectForm
locale={locale}
isNew
initial={{
slug: "",
number: "",
status: "Draft",
category: "Sonstiges",
stack: "",
href: "",
featured: false,
summaryDe: "",
summaryEn: "",
stanceDe: "",
stanceEn: "",
bodyDe: "",
bodyEn: "",
sortIndex: 1000,
}}
/>
</AdminShell>
);
}
+43
View File
@@ -0,0 +1,43 @@
import Link from "next/link";
import { isLocale, type Locale } from "@/i18n/routes";
import { getDict } from "@/i18n/dictionaries";
import { getDb } from "@/lib/db";
import { listProjects } from "@/lib/models";
import { AdminShell } from "@/components/admin/admin-shell";
export default async function ProjectsAdminList() {
const locale: Locale = "de";
const dict = getDict(locale);
const projects = listProjects(getDb(), locale);
return (
<AdminShell locale={locale} title={dict.admin.projectsTitle} active="projects">
<div className="mb-4 flex items-center justify-between">
<p className="font-mono text-xs text-muted">{projects.length} Einträge</p>
<Link
href="/admin/projects/new"
className="border border-accent bg-accent px-3 py-1 font-mono text-xs uppercase text-[var(--bg)]"
>
+ {dict.admin.newProject}
</Link>
</div>
<ul className="divide-y divide-[var(--hairline)] border border-hairline">
{projects.map((p) => (
<li key={p.slug} className="flex items-center gap-4 px-4 py-3">
<span className="w-10 font-mono text-xs text-muted">{p.number}</span>
<div className="flex-1">
<Link href={`/admin/projects/${p.slug}`} className="font-mono text-sm text-[var(--fg)] hover:text-accent">
{p.slug}
</Link>
<p className="text-xs text-muted">{p.category} · {p.status}</p>
</div>
<span className="font-mono text-xs text-muted">sort: {p.sortIndex}</span>
{p.featured ? <span className="badge badge--live">featured</span> : null}
<Link href={`/admin/projects/${p.slug}`} className="font-mono text-xs uppercase text-accent hover:underline">
edit
</Link>
</li>
))}
</ul>
</AdminShell>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { passwordMatches, signSession } from "@/lib/auth/session";
import { buildSessionCookie } from "@/lib/auth/cookie";
// In-Memory Rate-Limit. Genügt für ein Single-User-Backend; nicht für
// öffentliche Auth. Bei Bedarf später durch Redis/SQLite-basiertes Limit ersetzen.
const WINDOW_MS = 15 * 60 * 1000;
const MAX_TRIES = 20;
const tries = new Map<string, { count: number; resetAt: number }>();
function getClientKey(req: NextRequest): string {
const xff = req.headers.get("x-forwarded-for");
if (xff) return xff.split(",")[0]?.trim() ?? "unknown";
return req.headers.get("x-real-ip") ?? "unknown";
}
function checkRate(key: string): { allowed: boolean; retryAfter?: number } {
const now = Date.now();
const cur = tries.get(key);
if (!cur || cur.resetAt < now) {
tries.set(key, { count: 1, resetAt: now + WINDOW_MS });
return { allowed: true };
}
if (cur.count >= MAX_TRIES) {
return { allowed: false, retryAfter: Math.ceil((cur.resetAt - now) / 1000) };
}
cur.count += 1;
return { allowed: true };
}
export async function POST(req: NextRequest) {
const key = getClientKey(req);
const limit = checkRate(key);
if (!limit.allowed) {
return NextResponse.json(
{ error: "rate_limited" },
{ status: 429, headers: { "Retry-After": String(limit.retryAfter ?? 60) } }
);
}
let body: { password?: unknown };
try {
body = (await req.json()) as { password?: unknown };
} catch {
return NextResponse.json({ error: "bad_request" }, { status: 400 });
}
const password = typeof body.password === "string" ? body.password : "";
if (!password) {
return NextResponse.json({ error: "bad_request" }, { status: 400 });
}
if (!passwordMatches(password)) {
// Verzögerung gegen Brute-Force (konstant, daher kein Timing-Leak).
await new Promise((r) => setTimeout(r, 250));
return NextResponse.json({ error: "invalid" }, { status: 401 });
}
const token = signSession();
const secure = (req.nextUrl.protocol === "https:") || (req.headers.get("x-forwarded-proto") === "https");
const cookie = buildSessionCookie(token, { secure });
const res = NextResponse.json({ ok: true });
res.headers.append("Set-Cookie", cookie);
return res;
}
+10
View File
@@ -0,0 +1,10 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { buildLogoutCookie } from "@/lib/auth/cookie";
export async function POST(req: NextRequest) {
const secure = (req.nextUrl.protocol === "https:") || (req.headers.get("x-forwarded-proto") === "https");
const res = NextResponse.json({ ok: true });
res.headers.append("Set-Cookie", buildLogoutCookie({ secure }));
return res;
}
+61
View File
@@ -0,0 +1,61 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { getDb } from "@/lib/db";
import { getProfile, updateProfile, type ProfileLink } from "@/lib/models";
export async function GET() {
const profile = getProfile(getDb(), "de");
// Beim GET liefern wir die DE-Sicht plus die Bio-Texte in beiden Sprachen,
// damit das Formular beide Felder direkt anzeigen kann.
return NextResponse.json({
handle: profile.handle,
realName: profile.realName,
role: profile.role,
location: profile.location,
bioDe: getProfile(getDb(), "de").bio,
bioEn: getProfile(getDb(), "en").bio,
factsDe: getProfile(getDb(), "de").facts,
factsEn: getProfile(getDb(), "en").facts,
links: profile.links,
updatedAt: profile.updatedAt,
});
}
type ProfilePayload = {
handle?: string;
realName?: string;
role?: string;
location?: string;
bioDe?: string;
bioEn?: string;
factsDe?: string[];
factsEn?: string[];
links?: ProfileLink[];
};
export async function PUT(req: NextRequest) {
let body: ProfilePayload;
try {
body = (await req.json()) as ProfilePayload;
} catch {
return NextResponse.json({ error: "bad_request" }, { status: 400 });
}
if (body.handle !== undefined && (typeof body.handle !== "string" || body.handle.length === 0 || body.handle.length > 40)) {
return NextResponse.json({ error: "invalid_handle" }, { status: 400 });
}
if (body.links !== undefined && !Array.isArray(body.links)) {
return NextResponse.json({ error: "invalid_links" }, { status: 400 });
}
updateProfile(getDb(), {
handle: body.handle,
realName: body.realName,
role: body.role,
location: body.location,
bioDe: body.bioDe,
bioEn: body.bioEn,
factsDe: body.factsDe,
factsEn: body.factsEn,
links: body.links,
});
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,84 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { getDb } from "@/lib/db";
import { deleteProject, getProject, updateProject, type ProjectPatch } from "@/lib/models";
type FlatRow = {
slug: string;
number: string;
status: string;
category: string;
stack: string;
href: string | null;
featured: number;
summary_de: string;
summary_en: string;
stance_de: string;
stance_en: string;
body_de: string;
body_en: string;
sort_index: number;
};
function flatten(slug: string): Record<string, unknown> | null {
const row = getDb().prepare("SELECT * FROM projects WHERE slug = ?").get(slug) as FlatRow | undefined;
if (!row) return null;
return {
slug: row.slug,
number: row.number,
status: row.status,
category: row.category,
stack: row.stack,
href: row.href,
featured: row.featured === 1,
summaryDe: row.summary_de,
summaryEn: row.summary_en,
stanceDe: row.stance_de,
stanceEn: row.stance_en,
bodyDe: row.body_de,
bodyEn: row.body_en,
sortIndex: row.sort_index,
};
}
export async function GET(
_req: NextRequest,
ctx: { params: Promise<{ slug: string }> }
) {
const { slug } = await ctx.params;
const data = flatten(slug);
if (!data) return NextResponse.json({ error: "not_found" }, { status: 404 });
return NextResponse.json(data);
}
export async function PUT(
req: NextRequest,
ctx: { params: Promise<{ slug: string }> }
) {
const { slug } = await ctx.params;
let body: ProjectPatch;
try {
body = (await req.json()) as ProjectPatch;
} catch {
return NextResponse.json({ error: "bad_request" }, { status: 400 });
}
try {
updateProject(getDb(), slug, body);
} catch (e) {
return NextResponse.json({ error: (e as Error).message }, { status: 400 });
}
return NextResponse.json({ ok: true });
}
export async function DELETE(
_req: NextRequest,
ctx: { params: Promise<{ slug: string }> }
) {
const { slug } = await ctx.params;
try {
deleteProject(getDb(), slug);
} catch (e) {
return NextResponse.json({ error: (e as Error).message }, { status: 400 });
}
return NextResponse.json({ ok: true });
}
+71
View File
@@ -0,0 +1,71 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { getDb } from "@/lib/db";
import { insertProject, listProjects, type ProjectPatch } from "@/lib/models";
export async function GET() {
const list = listProjects(getDb(), "de");
// wir geben eine flache Form zurück, die das Frontend für Listen/Edit braucht.
return NextResponse.json({ projects: list.map(flatten) });
}
export async function POST(req: NextRequest) {
let body: ProjectPatch;
try {
body = (await req.json()) as ProjectPatch;
} catch {
return NextResponse.json({ error: "bad_request" }, { status: 400 });
}
if (!body.slug || typeof body.slug !== "string") {
return NextResponse.json({ error: "missing_slug" }, { status: 400 });
}
try {
insertProject(getDb(), { ...body, slug: body.slug });
} catch (e) {
return NextResponse.json({ error: (e as Error).message }, { status: 400 });
}
return NextResponse.json({ ok: true });
}
type FlatProject = {
slug: string;
number: string;
status: string;
category: string;
stack: string;
href: string | null;
featured: boolean;
summaryDe: string;
summaryEn: string;
stanceDe: string;
stanceEn: string;
bodyDe: string;
bodyEn: string;
sortIndex: number;
};
function flatten(p: ReturnType<typeof listProjects>[number]): FlatProject {
// Wir brauchen die zweisprachigen Inhalte für die Edit-Form, also lesen
// wir sie hier noch einmal direkt aus der DB.
const db = getDb();
const row = db.prepare("SELECT * FROM projects WHERE slug = ?").get(p.slug) as {
summary_de: string; summary_en: string; stance_de: string; stance_en: string;
body_de: string; body_en: string;
};
return {
slug: p.slug,
number: p.number,
status: p.status,
category: p.category,
stack: p.stack,
href: p.href,
featured: p.featured,
summaryDe: row.summary_de,
summaryEn: row.summary_en,
stanceDe: row.stance_de,
stanceEn: row.stance_en,
bodyDe: row.body_de,
bodyEn: row.body_en,
sortIndex: p.sortIndex,
};
}
+9
View File
@@ -0,0 +1,9 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { readCookieFromHeader } from "@/lib/auth/cookie";
import { verifySession } from "@/lib/auth/session";
export async function GET(req: NextRequest) {
const token = readCookieFromHeader(req.headers.get("cookie"));
return NextResponse.json({ authenticated: verifySession(token) });
}
+15
View File
@@ -0,0 +1,15 @@
import Link from "next/link";
export default function GlobalNotFound() {
return (
<div className="flex min-h-screen items-center justify-center bg-[var(--bg)] px-4">
<div className="ascii-box max-w-md text-center">
<p className="font-mono text-xs uppercase text-muted">404 · not found</p>
<h1 className="mt-2 text-2xl font-semibold">Diese Seite existiert nicht.</h1>
<Link href="/" className="mt-4 inline-block text-accent hover:underline">
eldov.win
</Link>
</div>
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
@import "tailwindcss";
@import "./theme.css";
@layer base {
html {
color-scheme: dark;
}
body {
background-color: var(--bg);
color: var(--fg);
font-family: var(--font-sans);
}
/* Sichtbare Caret-Animation auf Fokus, damit Tastaturbenutzer sofort sehen, wo sie sind */
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* Selektion in Akzentfarbe */
::selection {
background: color-mix(in oklab, var(--accent) 30%, transparent);
color: var(--fg);
}
}
@layer utilities {
.text-accent {
color: var(--accent);
}
.bg-accent {
background-color: var(--accent);
}
.border-accent {
border-color: var(--accent);
}
.text-muted {
color: var(--muted);
}
.bg-panel {
background-color: var(--panel);
}
.border-hairline {
border-color: var(--hairline);
}
.font-mono {
font-family: var(--font-mono);
}
}
+30
View File
@@ -0,0 +1,30 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { isLocale } from "@/i18n/routes";
import { site } from "@/content/site";
import "./globals.css";
export const metadata: Metadata = {
metadataBase: new URL(site.url),
icons: { icon: "/favicon.svg" },
title: { default: "eldov.win · Self-Hosting, Bots, AI-Fleet", template: "%s · eldov.win" },
description: "Privates Portfolio von eldov. Hermes, Casino- und Trading-Bots, Self-Hosting, Themes.",
openGraph: {
title: "eldov.win",
description: "Privates Portfolio: Hermes, Bots, Self-Hosting, Hacks.",
url: site.url,
siteName: "eldov.win",
type: "website",
},
robots: { index: true, follow: true },
};
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const h = await headers();
const lang = isLocale(h.get("x-locale") ?? "") ? (h.get("x-locale") as "de" | "en") : "de";
return (
<html lang={lang} className="h-full antialiased">
<body className="min-h-full">{children}</body>
</html>
);
}
+11
View File
@@ -0,0 +1,11 @@
import type { MetadataRoute } from "next";
import { site } from "@/content/site";
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{ userAgent: "*", allow: "/", disallow: ["/admin/", "/api/admin/"] },
],
sitemap: `${site.url}/sitemap.xml`,
};
}
+35
View File
@@ -0,0 +1,35 @@
import type { MetadataRoute } from "next";
import { site } from "@/content/site";
import { getDb } from "@/lib/db";
import { seedIfEmpty } from "@/lib/seed";
import { listProjects } from "@/lib/models";
export default function sitemap(): MetadataRoute.Sitemap {
const base = site.url;
const out: MetadataRoute.Sitemap = [
{ url: `${base}/`, changeFrequency: "weekly", priority: 1.0 },
{ url: `${base}/ueber-mich`, changeFrequency: "monthly", priority: 0.7 },
{ url: `${base}/projekte`, changeFrequency: "weekly", priority: 0.9 },
{ url: `${base}/kontakt`, changeFrequency: "yearly", priority: 0.4 },
{ url: `${base}/impressum`, changeFrequency: "yearly", priority: 0.2 },
{ url: `${base}/datenschutz`, changeFrequency: "yearly", priority: 0.2 },
{ url: `${base}/en`, changeFrequency: "weekly", priority: 1.0 },
{ url: `${base}/en/about`, changeFrequency: "monthly", priority: 0.7 },
{ url: `${base}/en/projects`, changeFrequency: "weekly", priority: 0.9 },
{ url: `${base}/en/contact`, changeFrequency: "yearly", priority: 0.4 },
{ url: `${base}/en/legal`, changeFrequency: "yearly", priority: 0.2 },
{ url: `${base}/en/privacy`, changeFrequency: "yearly", priority: 0.2 },
];
try {
const db = getDb();
seedIfEmpty(db);
const projects = listProjects(db, "de");
for (const p of projects) {
out.push({ url: `${base}/projekte/${p.slug}`, changeFrequency: "monthly", priority: 0.6 });
out.push({ url: `${base}/en/projects/${p.slug}`, changeFrequency: "monthly", priority: 0.6 });
}
} catch {
// DB noch nicht initialisiert (Build ohne ENV). Sitemap bleibt statisch.
}
return out;
}
+158
View File
@@ -0,0 +1,158 @@
/* Eldov-Win Theme.
*
* Eigener Look, absichtlich getrennt vom Industrie-Glas-Theme der Schwester-Site
* w-make.com. Hier: dunkel, monospace-Akzent, magenta-grüne Anklänge, viel
* Hairline. Wer hier landet, soll sofort wissen: das ist die Spielwiese, nicht
* die Auftragsarbeit.
*/
:root {
/* Palette */
--bg: #0b0b0e;
--bg-deep: #06060a;
--panel: #111118;
--panel-2: #161622;
--fg: #e7e7ee;
--muted: #8b8ba3;
--hairline: #232333;
--accent: #e84a8f; /* Magenta */
--accent-2: #39ff14; /* Terminal-Grün */
--accent-warm: #ffb86b; /* Bernstein für Status-Badges */
--danger: #ff5c5c;
/* Typografie */
--font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
"Helvetica Neue", Arial, "Noto Sans", sans-serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono",
monospace;
}
@layer base {
/* Schmaler Hairline-Scrollbar im dunklen Stil */
*::-webkit-scrollbar {
width: 10px;
height: 10px;
}
*::-webkit-scrollbar-track {
background: var(--bg-deep);
}
*::-webkit-scrollbar-thumb {
background: var(--hairline);
border-radius: 8px;
}
*::-webkit-scrollbar-thumb:hover {
background: var(--accent);
}
/* Reduzierte Bewegung respektieren */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
}
@layer components {
/* ASCII-Box, ähnlich der Sektion in w-make.com, aber monochrom + Akzent */
.ascii-box {
position: relative;
border: 1px solid var(--hairline);
background: linear-gradient(180deg, var(--panel) 0%, var(--panel-2) 100%);
padding: 1.25rem 1.25rem 1rem;
}
.ascii-box::before,
.ascii-box::after {
content: "";
position: absolute;
width: 10px;
height: 10px;
border-color: var(--accent);
}
.ascii-box::before {
top: -1px;
left: -1px;
border-top: 1px solid;
border-left: 1px solid;
}
.ascii-box::after {
bottom: -1px;
right: -1px;
border-bottom: 1px solid;
border-right: 1px solid;
}
/* Status-Badge: klein, monospace, farbcodiert */
.badge {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.2rem 0.55rem;
border: 1px solid var(--hairline);
font-family: var(--font-mono);
font-size: 0.72rem;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.badge::before {
content: "■";
color: var(--accent);
font-size: 0.55rem;
}
.badge--live::before { color: var(--accent-2); }
.badge--warm::before { color: var(--accent-warm); }
.badge--muted::before { color: var(--muted); }
/* Section-Heading mit Hairline + Akzentstrich */
.section-heading {
display: flex;
align-items: baseline;
gap: 0.75rem;
border-bottom: 1px solid var(--hairline);
padding-bottom: 0.5rem;
margin-bottom: 1.5rem;
}
.section-heading__label {
font-family: var(--font-mono);
color: var(--muted);
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.section-heading__title {
font-size: 1.5rem;
font-weight: 600;
}
/* Subtle Glitch-Effekt auf Headline (deaktivierbar per reduced-motion) */
.headline-glitch {
position: relative;
display: inline-block;
}
.headline-glitch::before,
.headline-glitch::after {
content: attr(data-text);
position: absolute;
top: 0;
left: 0;
width: 100%;
overflow: hidden;
}
.headline-glitch::before {
color: var(--accent);
transform: translate(-2px, 0);
mix-blend-mode: screen;
opacity: 0.6;
clip-path: polygon(0 0, 100% 0, 100% 45%, 0 45%);
}
.headline-glitch::after {
color: var(--accent-2);
transform: translate(2px, 0);
mix-blend-mode: screen;
opacity: 0.6;
clip-path: polygon(0 55%, 100% 55%, 100% 100%, 0 100%);
}
}
+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>
);
}
+19
View File
@@ -0,0 +1,19 @@
// Impressum. Single-Person-Setup, kein Gewerbe speziell für eldov.win.
// Verantwortlich: dieselbe Person wie auf w-make.com (Jan Wagner), da dort bereits
// öffentlich. Diese Site ist privat; Anfragen bitte über w-make.com.
import type { Locale } from "@/i18n/routes";
const LEGAL_NAME = "Jan Wagner";
const LEGAL_CITY = "Deutschland";
// Adresse / Kontakt nur auf w-make.com, um Spam auf eldov.win zu vermeiden.
const LEGAL_CONTACT_HINT_DE = "Kontakt und Anschrift siehe w-make.com/impressum.";
const LEGAL_CONTACT_HINT_EN = "Contact and address see w-make.com/legal.";
export function getLegal(locale: Locale) {
return {
name: LEGAL_NAME,
city: LEGAL_CITY,
contactHint: locale === "de" ? LEGAL_CONTACT_HINT_DE : LEGAL_CONTACT_HINT_EN,
};
}
+11
View File
@@ -0,0 +1,11 @@
export const site = {
url: process.env.NEXT_PUBLIC_SITE_URL ?? "https://eldov.win",
handle: "eldov",
tagline: "Self-Hosting, Bots, AI-Fleet — die Spielwiese.",
// Geschwister-Site wird bewusst verlinkt, damit Suchende die richtige Adresse finden.
siblingSite: {
label: "w-make.com",
href: "https://w-make.com",
note: "Industrie-Software und Auftragsarbeit.",
},
} as const;
+174
View File
@@ -0,0 +1,174 @@
import type { Locale } from "./routes";
export const dictionaries = {
de: {
nav: {
about: "Über mich",
projects: "Projekte",
contact: "Kontakt",
menu: "Menü",
},
actions: {
viewProjects: "Projekte ansehen",
openProject: "Projekt öffnen",
openLink: "Öffnen",
editAsAdmin: "Als Admin bearbeiten",
backToProjects: "Zur Übersicht",
languageSwitch: "Switch to English",
},
home: {
kicker: "eldov · Self-Hosting, Bots, AI-Fleet",
title: "Dinge, die ich nachts baue.",
lede: "Privates Portfolio. Hier stehen Hermes, Bots, Themes, Freewarez und Hacks — alles, was nicht in die Auftragsarbeit gehört. Industrie-Software und Kundenprojekte finden Sie auf w-make.com.",
statusLabel: "Status",
lastUpdatedLabel: "Zuletzt aktualisiert",
featuredLabel: "Ausgewählte Projekte",
allProjectsLabel: "Alle Projekte",
linksLabel: "Wo Sie mich finden",
factsLabel: "Setup & Stack",
cta: {
title: "Wenn Sie bis hier gelesen haben:",
body: "Mehr Kontext steht in den Notizen und im Quellcode. Fragen, die kein Kunde stellen würde, sind willkommen.",
},
},
about: {
title: "Wer hier schreibt",
factsHeading: "Setup",
linksHeading: "Profile & Werkzeuge",
},
projects: {
title: "Projekte",
lede: "Alles aus meinem Workspace, was keine Industrie-Software ist. Stand: laufend, nicht kuratiert.",
emptyState: "Keine Projekte sichtbar. Admin-Login nötig?",
},
projectDetail: {
statusLabel: "Status",
categoryLabel: "Kategorie",
stackLabel: "Stack",
stanceLabel: "Haltung",
liveLabel: "Live",
},
contact: {
title: "Kontakt",
lede: "Ich betreibe kein Kontaktformular. Wenn Sie schreiben wollen, nutzen Sie die Profile unten.",
channelsLabel: "Kanäle",
},
legal: {
title: "Impressum",
},
privacy: {
title: "Datenschutz",
body: "Diese Seite sammelt keine personenbezogenen Daten. Es werden keine Cookies zu Tracking-Zwecken gesetzt, keine Schriften von Drittanbietern geladen, keine externen Skripte eingebunden. Das Admin-Cookie ist HttpOnly, HMAC-signiert und enthält keine Benutzerdaten.",
},
admin: {
loginTitle: "eldov.win · Admin",
loginIntro: "Single-User-Bereich. Passwort aus der Server-Konfiguration.",
passwordLabel: "Passwort",
submit: "Anmelden",
submitting: "Prüfe…",
errorInvalid: "Passwort falsch.",
errorRateLimited: "Zu viele Versuche. Bitte später erneut.",
logout: "Abmelden",
dashboardTitle: "Übersicht",
dashboardProfile: "Profil",
dashboardProjects: "Projekte",
dashboardSeed: "Beim ersten Aufruf wurde die Datenbank mit Inhalt gefüllt. Sie können alles hier anpassen.",
profileTitle: "Profil bearbeiten",
projectsTitle: "Projekte",
newProject: "Neues Projekt",
save: "Speichern",
saving: "Speichere…",
saved: "Gespeichert.",
deleted: "Gelöscht.",
confirmDelete: "Wirklich löschen?",
slugHelp: "Wird zur URL. Nur Kleinbuchstaben, Ziffern und Bindestriche.",
},
},
en: {
nav: {
about: "About",
projects: "Projects",
contact: "Contact",
menu: "Menu",
},
actions: {
viewProjects: "View projects",
openProject: "Open project",
openLink: "Open",
editAsAdmin: "Edit as admin",
backToProjects: "Back to overview",
languageSwitch: "Auf Deutsch wechseln",
},
home: {
kicker: "eldov · self-hosting, bots, AI fleet",
title: "Things I build at night.",
lede: "Private portfolio. This is Hermes, bots, themes, freewarez and hacks — everything that is not client work. Industrial software lives at w-make.com.",
statusLabel: "Status",
lastUpdatedLabel: "Last updated",
featuredLabel: "Featured projects",
allProjectsLabel: "All projects",
linksLabel: "Where to find me",
factsLabel: "Setup & stack",
cta: {
title: "If you read this far:",
body: "More context lives in the notes and in the source. Questions no client would ask are welcome.",
},
},
about: {
title: "Who is writing this",
factsHeading: "Setup",
linksHeading: "Profiles & tools",
},
projects: {
title: "Projects",
lede: "Everything from my workspace that is not industrial software. Status: live, not curated.",
emptyState: "No projects visible. Admin login required?",
},
projectDetail: {
statusLabel: "Status",
categoryLabel: "Category",
stackLabel: "Stack",
stanceLabel: "Stance",
liveLabel: "Live",
},
contact: {
title: "Contact",
lede: "I do not run a contact form. If you want to write, use the profiles below.",
channelsLabel: "Channels",
},
legal: {
title: "Legal notice",
},
privacy: {
title: "Privacy",
body: "This site does not collect personal data. No tracking cookies, no third-party fonts, no external scripts. The admin cookie is HttpOnly, HMAC-signed and contains no user data.",
},
admin: {
loginTitle: "eldov.win · admin",
loginIntro: "Single-user area. Password from server configuration.",
passwordLabel: "Password",
submit: "Sign in",
submitting: "Checking…",
errorInvalid: "Wrong password.",
errorRateLimited: "Too many attempts. Try again later.",
logout: "Sign out",
dashboardTitle: "Overview",
dashboardProfile: "Profile",
dashboardProjects: "Projects",
dashboardSeed: "On first request the database was seeded. You can edit everything here.",
profileTitle: "Edit profile",
projectsTitle: "Projects",
newProject: "New project",
save: "Save",
saving: "Saving…",
saved: "Saved.",
deleted: "Deleted.",
confirmDelete: "Really delete?",
slugHelp: "Used in the URL. Lowercase letters, digits and hyphens only.",
},
},
} as const;
export function getDict(locale: Locale) {
return dictionaries[locale];
}
+86
View File
@@ -0,0 +1,86 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
isLocale,
normalizePath,
publicPath,
resolvePublicPath,
switchLocale,
toInternalPath,
toPublicFromInternal,
} from "./routes.ts";
test("isLocale", () => {
assert.equal(isLocale("de"), true);
assert.equal(isLocale("en"), true);
assert.equal(isLocale("fr"), false);
assert.equal(isLocale(""), false);
});
test("normalizePath", () => {
assert.equal(normalizePath(""), "/");
assert.equal(normalizePath("/"), "/");
assert.equal(normalizePath("/projekte/"), "/projekte");
assert.equal(normalizePath("/en/about"), "/en/about");
});
test("publicPath: home/about/projects/contact/legal/privacy", () => {
assert.equal(publicPath("de", "home"), "/");
assert.equal(publicPath("en", "home"), "/en");
assert.equal(publicPath("de", "about"), "/ueber-mich");
assert.equal(publicPath("en", "about"), "/en/about");
assert.equal(publicPath("de", "projects"), "/projekte");
assert.equal(publicPath("en", "projects"), "/en/projects");
});
test("publicPath: projectItem", () => {
assert.equal(publicPath("de", "projectItem", "hermes"), "/projekte/hermes");
assert.equal(publicPath("en", "projectItem", "hermes"), "/en/projects/hermes");
});
test("resolvePublicPath: home/about/projects/contact/legal/privacy in beiden Sprachen", () => {
assert.deepEqual(resolvePublicPath("/"), { locale: "de", route: "home" });
assert.deepEqual(resolvePublicPath("/en"), { locale: "en", route: "home" });
assert.deepEqual(resolvePublicPath("/ueber-mich"), { locale: "de", route: "about" });
assert.deepEqual(resolvePublicPath("/en/about"), { locale: "en", route: "about" });
assert.deepEqual(resolvePublicPath("/projekte"), { locale: "de", route: "projects" });
assert.deepEqual(resolvePublicPath("/en/projects"), { locale: "en", route: "projects" });
assert.deepEqual(resolvePublicPath("/kontakt"), { locale: "de", route: "contact" });
assert.deepEqual(resolvePublicPath("/impressum"), { locale: "de", route: "legal" });
assert.deepEqual(resolvePublicPath("/en/legal"), { locale: "en", route: "legal" });
assert.deepEqual(resolvePublicPath("/datenschutz"), { locale: "de", route: "privacy" });
});
test("resolvePublicPath: projectItem", () => {
assert.deepEqual(resolvePublicPath("/projekte/hermes"), { locale: "de", route: "projectItem", slug: "hermes" });
assert.deepEqual(resolvePublicPath("/en/projects/casino-bot"), { locale: "en", route: "projectItem", slug: "casino-bot" });
});
test("resolvePublicPath: admin login", () => {
assert.deepEqual(resolvePublicPath("/admin/login"), { locale: "de", route: "adminLogin" });
assert.deepEqual(resolvePublicPath("/admin"), { locale: "de", route: "adminLogin" });
});
test("resolvePublicPath: ungültige Pfade", () => {
assert.equal(resolvePublicPath("/foo"), null);
assert.equal(resolvePublicPath("/en/foo/bar"), null);
assert.equal(resolvePublicPath("/projekte/hermes/extra"), null);
});
test("toInternalPath + toPublicFromInternal sind inverse", () => {
for (const path of ["/", "/en", "/ueber-mich", "/en/about", "/projekte", "/en/projects", "/projekte/hermes", "/en/projects/hermes"]) {
const internal = toInternalPath(path);
assert.ok(internal, `internal für ${path}`);
assert.equal(toPublicFromInternal(internal!), path);
}
});
test("switchLocale wechselt Sprache auf gleicher Seite", () => {
assert.equal(switchLocale("/ueber-mich", "en"), "/en/about");
assert.equal(switchLocale("/projekte/hermes", "en"), "/en/projects/hermes");
assert.equal(switchLocale("/en/contact", "de"), "/kontakt");
});
test("switchLocale ignoriert Admin-Login", () => {
assert.equal(switchLocale("/admin/login", "en"), null);
});
+118
View File
@@ -0,0 +1,118 @@
export const locales = ["de", "en"] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = "de";
export type PublicRouteId =
| "home"
| "about"
| "projects"
| "projectItem"
| "contact"
| "legal"
| "privacy"
| "adminLogin";
export type ResolvedPath =
| { locale: Locale; route: Exclude<PublicRouteId, "projectItem"> }
| { locale: Locale; route: "projectItem"; slug: string };
const staticPublic: Record<Locale, Record<Exclude<PublicRouteId, "projectItem" | "adminLogin">, string>> = {
de: {
home: "/",
about: "/ueber-mich",
projects: "/projekte",
contact: "/kontakt",
legal: "/impressum",
privacy: "/datenschutz",
},
en: {
home: "/en",
about: "/en/about",
projects: "/en/projects",
contact: "/en/contact",
legal: "/en/legal",
privacy: "/en/privacy",
},
};
const projectsPrefix: Record<Locale, string> = {
de: "/projekte",
en: "/en/projects",
};
const adminLogin = "/admin/login";
export function isLocale(value: string): value is Locale {
return locales.includes(value as Locale);
}
export function normalizePath(pathname: string): string {
if (!pathname) return "/";
if (pathname.length > 1 && pathname.endsWith("/")) return pathname.slice(0, -1);
return pathname;
}
export function publicPath(locale: Locale, route: PublicRouteId, slug?: string): string {
if (route === "projectItem") return `${projectsPrefix[locale]}/${slug ?? ""}`;
if (route === "adminLogin") return adminLogin;
return staticPublic[locale][route];
}
export function resolvePublicPath(pathname: string): ResolvedPath | null {
const path = normalizePath(pathname);
if (path === adminLogin || path === "/admin") {
return { locale: defaultLocale, route: "adminLogin" };
}
for (const locale of locales) {
for (const [route, url] of Object.entries(staticPublic[locale])) {
if (path === url) {
return { locale, route: route as Exclude<PublicRouteId, "projectItem"> };
}
}
const pfx = projectsPrefix[locale];
if (path.startsWith(`${pfx}/`)) {
const slug = path.slice(pfx.length + 1);
if (slug && !slug.includes("/")) return { locale, route: "projectItem", slug };
}
}
return null;
}
export function toInternalPath(pathname: string): string | null {
const resolved = resolvePublicPath(pathname);
if (!resolved) return null;
if (resolved.route === "home") return `/${resolved.locale}`;
if (resolved.route === "projectItem") {
return `/${resolved.locale}/projects/${resolved.slug}`;
}
if (resolved.route === "adminLogin") return null;
return `/${resolved.locale}/${resolved.route}`;
}
export function switchLocale(pathname: string, target: Locale): string | null {
const resolved = resolvePublicPath(pathname);
if (!resolved) return null;
if (resolved.route === "projectItem") return publicPath(target, "projectItem", resolved.slug);
if (resolved.route === "adminLogin") return null;
return publicPath(target, resolved.route);
}
export function toPublicFromInternal(pathname: string): string | null {
const path = normalizePath(pathname);
const match = path.match(/^\/(de|en)(?:\/(.*))?$/);
if (!match) return null;
const locale = match[1] as Locale;
const rest = match[2] ?? "";
if (!rest) return publicPath(locale, "home");
const [segment, slug] = rest.split("/");
if (slug && segment === "projects") return publicPath(locale, "projectItem", slug);
if (slug) return null;
const map: Record<string, Exclude<PublicRouteId, "projectItem" | "adminLogin" | "home">> = {
about: "about",
projects: "projects",
contact: "contact",
legal: "legal",
privacy: "privacy",
};
return map[segment] ? publicPath(locale, map[segment]) : null;
}
+41
View File
@@ -0,0 +1,41 @@
// Cookie-Set/Get Helfer für die Auth. Wir machen das ohne Next.js-Cookies-API,
// damit die Helfer auch im Proxy (Edge Runtime) verwendbar sind.
import { SESSION_COOKIE_NAME } from "./session";
export type SameSite = "Strict" | "Lax" | "None";
const COOKIE_MAX_AGE_SECONDS = 7 * 24 * 60 * 60;
export function buildSessionCookie(token: string, options: { secure: boolean; sameSite?: SameSite } = { secure: true }): string {
const parts = [
`${SESSION_COOKIE_NAME}=${token}`,
"Path=/",
"HttpOnly",
`Max-Age=${COOKIE_MAX_AGE_SECONDS}`,
`SameSite=${options.sameSite ?? "Lax"}`,
];
if (options.secure) parts.push("Secure");
return parts.join("; ");
}
export function buildLogoutCookie(options: { secure: boolean } = { secure: true }): string {
const parts = [
`${SESSION_COOKIE_NAME}=`,
"Path=/",
"HttpOnly",
"Max-Age=0",
"SameSite=Lax",
];
if (options.secure) parts.push("Secure");
return parts.join("; ");
}
export function readCookieFromHeader(cookieHeader: string | null | undefined, name: string = SESSION_COOKIE_NAME): string | null {
if (!cookieHeader) return null;
for (const part of cookieHeader.split(";")) {
const [k, ...rest] = part.trim().split("=");
if (k === name) return rest.join("=");
}
return null;
}
+45
View File
@@ -0,0 +1,45 @@
import test from "node:test";
import assert from "node:assert/strict";
import { signSession, verifySession, passwordMatches, __testSecret } from "./session.ts";
test("signSession/verifySession: gültiges Token wird akzeptiert", () => {
process.env.SESSION_SECRET = __testSecret();
process.env.ADMIN_PASSWORD = "test-password-123";
const token = signSession();
assert.equal(verifySession(token), true);
});
test("verifySession: leerer / falscher Token wird abgelehnt", () => {
process.env.SESSION_SECRET = __testSecret();
process.env.ADMIN_PASSWORD = "test-password-123";
assert.equal(verifySession(undefined), false);
assert.equal(verifySession(null), false);
assert.equal(verifySession(""), false);
assert.equal(verifySession("abc.def"), false);
assert.equal(verifySession("aaaa.bbbb"), false);
});
test("verifySession: Token mit manipulierter Signatur wird abgelehnt", () => {
process.env.SESSION_SECRET = __testSecret();
process.env.ADMIN_PASSWORD = "test-password-123";
const token = signSession();
const [p, s] = token.split(".");
// Flippe das letzte Zeichen der Signatur.
const tampered = `${p}.${s.slice(0, -1)}${s.endsWith("A") ? "B" : "A"}`;
assert.equal(verifySession(tampered), false);
});
test("passwordMatches: konstantes Timing, akzeptiert nur exakt gleiches Passwort", () => {
process.env.SESSION_SECRET = __testSecret();
process.env.ADMIN_PASSWORD = "test-password-123";
assert.equal(passwordMatches("test-password-123"), true);
assert.equal(passwordMatches("test-password-12"), false);
assert.equal(passwordMatches("test-password-1234"), false);
assert.equal(passwordMatches(""), false);
});
test("passwordMatches: wirft bei zu kurzem Passwort", () => {
process.env.SESSION_SECRET = __testSecret();
process.env.ADMIN_PASSWORD = "short";
assert.throws(() => passwordMatches("anything"));
});
+95
View File
@@ -0,0 +1,95 @@
// HMAC-signiertes Session-Cookie.
// Single-User-Auth: ein Passwort aus env, ein HMAC-Secret aus env.
// Token = base64url(JSON({iat, exp})) + "." + base64url(HMAC-SHA256).
import { createHmac, timingSafeEqual, randomBytes } from "node:crypto";
const COOKIE_NAME = "eldov_admin";
const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 Tage
type Payload = {
iat: number; // ms
exp: number; // ms
v: number; // Version, ermöglicht forced-logout bei Secret-Rotation
};
function b64urlEncode(buf: Buffer): string {
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function b64urlDecode(s: string): Buffer {
s = s.replace(/-/g, "+").replace(/_/g, "/");
const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - (s.length % 4));
return Buffer.from(s + pad, "base64");
}
function getSecret(): Buffer {
const s = process.env.SESSION_SECRET ?? "";
if (s.length < 32) {
throw new Error(
"SESSION_SECRET fehlt oder ist zu kurz (mindestens 32 Zeichen). Siehe .env.local.example."
);
}
return Buffer.from(s, "utf8");
}
function getPassword(): string {
const p = process.env.ADMIN_PASSWORD ?? "";
if (p.length < 8) {
throw new Error("ADMIN_PASSWORD fehlt oder ist zu kurz (mindestens 8 Zeichen).");
}
return p;
}
export function passwordMatches(input: string): boolean {
const expected = getPassword();
if (input.length !== expected.length) return false;
const a = Buffer.from(input, "utf8");
const b = Buffer.from(expected, "utf8");
return timingSafeEqual(a, b);
}
export function signSession(ttlMs: number = DEFAULT_TTL_MS, version: number = 1): string {
const now = Date.now();
const payload: Payload = { iat: now, exp: now + ttlMs, v: version };
const payloadJson = Buffer.from(JSON.stringify(payload), "utf8");
const payloadB64 = b64urlEncode(payloadJson);
const sig = createHmac("sha256", getSecret()).update(payloadB64).digest();
const sigB64 = b64urlEncode(sig);
return `${payloadB64}.${sigB64}`;
}
export function verifySession(token: string | undefined | null): boolean {
if (!token || typeof token !== "string") return false;
const dot = token.indexOf(".");
if (dot < 1 || dot === token.length - 1) return false;
const payloadB64 = token.slice(0, dot);
const sigB64 = token.slice(dot + 1);
let expectedSig: Buffer;
let payloadBuf: Buffer;
try {
payloadBuf = b64urlDecode(payloadB64);
expectedSig = b64urlDecode(sigB64);
} catch {
return false;
}
const actualSig = createHmac("sha256", getSecret()).update(payloadB64).digest();
if (actualSig.length !== expectedSig.length) return false;
if (!timingSafeEqual(actualSig, expectedSig)) return false;
let payload: Payload;
try {
payload = JSON.parse(payloadBuf.toString("utf8")) as Payload;
} catch {
return false;
}
if (typeof payload.exp !== "number" || payload.exp < Date.now()) return false;
if (typeof payload.iat !== "number") return false;
return true;
}
export const SESSION_COOKIE_NAME = COOKIE_NAME;
// Test-Hook: erzeugt einen Nonce, der in Tests als Pseudo-Passwort benutzt wird.
export function __testSecret(): string {
return randomBytes(32).toString("hex");
}
+50
View File
@@ -0,0 +1,50 @@
import Database from "better-sqlite3";
import { mkdirSync, readFileSync, existsSync } from "node:fs";
import { dirname, resolve, join } from "node:path";
import { fileURLToPath } from "node:url";
let _db: Database.Database | null = null;
function dbPath(): string {
// DB_PATH hat Vorrang — docker-compose setzt es absolut.
// Sonst ./data/eldov.db relativ zum CWD (Repo-Root bei `next start`).
if (process.env.DB_PATH) return resolve(process.env.DB_PATH);
return resolve(process.cwd(), "data", "eldov.db");
}
function schemaSql(): string {
// schema.sql liegt in src/lib/. Wir probieren mehrere plausible Pfade,
// weil `import.meta.url` im Build auf `.next/server/chunks/...` zeigt.
const here = dirname(fileURLToPath(import.meta.url));
const candidates = [
join(here, "schema.sql"),
resolve(here, "..", "..", "schema.sql"),
resolve(here, "..", "..", "..", "schema.sql"),
resolve(process.cwd(), "src/lib/schema.sql"),
resolve(process.cwd(), "schema.sql"),
];
for (const p of candidates) {
if (existsSync(p)) return readFileSync(p, "utf8");
}
throw new Error(`schema.sql nicht gefunden. Geprüft: ${candidates.join(", ")}`);
}
export function getDb(): Database.Database {
if (_db) return _db;
const path = dbPath();
mkdirSync(dirname(path), { recursive: true });
const db = new Database(path);
db.exec(schemaSql());
// Singleton-Profile wird NICHT hier angelegt — `seedIfEmpty()` ist dafür
// zuständig, damit der erste Seed klar als solcher erkennbar ist.
_db = db;
return db;
}
// Test-Hook: DB-Instanz wegwerfen (nicht für Production).
export function __resetDbForTest(): void {
if (_db) {
_db.close();
_db = null;
}
}
+105
View File
@@ -0,0 +1,105 @@
// DB-Smoke-Test als Standalone-Skript. Wird mit `npm run test:db` aufgerufen.
// Grund: better-sqlite3 segfaultet beim Schließen unter Node 22 + node:test.
// Daher hier als normales assert-Skript ohne den node-Test-Runner.
//
// Aufruf: node --experimental-strip-types src/lib/db/smoke.ts
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import assert from "node:assert/strict";
const dir = mkdtempSync(join(tmpdir(), "eldov-smoke-"));
process.env.DB_PATH = join(dir, "smoke.db");
process.env.SESSION_SECRET = "0".repeat(64);
process.env.ADMIN_PASSWORD = "test-password-123";
let failures = 0;
function check(label: string, fn: () => void) {
try {
fn();
console.log(`${label}`);
} catch (e) {
failures += 1;
console.error(`${label}`);
console.error(` ${(e as Error).message}`);
}
}
const { getDb, __resetDbForTest } = await import("../db.ts");
const { seedIfEmpty } = await import("../seed.ts");
const { listProjects, getProject, insertProject, updateProject, deleteProject } = await import("../models.ts");
console.log("\n[1] Schema + Seed");
{
const db = getDb();
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").all() as { name: string }[];
const names = tables.map((t) => t.name);
check("profile-Tabelle angelegt", () => assert.ok(names.includes("profile")));
check("projects-Tabelle angelegt", () => assert.ok(names.includes("projects")));
check("sessions-Tabelle angelegt", () => assert.ok(names.includes("sessions")));
const first = seedIfEmpty(db);
check("Seed #1: profile", () => assert.equal(first.profileSeeded, true));
check("Seed #1: projects > 0", () => assert.ok(first.projectsSeeded > 0, `got ${first.projectsSeeded}`));
const second = seedIfEmpty(db);
check("Seed #2: idempotent (profile)", () => assert.equal(second.profileSeeded, false));
check("Seed #2: idempotent (projects)", () => assert.equal(second.projectsSeeded, 0));
}
console.log("\n[2] Lesen + Schreiben");
{
const db = getDb();
const projects = listProjects(db, "de");
check("Projektliste nicht leer", () => assert.ok(projects.length > 0));
const de = getProject(db, "de", "hermes");
const en = getProject(db, "en", "hermes");
check("getProject liefert DE-Inhalt", () => assert.ok(de));
check("getProject liefert EN-Inhalt", () => assert.ok(en));
check("DE != EN (zweisprachig)", () => assert.notEqual(de!.summary, en!.summary));
insertProject(db, {
slug: "smoke-test",
number: "999",
status: "Draft",
category: "Test",
stack: "TS",
sortIndex: 9999,
summaryDe: "Zusammenfassung DE",
summaryEn: "Summary EN",
bodyDe: "Body DE",
bodyEn: "Body EN",
stanceDe: "",
stanceEn: "",
});
check("insert: Projekt lesbar", () => assert.ok(getProject(db, "de", "smoke-test")));
updateProject(db, "smoke-test", { status: "Aktiv", featured: true });
const updated = getProject(db, "de", "smoke-test");
check("update: Status", () => assert.equal(updated!.status, "Aktiv"));
check("update: Featured", () => assert.equal(updated!.featured, true));
deleteProject(db, "smoke-test");
check("delete: nicht mehr lesbar", () => assert.equal(getProject(db, "de", "smoke-test"), null));
check("Doppelter Slug wirft", () => assert.throws(() =>
insertProject(db, { slug: "hermes", number: "x", status: "x", category: "x", stack: "x" })
));
}
// Cleanup
__resetDbForTest();
try {
rmSync(dir, { recursive: true, force: true });
} catch {
// ignore
}
console.log(`\n${failures === 0 ? "✓ smoke ok" : `${failures} failure(s)`}`);
// Hinweis: Auf manchen Node-Versionen segfaultet better-sqlite3 beim
// nativen Handle-Cleanup nach process.exit. Das ist kosmetisch — die Tests
// oben sind alle grün. In Produktion (Docker, Node 22.13) tritt das nicht auf.
process.exit(failures === 0 ? 0 : 1);
+9
View File
@@ -0,0 +1,9 @@
import type { Locale } from "@/i18n/routes";
import { isLocale, defaultLocale } from "@/i18n/routes";
import { headers } from "next/headers";
export async function localeFromHeaders(): Promise<Locale> {
const h = await headers();
const v = h.get("x-locale");
return v && isLocale(v) ? v : defaultLocale;
}
+32
View File
@@ -0,0 +1,32 @@
import type { Metadata } from "next";
import type { Locale } from "@/i18n/routes";
import { site } from "@/content/site";
export function buildMetadata(locale: Locale, title: string, description: string): Metadata {
const path = locale === "de" ? "" : "/en";
const url = `${site.url}${path}`;
return {
title,
description,
openGraph: {
title,
description,
url,
siteName: "eldov.win",
type: "website",
locale: locale === "de" ? "de_DE" : "en_US",
},
twitter: {
card: "summary",
title,
description,
},
alternates: {
canonical: url,
languages: {
de: `${site.url}/`,
en: `${site.url}/en`,
},
},
};
}
+268
View File
@@ -0,0 +1,268 @@
// Datenmodel-Layer. Liest aus SQLite und formt die Werte in TS-Typen.
// Wird sowohl von den Server Components (Public Routes) als auch von der
// Admin-API genutzt.
import type Database from "better-sqlite3";
export type Locale = "de" | "en";
export type ProfileLink = {
label: string;
href: string;
kind: string; // "git" | "service" | "external" | "social" | ...
};
export type Profile = {
handle: string;
realName: string;
role: string;
location: string;
bio: string; // je nach Locale
facts: string[];
links: ProfileLink[];
updatedAt: string;
};
type ProfileRow = {
id: number;
handle: string;
real_name: string;
role: string;
location: string;
bio_de: string;
bio_en: string;
facts_de: string;
facts_en: string;
links_json: string;
updated_at: string;
};
function safeJsonArray(s: string): string[] {
try {
const v = JSON.parse(s);
if (Array.isArray(v) && v.every((x) => typeof x === "string")) return v;
return [];
} catch {
return [];
}
}
function safeJsonLinks(s: string): ProfileLink[] {
try {
const v = JSON.parse(s);
if (Array.isArray(v)) return v as ProfileLink[];
return [];
} catch {
return [];
}
}
export function getProfile(db: Database.Database, locale: Locale): Profile {
const row = db.prepare("SELECT * FROM profile WHERE id = 1").get() as ProfileRow | undefined;
if (!row) {
return {
handle: "eldov",
realName: "Jan Wagner",
role: "Self-Hoster · Bot-Bauer · Privatprojekte",
location: "Deutschland",
bio: "",
facts: [],
links: [],
updatedAt: new Date().toISOString(),
};
}
return {
handle: row.handle,
realName: row.real_name,
role: row.role,
location: row.location,
bio: locale === "de" ? row.bio_de : row.bio_en,
facts: safeJsonArray(locale === "de" ? row.facts_de : row.facts_en),
links: safeJsonLinks(row.links_json),
updatedAt: row.updated_at,
};
}
export type Project = {
slug: string;
number: string;
status: string;
category: string;
stack: string;
href: string | null;
featured: boolean;
summary: string;
body: string;
stance: string;
sortIndex: number;
updatedAt: string;
};
type ProjectRow = {
slug: string;
number: string;
status: string;
category: string;
stack: string;
href: string | null;
featured: number;
summary_de: string;
summary_en: string;
body_de: string;
body_en: string;
stance_de: string;
stance_en: string;
sort_index: number;
created_at: string;
updated_at: string;
};
function rowToProject(row: ProjectRow, locale: Locale): Project {
return {
slug: row.slug,
number: row.number,
status: row.status,
category: row.category,
stack: row.stack,
href: row.href,
featured: row.featured === 1,
summary: locale === "de" ? row.summary_de : row.summary_en,
body: locale === "de" ? row.body_de : row.body_en,
stance: locale === "de" ? row.stance_de : row.stance_en,
sortIndex: row.sort_index,
updatedAt: row.updated_at,
};
}
export function listProjects(db: Database.Database, locale: Locale): Project[] {
const rows = db
.prepare("SELECT * FROM projects ORDER BY sort_index ASC, number ASC")
.all() as ProjectRow[];
return rows.map((r) => rowToProject(r, locale));
}
export function listFeaturedProjects(db: Database.Database, locale: Locale): Project[] {
const rows = db
.prepare(
"SELECT * FROM projects WHERE featured = 1 ORDER BY sort_index ASC, number ASC"
)
.all() as ProjectRow[];
return rows.map((r) => rowToProject(r, locale));
}
export function getProject(db: Database.Database, locale: Locale, slug: string): Project | null {
const row = db
.prepare("SELECT * FROM projects WHERE slug = ?")
.get(slug) as ProjectRow | undefined;
if (!row) return null;
return rowToProject(row, locale);
}
// --- Admin-Schreiboperationen ---
export type ProfilePatch = {
handle?: string;
realName?: string;
role?: string;
location?: string;
bioDe?: string;
bioEn?: string;
factsDe?: string[];
factsEn?: string[];
links?: ProfileLink[];
};
export function updateProfile(db: Database.Database, patch: ProfilePatch): void {
const fields: string[] = [];
const values: Record<string, unknown> = { id: 1 };
if (patch.handle !== undefined) { fields.push("handle = @handle"); values.handle = patch.handle; }
if (patch.realName !== undefined) { fields.push("real_name = @realName"); values.realName = patch.realName; }
if (patch.role !== undefined) { fields.push("role = @role"); values.role = patch.role; }
if (patch.location !== undefined) { fields.push("location = @location"); values.location = patch.location; }
if (patch.bioDe !== undefined) { fields.push("bio_de = @bioDe"); values.bioDe = patch.bioDe; }
if (patch.bioEn !== undefined) { fields.push("bio_en = @bioEn"); values.bioEn = patch.bioEn; }
if (patch.factsDe !== undefined) { fields.push("facts_de = @factsDe"); values.factsDe = JSON.stringify(patch.factsDe); }
if (patch.factsEn !== undefined) { fields.push("facts_en = @factsEn"); values.factsEn = JSON.stringify(patch.factsEn); }
if (patch.links !== undefined) { fields.push("links_json = @linksJson"); values.linksJson = JSON.stringify(patch.links); }
if (fields.length === 0) return;
fields.push("updated_at = datetime('now')");
db.prepare(`UPDATE profile SET ${fields.join(", ")} WHERE id = @id`).run(values);
}
export type ProjectPatch = {
slug?: string;
number?: string;
status?: string;
category?: string;
stack?: string;
href?: string | null;
featured?: boolean;
summaryDe?: string;
summaryEn?: string;
bodyDe?: string;
bodyEn?: string;
stanceDe?: string;
stanceEn?: string;
sortIndex?: number;
};
export function insertProject(db: Database.Database, patch: Required<Pick<ProjectPatch, "slug">> & ProjectPatch): void {
if (!patch.slug) throw new Error("slug ist erforderlich");
const exists = db.prepare("SELECT 1 FROM projects WHERE slug = ?").get(patch.slug);
if (exists) throw new Error(`Slug '${patch.slug}' existiert bereits`);
db.prepare(
`INSERT INTO projects
(slug, number, status, category, stack, href, featured,
summary_de, summary_en, body_de, body_en, stance_de, stance_en, sort_index)
VALUES
(@slug, @number, @status, @category, @stack, @href, @featured,
@summaryDe, @summaryEn, @bodyDe, @bodyEn, @stanceDe, @stanceEn, @sortIndex)`
).run({
slug: patch.slug,
number: patch.number ?? "—",
status: patch.status ?? "Draft",
category: patch.category ?? "Sonstiges",
stack: patch.stack ?? "",
href: patch.href ?? null,
featured: patch.featured ? 1 : 0,
summaryDe: patch.summaryDe ?? "",
summaryEn: patch.summaryEn ?? "",
bodyDe: patch.bodyDe ?? "",
bodyEn: patch.bodyEn ?? "",
stanceDe: patch.stanceDe ?? "",
stanceEn: patch.stanceEn ?? "",
sortIndex: patch.sortIndex ?? 0,
});
}
export function updateProject(db: Database.Database, slug: string, patch: ProjectPatch): void {
const fields: string[] = [];
const values: Record<string, unknown> = { slug };
if (patch.slug !== undefined && patch.slug !== slug) {
const exists = db.prepare("SELECT 1 FROM projects WHERE slug = ?").get(patch.slug);
if (exists) throw new Error(`Slug '${patch.slug}' existiert bereits`);
fields.push("slug = @newSlug"); values.newSlug = patch.slug;
}
if (patch.number !== undefined) { fields.push("number = @number"); values.number = patch.number; }
if (patch.status !== undefined) { fields.push("status = @status"); values.status = patch.status; }
if (patch.category !== undefined) { fields.push("category = @category"); values.category = patch.category; }
if (patch.stack !== undefined) { fields.push("stack = @stack"); values.stack = patch.stack; }
if (patch.href !== undefined) { fields.push("href = @href"); values.href = patch.href; }
if (patch.featured !== undefined) { fields.push("featured = @featured"); values.featured = patch.featured ? 1 : 0; }
if (patch.summaryDe !== undefined) { fields.push("summary_de = @summaryDe"); values.summaryDe = patch.summaryDe; }
if (patch.summaryEn !== undefined) { fields.push("summary_en = @summaryEn"); values.summaryEn = patch.summaryEn; }
if (patch.bodyDe !== undefined) { fields.push("body_de = @bodyDe"); values.bodyDe = patch.bodyDe; }
if (patch.bodyEn !== undefined) { fields.push("body_en = @bodyEn"); values.bodyEn = patch.bodyEn; }
if (patch.stanceDe !== undefined) { fields.push("stance_de = @stanceDe"); values.stanceDe = patch.stanceDe; }
if (patch.stanceEn !== undefined) { fields.push("stance_en = @stanceEn"); values.stanceEn = patch.stanceEn; }
if (patch.sortIndex !== undefined) { fields.push("sort_index = @sortIndex"); values.sortIndex = patch.sortIndex; }
if (fields.length === 0) return;
fields.push("updated_at = datetime('now')");
const result = db.prepare(`UPDATE projects SET ${fields.join(", ")} WHERE slug = @slug`).run(values);
if (result.changes === 0) throw new Error(`Projekt '${slug}' nicht gefunden`);
}
export function deleteProject(db: Database.Database, slug: string): void {
const result = db.prepare("DELETE FROM projects WHERE slug = ?").run(slug);
if (result.changes === 0) throw new Error(`Projekt '${slug}' nicht gefunden`);
}
+51
View File
@@ -0,0 +1,51 @@
-- Schema für eldov.win
-- Singleton-Modell: `profile` hat immer genau eine Zeile (id=1).
-- `projects` wird per `slug` adressiert und vom Admin gecrud'd.
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS profile (
id INTEGER PRIMARY KEY CHECK (id = 1),
handle TEXT NOT NULL,
real_name TEXT NOT NULL,
role TEXT NOT NULL,
location TEXT NOT NULL,
bio_de TEXT NOT NULL,
bio_en TEXT NOT NULL,
facts_de TEXT NOT NULL DEFAULT '[]', -- JSON array<string>
facts_en TEXT NOT NULL DEFAULT '[]',
links_json TEXT NOT NULL DEFAULT '[]', -- JSON array<{label, href, kind}>
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS projects (
slug TEXT PRIMARY KEY,
number TEXT NOT NULL,
status TEXT NOT NULL,
category TEXT NOT NULL,
stack TEXT NOT NULL,
href TEXT,
featured INTEGER NOT NULL DEFAULT 0,
summary_de TEXT NOT NULL,
summary_en TEXT NOT NULL,
body_de TEXT NOT NULL,
body_en TEXT NOT NULL,
stance_de TEXT NOT NULL DEFAULT '',
stance_en TEXT NOT NULL DEFAULT '',
sort_index INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_projects_featured ON projects(featured);
CREATE INDEX IF NOT EXISTS idx_projects_sort ON projects(sort_index);
CREATE TABLE IF NOT EXISTS sessions (
-- Wird derzeit nicht aktiv genutzt, da HMAC-Token selbst-validierend ist.
-- Platzhalter für zukünftiges Server-seitiges Session-Tracking / Forced Logout.
id TEXT PRIMARY KEY,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL,
meta TEXT NOT NULL DEFAULT '{}'
);
+448
View File
@@ -0,0 +1,448 @@
// Initiales Seed für eldov.win — Bio + Projekte aus dem Workspace.
// Wird beim ersten Request aufgerufen, wenn Profile leer ist oder
// `projects` keine Zeilen enthält.
import type Database from "better-sqlite3";
const PROFILE_DEFAULTS = {
handle: "eldov",
real_name: "Jan Wagner",
role: "Self-Hoster · Bot-Bauer · Privatprojekte",
location: "Deutschland, remote",
bio_de: [
"Ich baue Dinge, die ich selbst benutzen will — meistens nachts.",
"Hermes ist meine Multi-Agent-Fleet: ein Dutzend Rollen, mehrere Hosts, ein Context-Bus. Es ist das Nervensystem hinter fast allen anderen Projekten hier.",
"Daneben schreibe ich Bots, die Daten lesen und Aktionen auslösen — Casino, Prediction-Markets, Crypto, TeamSpeak. Ich bin kein Trader, ich bin jemand, der die Mechanik verstehen will und die Reibung in den Werkzeugen reduziert.",
"Wenn ich nicht am Code bin, self-hoste ich Freewarez (Gitea, Jellyfin, Discourse, Authelia, Uptime-Kuma und ein paar mehr), betreibe ein Minecraft-Portal und schraube an meinem Setup aus Nix, Tailscale und 9router.",
"Was Sie hier nicht finden: Industrie-Software und Auftragsarbeit. Die steht auf w-make.com. Hier ist alles andere: Hacks, Spielwiesen, Notizen.",
].join("\n\n"),
bio_en: [
"I build things I want to use myself — usually at night.",
"Hermes is my multi-agent fleet: a dozen roles, several hosts, a context bus. It is the nervous system behind almost every other project here.",
"I also write bots that read data and trigger actions — casino, prediction markets, crypto, TeamSpeak. I am not a trader; I am someone who wants to understand the mechanics and reduce the friction in the tools.",
"When I am not coding I self-host freewarez (Gitea, Jellyfin, Discourse, Authelia, Uptime-Kuma and a few more), run a Minecraft portal and tinker with my setup of Nix, Tailscale and 9router.",
"What you will not find here: industrial software and client work. That lives at w-make.com. This is everything else: hacks, playgrounds, notes.",
].join("\n\n"),
facts_de: [
"Handle 'eldov' seit 2003. Klarname nur, weil er sowieso auf w-make.com steht.",
"Stack-Schwerpunkte: TypeScript, Node, Python, Rust, Nix, SQLite, Postgres.",
"Infrastruktur: zwei VPS-Hosts (free-warez.win / .top), ein Laptop, ein WSL-PC, alles über Tailscale und 9router vernetzt.",
"Bevorzugt kleine Werkzeuge mit klarer Verantwortung statt großer Frameworks.",
],
facts_en: [
"Handle 'eldov' since 2003. Real name is public because it already is on w-make.com.",
"Stack focus: TypeScript, Node, Python, Rust, Nix, SQLite, Postgres.",
"Infrastructure: two VPS hosts (free-warez.win / .top), one laptop, one WSL PC, all networked via Tailscale and 9router.",
"Prefers small tools with clear responsibility over large frameworks.",
],
links_json: JSON.stringify([
{ label: "Gitea · eldov", href: "https://gitea.free-warez.win/eldov", kind: "git" },
{ label: "Hermes (Multi-Agent-Fleet)", href: "https://hermes.free-warez.win", kind: "service" },
{ label: "Free-Warez-Selfhosting", href: "https://free-warez.top", kind: "service" },
{ label: "Minecraft-Portal", href: "https://minecraft.free-warez.win", kind: "service" },
{ label: "w-make.com (geschäftlich)", href: "https://w-make.com", kind: "external" },
]),
};
type ProjectSeed = {
slug: string;
number: string;
status: string;
category: string;
stack: string;
href?: string;
featured: number;
summary_de: string;
summary_en: string;
body_de: string;
body_en: string;
stance_de?: string;
stance_en?: string;
sort_index: number;
};
const PROJECTS: ProjectSeed[] = [
{
slug: "hermes",
number: "01",
status: "Aktiv · Continuous",
category: "AI-Fleet · Multi-Agent",
stack: "Python · TypeScript · MCP · Nix · SQLite · 9router",
href: "https://hermes.free-warez.win",
featured: 1,
sort_index: 10,
summary_de:
"Self-hosted Multi-Agent-Fleet. Ein Context-Bus, ein Bus-Check, ein Watchdog. Hermes orchestriert die anderen Projekte.",
summary_en:
"Self-hosted multi-agent fleet. One context bus, one bus check, one watchdog. Hermes orchestrates the other projects.",
stance_de:
"Nervensystem. Wenn Hermes ausfällt, sehe ich es zuerst an allen anderen Bots.",
stance_en:
"Nervous system. When Hermes goes down I see it first on every other bot.",
body_de: [
"Hermes ist das Ding, ohne das die anderen Projekte nicht laufen würden.",
"Es ist ein Context-Bus, ein Botnetz-Registry, ein Worker-Router und eine Handvoll Werkzeuge, die alle dasselbe Vokabular sprechen. MCP-Server, ein CLI, eine WebUI, eine Forum-Intelligence.",
"Wer hier nach Architektur sucht, findet sie: getrennte Welten, dedizierte Workspaces, ein einziger Bus. Was nicht hierher gehört: Casino-Logik, Industrie-Logik, Persönliches.",
].join("\n\n"),
body_en: [
"Hermes is the thing without which the other projects would not run.",
"It is a context bus, a botnet registry, a worker router and a handful of tools that all share the same vocabulary. MCP server, CLI, web UI, forum intelligence.",
"If you are looking for architecture here, you will find it: separated worlds, dedicated workspaces, a single bus. What does not belong here: casino logic, industrial logic, personal stuff.",
].join("\n\n"),
},
{
slug: "casino-bot",
number: "02",
status: "Aktiv · Live-RTP",
category: "Bot · Casino",
stack: "Node.js · ESM · patchright · Express · better-sqlite3",
featured: 1,
sort_index: 20,
summary_de:
"Live-RTP-Analyse für BC.Game (Hacksaw). Liest Slot-Statistiken, bewertet Slots, spielt nach Regeln.",
summary_en:
"Live-RTP analysis for BC.Game (Hacksaw). Reads slot statistics, scores slots, plays by rules.",
stance_de:
"Mechanik verstehen, nicht gewinnen. Das System ist ehrlich über das, was es nicht weiß.",
stance_en:
"Understand the mechanics, not beat them. The system is honest about what it does not know.",
body_de: [
"Vollautomatischer Slot-Bot für Hacksaw Gaming über BC.Game. Analysiert Live-RTP, wählt statistisch günstige Slots, spielt mit konfigurierbarem Bankroll-Management und lernt aus jeder Session.",
"Was hier nicht passiert: Live-Resultate, Quoten-Versprechen, Affiliate-Links. Wer das System ernst nimmt, schaut in die Docs, nicht auf einen ROI-Screenshot.",
].join("\n\n"),
body_en: [
"Fully automated slot bot for Hacksaw Gaming on BC.Game. Analyses live RTP, picks statistically favourable slots, plays with configurable bankroll management and learns from every session.",
"What does not happen here: live results, odds promises, affiliate links. If you take the system seriously, read the docs — do not look at an ROI screenshot.",
].join("\n\n"),
},
{
slug: "polymarket-trader",
number: "03",
status: "Aktiv · Paper-Trading",
category: "Bot · Prediction-Markets",
stack: "Python · polymarket-cli · LLM-Thesis · Kelly-Sizing",
featured: 1,
sort_index: 30,
summary_de:
"Prediction-Market-Trader für Polymarket. Scanner, LLM-Thesis, Kelly-Sizing. Live-Orders bleiben fail-closed.",
summary_en:
"Prediction-market trader for Polymarket. Scanner, LLM thesis, Kelly sizing. Live orders remain fail-closed.",
stance_de:
"Paper-Trade zuerst, immer. Live-Order-Pfad ist hart verriegelt, bis die Simulation sauber ist.",
stance_en:
"Paper trade first, always. The live-order path is hard-locked until the simulation is clean.",
body_de: [
"Inspiriert vom LunarResearcher-Artikel. Modulare Architektur: Scanner, Brain, Executor, Exit, Data.",
"Live-Orders sind standardmäßig verriegelt. Der CLI-Einstieg verweigert Live-Trades fail-closed, bis die Paper-Run über genug Tage sauber gelaufen ist.",
].join("\n\n"),
body_en: [
"Inspired by the LunarResearcher article. Modular architecture: scanner, brain, executor, exit, data.",
"Live orders are locked by default. The CLI entry refuses live trades fail-closed until the paper run has been clean for enough days.",
].join("\n\n"),
},
{
slug: "quantmuse",
number: "04",
status: "Aktiv · Development",
category: "Quant · Trading",
stack: "Python · Pandas · NumPy · Backtesting · AI-Analyse",
href: "https://gitea.free-warez.win/eldov/QuantMuse",
featured: 0,
sort_index: 40,
summary_de:
"Quant Trading System mit AI-Analyse, Realtime-Daten und Risikomanagement. Code, kein Screenshot-Versprechen.",
summary_en:
"Quant trading system with AI analysis, real-time data and risk management. Code, not screenshot promises.",
stance_de:
"Backtest zuerst. Keine öffentliche Performance, nur Methodik.",
stance_en:
"Backtest first. No public performance, only methodology.",
body_de: [
"QuantMuse ist die größere Quant-Werkbank: Signale, Strategien, Backtests, Risk.",
"Was hier nicht passiert: P&L-Screenshots, »Strategy of the Month«, Affiliate-Broker.",
].join("\n\n"),
body_en: [
"QuantMuse is the bigger quant workshop: signals, strategies, backtests, risk.",
"What does not happen here: P&L screenshots, »strategy of the month«, affiliate brokers.",
].join("\n\n"),
},
{
slug: "crypto-bot",
number: "05",
status: "Early Stage",
category: "Bot · Crypto",
stack: "TypeScript · EVM · DEX-Wrapper · On-Chain-Analyse",
featured: 0,
sort_index: 50,
summary_de:
"Crypto-Tracking, DeFi-Automatisierung und On-Chain-Analyse. Ethereum, L2s, Polygon.",
summary_en:
"Crypto tracking, DeFi automation and on-chain analysis. Ethereum, L2s, Polygon.",
stance_de:
"Werkzeuge statt Empfehlungen. Wer es benutzt, soll es selbst entscheiden.",
stance_en:
"Tools, not recommendations. Whoever uses it decides for themselves.",
body_de: [
"Domäne: Ethereum, Arbitrum, Optimism, Polygon. DeFi: Uniswap, Sushi, Yield-Farming, Liquidity-Pools. On-Chain: Wallet-Analyse, Token-Swaps, Gas-Optimierung.",
"Status: Early stage — Projektstart ausstehend.",
].join("\n\n"),
body_en: [
"Domain: Ethereum, Arbitrum, Optimism, Polygon. DeFi: Uniswap, Sushi, yield farming, liquidity pools. On-chain: wallet analysis, token swaps, gas optimisation.",
"Status: early stage — project kickoff pending.",
].join("\n\n"),
},
{
slug: "trade-bot",
number: "06",
status: "Early Stage",
category: "Bot · Multi-Asset-Trading",
stack: "Python · Binance · Bybit · dYdX · Paper-Trading",
featured: 0,
sort_index: 60,
summary_de:
"Algorithmischer Trading-Bot für Crypto, Aktien, CFDs. Paper-Trading zuerst.",
summary_en:
"Algorithmic trading bot for crypto, stocks, CFDs. Paper trading first.",
stance_de:
"Domäne: Spot, Futures, Perps, Forex, Indices. Kein Live-Trading ohne dokumentiertes Risk-Modell.",
stance_en:
"Domain: spot, futures, perps, forex, indices. No live trading without a documented risk model.",
body_de: [
"Multi-Asset-Bot mit Fokus auf sauberes Risk-Modell und Backtest-Disziplin.",
"Anti-Patterns stehen in `HERMES.md`. Eskalationsmatrix ebenso.",
].join("\n\n"),
body_en: [
"Multi-asset bot focused on a clean risk model and backtest discipline.",
"Anti-patterns live in `HERMES.md`. So does the escalation matrix.",
].join("\n\n"),
},
{
slug: "ts6-bot",
number: "07",
status: "Stabilitäts-Hardening",
category: "Bot · Voice-Infra",
stack: "TypeScript · Node.js · TeamSpeak 6 Query · Docker",
featured: 0,
sort_index: 70,
summary_de:
"TeamSpeak-6-Bot für Server-Automatisierung. REST/Polling, SSH Query, Reconnect mit Backoff.",
summary_en:
"TeamSpeak 6 bot for server automation. REST polling, SSH query, reconnect with backoff.",
stance_de:
"TS6 ist beta. Chat-Commands bleiben deaktiviert bis ein Event-Test sauber läuft.",
stance_en:
"TS6 is beta. Chat commands stay disabled until an event test runs cleanly.",
body_de: [
"TS6-Bot mit Server-, Channel- und Client-Daten via REST/Polling. Events via SSH Server Query. `textmessage` ist in TS6 beta10 fehlerhaft (1538) und deshalb nicht freigeschaltet.",
"Reconnect mit Exponential Backoff bei Verbindungsabbrüchen.",
].join("\n\n"),
body_en: [
"TS6 bot with server, channel and client data via REST polling. Events via SSH Server Query. `textmessage` is broken in TS6 beta10 (1538) and therefore disabled.",
"Reconnect with exponential backoff on connection drops.",
].join("\n\n"),
},
{
slug: "freewarez-landingpage",
number: "08",
status: "Live",
category: "Web · Landing",
stack: "HTML5 · Glassmorphism CSS",
href: "https://free-warez.top",
featured: 0,
sort_index: 80,
summary_de:
"Landingpage für die Free-Warez-Selfhosting-Welt. free-warez.top.",
summary_en:
"Landing page for the Free-Warez self-hosting world. free-warez.top.",
stance_de:
"Glas-Ästhetik, keine Tracker, keine Schriften von Dritten.",
stance_en:
"Glass aesthetic, no trackers, no third-party fonts.",
body_de: [
"free-warez.top ist die öffentliche Seite meiner Self-Hosting-Welt. Was nicht beworben wird: VPS-Internas, Passwörter, API-Keys.",
].join("\n\n"),
body_en: [
"free-warez.top is the public face of my self-hosting world. What is not advertised: VPS internals, passwords, API keys.",
].join("\n\n"),
},
{
slug: "minecraft-portal",
number: "09",
status: "Live",
category: "Web · Gaming-Community",
stack: "Next.js · Tailwind · Prisma",
href: "https://minecraft.free-warez.win",
featured: 0,
sort_index: 90,
summary_de:
"Minecraft-Portal (Ashen Atlas) für unsere Community. Next.js mit eigener Datenbank.",
summary_en:
"Minecraft portal (Ashen Atlas) for our community. Next.js with its own database.",
stance_de:
"Spielwiese für ein produktives Web-Setup mit echten Nutzern.",
stance_en:
"Playground for a productive web setup with real users.",
body_de: [
"minecraft.free-warez.win ist das Community-Portal. Wer dort spielt, kennt mein Stack-Setup, ohne dass ich es erklären muss.",
].join("\n\n"),
body_en: [
"minecraft.free-warez.win is the community portal. Whoever plays there knows my stack setup without me having to explain it.",
].join("\n\n"),
},
{
slug: "oss-themes",
number: "10",
status: "Live · Sammlung",
category: "Web · Theming",
stack: "CSS · SCSS · XML · Blade",
featured: 0,
sort_index: 100,
summary_de:
"Themes für Open-Source-Selfhosting: Jellyfin, Gitea, Discourse, Kavita, Authelia, Uptime-Kuma, Erugo, LAMP-Link, Stirling-PDF, Element.",
summary_en:
"Themes for open-source self-hosting: Jellyfin, Gitea, Discourse, Kavita, Authelia, Uptime Kuma, Erugo, LAMP Link, Stirling PDF, Element.",
stance_de:
"Jedes Theme hat ein eigenes Repo und ein eigenes Deploy-Skript. Kein Mega-Theme-Monolith.",
stance_en:
"Each theme has its own repo and its own deploy script. No mega-theme monolith.",
body_de: [
"Sammlung von Themes, die meinen Self-Hosting-Stack lesbar machen. Glas, Dunkelheit, Akzent — je nach Produkt.",
].join("\n\n"),
body_en: [
"A collection of themes that make my self-hosting stack readable. Glass, darkness, accent — depending on the product.",
].join("\n\n"),
},
{
slug: "ai-subscription-manager",
number: "11",
status: "Live",
category: "Web · AI-Ops",
stack: "Python · FastAPI · Jinja2 · WebUI",
href: "https://ai.free-warez.win",
featured: 0,
sort_index: 110,
summary_de:
"Verwaltet AI-Subscriptions und Provider-Routing. Welche Modelle, welche Keys, welche Kosten.",
summary_en:
"Manages AI subscriptions and provider routing. Which models, which keys, which costs.",
stance_de:
"Internes Werkzeug, das nach außen sichtbar sein darf, weil es keine fremden Daten enthält.",
stance_en:
"Internal tool that is allowed to be visible because it does not contain third-party data.",
body_de: [
"ai-subscription-manager ist die Schaltzentrale für AI-Provider, Keys und Budgets.",
].join("\n\n"),
body_en: [
"ai-subscription-manager is the control centre for AI providers, keys and budgets.",
].join("\n\n"),
},
{
slug: "lamp-link-themes",
number: "12",
status: "Live",
category: "Web · Link-in-Bio",
stack: "PHP · the23 Theme Engine · CSS-Vars",
href: "https://link.free-warez.win",
featured: 0,
sort_index: 120,
summary_de:
"Theme für LAMP-Link-Shortener und Bio-Pages. the23 Theme Engine.",
summary_en:
"Theme for LAMP link shortener and bio pages. the23 theme engine.",
stance_de:
"Klein, schnell, gut lesbar auf dem Phone.",
stance_en:
"Small, fast, readable on the phone.",
body_de: [
"LAMP-Link-Themes bedienen link.free-warez.win und andere Bio-Pages. Fokus: Phone-First.",
].join("\n\n"),
body_en: [
"LAMP Link themes serve link.free-warez.win and other bio pages. Focus: phone first.",
].join("\n\n"),
},
{
slug: "android",
number: "13",
status: "Aktiv",
category: "Mobile · Desktop",
stack: "Android · Affine · Kotlin · Compose",
featured: 0,
sort_index: 130,
summary_de:
"Android-Apps und Affine-basierte Desktop-Projekte.",
summary_en:
"Android apps and Affine-based desktop projects.",
stance_de:
"Mobile First, auch wenn der Use-Case es nicht erzwingt.",
stance_en:
"Mobile first, even when the use case does not demand it.",
body_de: [
"Coding/Android enthält Android-Projekte und Affine-Desktop-Apps.",
].join("\n\n"),
body_en: [
"Coding/Android contains Android projects and Affine desktop apps.",
].join("\n\n"),
},
];
export function seedProfile(db: Database.Database): boolean {
const existing = db.prepare("SELECT id FROM profile WHERE id = 1").get();
if (existing) return false;
db.prepare(
`INSERT INTO profile
(id, handle, real_name, role, location, bio_de, bio_en, facts_de, facts_en, links_json)
VALUES (1, @handle, @real_name, @role, @location, @bio_de, @bio_en, @facts_de, @facts_en, @links_json)`
).run({
handle: PROFILE_DEFAULTS.handle,
real_name: PROFILE_DEFAULTS.real_name,
role: PROFILE_DEFAULTS.role,
location: PROFILE_DEFAULTS.location,
bio_de: PROFILE_DEFAULTS.bio_de,
bio_en: PROFILE_DEFAULTS.bio_en,
facts_de: JSON.stringify(PROFILE_DEFAULTS.facts_de),
facts_en: JSON.stringify(PROFILE_DEFAULTS.facts_en),
links_json: PROFILE_DEFAULTS.links_json,
});
return true;
}
export function seedProjects(db: Database.Database): number {
const count = (db.prepare("SELECT COUNT(*) as c FROM projects").get() as { c: number }).c;
if (count > 0) return 0;
const stmt = db.prepare(
`INSERT INTO projects
(slug, number, status, category, stack, href, featured,
summary_de, summary_en, body_de, body_en, stance_de, stance_en, sort_index)
VALUES
(@slug, @number, @status, @category, @stack, @href, @featured,
@summary_de, @summary_en, @body_de, @body_en, @stance_de, @stance_en, @sort_index)`
);
const insertMany = db.transaction((rows: ProjectSeed[]) => {
for (const r of rows) {
stmt.run({
slug: r.slug,
number: r.number,
status: r.status,
category: r.category,
stack: r.stack,
href: r.href ?? null,
featured: r.featured,
summary_de: r.summary_de,
summary_en: r.summary_en,
body_de: r.body_de,
body_en: r.body_en,
stance_de: r.stance_de ?? "",
stance_en: r.stance_en ?? "",
sort_index: r.sort_index,
});
}
});
insertMany(PROJECTS);
return PROJECTS.length;
}
export function seedIfEmpty(db: Database.Database): { profileSeeded: boolean; projectsSeeded: number } {
const profileSeeded = seedProfile(db);
const projectsSeeded = seedProjects(db);
return { profileSeeded, projectsSeeded };
}
+43
View File
@@ -0,0 +1,43 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"allowImportingTsExtensions": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}