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