Compare commits
13
Commits
1f6894d781
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
820a2f8241 | ||
|
|
78b857a80c | ||
|
|
97944875ca | ||
|
|
1f37edfa46 | ||
|
|
4717404cc3 | ||
|
|
7228fee77a | ||
|
|
c822a110ce | ||
|
|
042ea88d94 | ||
|
|
7e8313c96e | ||
|
|
0482b780f4 | ||
|
|
aa55c29dbf | ||
|
|
b52f93989c | ||
|
|
5657b1d202 |
@@ -0,0 +1,111 @@
|
|||||||
|
2026-08-16T00:09:06.563114Z INFO 591: 🔧 Loading CodeGraph configuration...
|
||||||
|
2026-08-16T00:09:06.563603Z INFO 678: 📋 No config file found, using defaults
|
||||||
|
2026-08-16T00:09:06.563618Z INFO 605: ✅ Configuration loaded successfully
|
||||||
|
2026-08-16T00:09:06.563620Z INFO 609: 📄 Config file: NONE (using defaults)
|
||||||
|
2026-08-16T00:09:06.563622Z INFO 611: 🤖 Embedding provider: auto
|
||||||
|
2026-08-16T00:09:06.563624Z INFO 612: 🔧 Embedding model: None
|
||||||
|
2026-08-16T00:09:06.563630Z INFO 613: 📐 Embedding dimension: 2048
|
||||||
|
2026-08-16T00:09:06.563632Z INFO 614: 🌐 Ollama URL: http://localhost:11434
|
||||||
|
2026-08-16T00:09:06.563634Z INFO 615: 💬 LLM insights: disabled (context-only)
|
||||||
|
2026-08-16T00:09:06.594420Z INFO 239: client initialized
|
||||||
|
2026-08-16T00:09:06.595349Z INFO serve_inner: 562: Service initialized as server peer_info=Some(InitializeRequestParam { protocol_version: ProtocolVersion("2025-11-25"), capabilities: ClientCapabilities { experimental: None, roots: None, sampling: None, elicitation: Some(ElicitationCapability { schema_validation: None }) }, client_info: Implementation { name: "Cursor", title: None, version: "1.0.0", icons: None, website_url: None } })
|
||||||
|
2026-08-16T00:09:13.103303Z INFO serve_inner: 618: input stream terminated
|
||||||
|
2026-08-16T00:09:13.103383Z INFO serve_inner: 844: serve finished quit_reason=Closed
|
||||||
|
2026-08-16T00:09:14.547926Z INFO 591: 🔧 Loading CodeGraph configuration...
|
||||||
|
2026-08-16T00:09:14.547973Z INFO 678: 📋 No config file found, using defaults
|
||||||
|
2026-08-16T00:09:14.547989Z INFO 605: ✅ Configuration loaded successfully
|
||||||
|
2026-08-16T00:09:14.547991Z INFO 609: 📄 Config file: NONE (using defaults)
|
||||||
|
2026-08-16T00:09:14.547993Z INFO 611: 🤖 Embedding provider: auto
|
||||||
|
2026-08-16T00:09:14.547995Z INFO 612: 🔧 Embedding model: None
|
||||||
|
2026-08-16T00:09:14.548002Z INFO 613: 📐 Embedding dimension: 2048
|
||||||
|
2026-08-16T00:09:14.548005Z INFO 614: 🌐 Ollama URL: http://localhost:11434
|
||||||
|
2026-08-16T00:09:14.548007Z INFO 615: 💬 LLM insights: disabled (context-only)
|
||||||
|
2026-08-16T00:09:14.564338Z INFO 239: client initialized
|
||||||
|
2026-08-16T00:09:14.564401Z INFO serve_inner: 562: Service initialized as server peer_info=Some(InitializeRequestParam { protocol_version: ProtocolVersion("2025-11-25"), capabilities: ClientCapabilities { experimental: None, roots: None, sampling: None, elicitation: Some(ElicitationCapability { schema_validation: None }) }, client_info: Implementation { name: "Cursor", title: None, version: "1.0.0", icons: None, website_url: None } })
|
||||||
|
2026-08-16T00:11:08.839023Z INFO serve_inner: 591: 🔧 Loading CodeGraph configuration...
|
||||||
|
2026-08-16T00:11:08.839058Z INFO serve_inner: 678: 📋 No config file found, using defaults
|
||||||
|
2026-08-16T00:11:08.839075Z INFO serve_inner: 605: ✅ Configuration loaded successfully
|
||||||
|
2026-08-16T00:11:08.839078Z INFO serve_inner: 609: 📄 Config file: NONE (using defaults)
|
||||||
|
2026-08-16T00:11:08.839081Z INFO serve_inner: 611: 🤖 Embedding provider: auto
|
||||||
|
2026-08-16T00:11:08.839084Z INFO serve_inner: 612: 🔧 Embedding model: None
|
||||||
|
2026-08-16T00:11:08.839088Z INFO serve_inner: 613: 📐 Embedding dimension: 2048
|
||||||
|
2026-08-16T00:11:08.839090Z INFO serve_inner: 614: 🌐 Ollama URL: http://localhost:11434
|
||||||
|
2026-08-16T00:11:08.839093Z INFO serve_inner: 615: 💬 LLM insights: disabled (context-only)
|
||||||
|
2026-08-16T00:11:08.839106Z INFO serve_inner: 648: AutoAgents semantic_question (tier=Small)
|
||||||
|
2026-08-16T00:11:08.839120Z INFO serve_inner: 591: 🔧 Loading CodeGraph configuration...
|
||||||
|
2026-08-16T00:11:08.839130Z INFO serve_inner: 678: 📋 No config file found, using defaults
|
||||||
|
2026-08-16T00:11:08.839138Z INFO serve_inner: 605: ✅ Configuration loaded successfully
|
||||||
|
2026-08-16T00:11:08.839141Z INFO serve_inner: 609: 📄 Config file: NONE (using defaults)
|
||||||
|
2026-08-16T00:11:08.839144Z INFO serve_inner: 611: 🤖 Embedding provider: auto
|
||||||
|
2026-08-16T00:11:08.839146Z INFO serve_inner: 612: 🔧 Embedding model: None
|
||||||
|
2026-08-16T00:11:08.839149Z INFO serve_inner: 613: 📐 Embedding dimension: 2048
|
||||||
|
2026-08-16T00:11:08.839151Z INFO serve_inner: 614: 🌐 Ollama URL: http://localhost:11434
|
||||||
|
2026-08-16T00:11:08.839172Z INFO serve_inner: 615: 💬 LLM insights: disabled (context-only)
|
||||||
|
2026-08-16T00:11:08.839218Z WARN serve_inner: 773: response error id=3 error=ErrorData { code: ErrorCode(-32603), message: "Failed to create LLM provider: LLM is not enabled in configuration", data: None }
|
||||||
|
2026-08-16T00:11:10.909914Z INFO serve_inner: 591: 🔧 Loading CodeGraph configuration...
|
||||||
|
2026-08-16T00:11:10.909956Z INFO serve_inner: 678: 📋 No config file found, using defaults
|
||||||
|
2026-08-16T00:11:10.909980Z INFO serve_inner: 605: ✅ Configuration loaded successfully
|
||||||
|
2026-08-16T00:11:10.909985Z INFO serve_inner: 609: 📄 Config file: NONE (using defaults)
|
||||||
|
2026-08-16T00:11:10.909988Z INFO serve_inner: 611: 🤖 Embedding provider: auto
|
||||||
|
2026-08-16T00:11:10.909993Z INFO serve_inner: 612: 🔧 Embedding model: None
|
||||||
|
2026-08-16T00:11:10.909997Z INFO serve_inner: 613: 📐 Embedding dimension: 2048
|
||||||
|
2026-08-16T00:11:10.910002Z INFO serve_inner: 614: 🌐 Ollama URL: http://localhost:11434
|
||||||
|
2026-08-16T00:11:10.910006Z INFO serve_inner: 615: 💬 LLM insights: disabled (context-only)
|
||||||
|
2026-08-16T00:11:10.910012Z INFO serve_inner: 648: AutoAgents architecture_analysis (tier=Small)
|
||||||
|
2026-08-16T00:11:10.910021Z INFO serve_inner: 591: 🔧 Loading CodeGraph configuration...
|
||||||
|
2026-08-16T00:11:10.910034Z INFO serve_inner: 678: 📋 No config file found, using defaults
|
||||||
|
2026-08-16T00:11:10.910047Z INFO serve_inner: 605: ✅ Configuration loaded successfully
|
||||||
|
2026-08-16T00:11:10.910051Z INFO serve_inner: 609: 📄 Config file: NONE (using defaults)
|
||||||
|
2026-08-16T00:11:10.910055Z INFO serve_inner: 611: 🤖 Embedding provider: auto
|
||||||
|
2026-08-16T00:11:10.910059Z INFO serve_inner: 612: 🔧 Embedding model: None
|
||||||
|
2026-08-16T00:11:10.910062Z INFO serve_inner: 613: 📐 Embedding dimension: 2048
|
||||||
|
2026-08-16T00:11:10.910066Z INFO serve_inner: 614: 🌐 Ollama URL: http://localhost:11434
|
||||||
|
2026-08-16T00:11:10.910070Z INFO serve_inner: 615: 💬 LLM insights: disabled (context-only)
|
||||||
|
2026-08-16T00:11:10.910083Z WARN serve_inner: 773: response error id=4 error=ErrorData { code: ErrorCode(-32603), message: "Failed to create LLM provider: LLM is not enabled in configuration", data: None }
|
||||||
|
2026-08-16T00:17:24.425995Z INFO 591: 🔧 Loading CodeGraph configuration...
|
||||||
|
2026-08-16T00:17:24.426033Z INFO 678: 📋 No config file found, using defaults
|
||||||
|
2026-08-16T00:17:24.426052Z INFO 605: ✅ Configuration loaded successfully
|
||||||
|
2026-08-16T00:17:24.426054Z INFO 609: 📄 Config file: NONE (using defaults)
|
||||||
|
2026-08-16T00:17:24.426056Z INFO 611: 🤖 Embedding provider: auto
|
||||||
|
2026-08-16T00:17:24.426059Z INFO 612: 🔧 Embedding model: None
|
||||||
|
2026-08-16T00:17:24.426064Z INFO 613: 📐 Embedding dimension: 2048
|
||||||
|
2026-08-16T00:17:24.426067Z INFO 614: 🌐 Ollama URL: http://localhost:11434
|
||||||
|
2026-08-16T00:17:24.426070Z INFO 615: 💬 LLM insights: disabled (context-only)
|
||||||
|
2026-08-16T00:18:55.684496Z INFO 591: 🔧 Loading CodeGraph configuration...
|
||||||
|
2026-08-16T00:18:55.684539Z INFO 678: 📋 No config file found, using defaults
|
||||||
|
2026-08-16T00:18:55.684560Z INFO 605: ✅ Configuration loaded successfully
|
||||||
|
2026-08-16T00:18:55.684563Z INFO 609: 📄 Config file: NONE (using defaults)
|
||||||
|
2026-08-16T00:18:55.684565Z INFO 611: 🤖 Embedding provider: auto
|
||||||
|
2026-08-16T00:18:55.684568Z INFO 612: 🔧 Embedding model: None
|
||||||
|
2026-08-16T00:18:55.684575Z INFO 613: 📐 Embedding dimension: 2048
|
||||||
|
2026-08-16T00:18:55.684578Z INFO 614: 🌐 Ollama URL: http://localhost:11434
|
||||||
|
2026-08-16T00:18:55.684581Z INFO 615: 💬 LLM insights: disabled (context-only)
|
||||||
|
2026-08-16T00:19:03.280146Z INFO 591: 🔧 Loading CodeGraph configuration...
|
||||||
|
2026-08-16T00:19:03.280209Z INFO 678: 📋 No config file found, using defaults
|
||||||
|
2026-08-16T00:19:03.280227Z INFO 605: ✅ Configuration loaded successfully
|
||||||
|
2026-08-16T00:19:03.280230Z INFO 609: 📄 Config file: NONE (using defaults)
|
||||||
|
2026-08-16T00:19:03.280232Z INFO 611: 🤖 Embedding provider: auto
|
||||||
|
2026-08-16T00:19:03.280235Z INFO 612: 🔧 Embedding model: None
|
||||||
|
2026-08-16T00:19:03.280240Z INFO 613: 📐 Embedding dimension: 2048
|
||||||
|
2026-08-16T00:19:03.280242Z INFO 614: 🌐 Ollama URL: http://localhost:11434
|
||||||
|
2026-08-16T00:19:03.280244Z INFO 615: 💬 LLM insights: disabled (context-only)
|
||||||
|
2026-08-16T09:14:56.294149Z INFO serve_inner: 591: 🔧 Loading CodeGraph configuration...
|
||||||
|
2026-08-16T09:14:56.295692Z INFO serve_inner: 678: 📋 No config file found, using defaults
|
||||||
|
2026-08-16T09:14:56.295896Z INFO serve_inner: 605: ✅ Configuration loaded successfully
|
||||||
|
2026-08-16T09:14:56.295900Z INFO serve_inner: 609: 📄 Config file: NONE (using defaults)
|
||||||
|
2026-08-16T09:14:56.295902Z INFO serve_inner: 611: 🤖 Embedding provider: auto
|
||||||
|
2026-08-16T09:14:56.295905Z INFO serve_inner: 612: 🔧 Embedding model: None
|
||||||
|
2026-08-16T09:14:56.295919Z INFO serve_inner: 613: 📐 Embedding dimension: 2048
|
||||||
|
2026-08-16T09:14:56.295921Z INFO serve_inner: 614: 🌐 Ollama URL: http://localhost:11434
|
||||||
|
2026-08-16T09:14:56.295924Z INFO serve_inner: 615: 💬 LLM insights: disabled (context-only)
|
||||||
|
2026-08-16T09:14:56.296120Z INFO serve_inner: 648: AutoAgents semantic_question (tier=Small)
|
||||||
|
2026-08-16T09:14:56.296706Z INFO serve_inner: 591: 🔧 Loading CodeGraph configuration...
|
||||||
|
2026-08-16T09:14:56.296732Z INFO serve_inner: 678: 📋 No config file found, using defaults
|
||||||
|
2026-08-16T09:14:56.296746Z INFO serve_inner: 605: ✅ Configuration loaded successfully
|
||||||
|
2026-08-16T09:14:56.296749Z INFO serve_inner: 609: 📄 Config file: NONE (using defaults)
|
||||||
|
2026-08-16T09:14:56.296751Z INFO serve_inner: 611: 🤖 Embedding provider: auto
|
||||||
|
2026-08-16T09:14:56.296754Z INFO serve_inner: 612: 🔧 Embedding model: None
|
||||||
|
2026-08-16T09:14:56.296759Z INFO serve_inner: 613: 📐 Embedding dimension: 2048
|
||||||
|
2026-08-16T09:14:56.296761Z INFO serve_inner: 614: 🌐 Ollama URL: http://localhost:11434
|
||||||
|
2026-08-16T09:14:56.296764Z INFO serve_inner: 615: 💬 LLM insights: disabled (context-only)
|
||||||
|
2026-08-16T09:14:56.297080Z WARN serve_inner: 773: response error id=5 error=ErrorData { code: ErrorCode(-32603), message: "Failed to create LLM provider: LLM is not enabled in configuration", data: None }
|
||||||
@@ -5,3 +5,7 @@ npm-debug.log
|
|||||||
README.md
|
README.md
|
||||||
.next
|
.next
|
||||||
.git
|
.git
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
.codegraph
|
||||||
|
data
|
||||||
|
|||||||
@@ -20,6 +20,10 @@
|
|||||||
# production
|
# production
|
||||||
/build
|
/build
|
||||||
|
|
||||||
|
# local data
|
||||||
|
/data/*.sqlite
|
||||||
|
/data/*.sqlite-*
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
*.pem
|
||||||
|
|||||||
@@ -0,0 +1,566 @@
|
|||||||
|
# W-Make Portfolio CMS - Umsetzungsplan
|
||||||
|
**Erstellt:** 2026-08-22
|
||||||
|
**Ziel:** Admin-Login + Content-Management für Seiten, Links und News
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Anforderungen
|
||||||
|
|
||||||
|
### Must-Have (Phase 1):
|
||||||
|
1. **Admin-Login** - Authelia-basiert (bereits vorhanden auf auth.w-make.com)
|
||||||
|
2. **News/Updates verwalten** - erstellen, bearbeiten, löschen, publizieren
|
||||||
|
3. **Links verwalten** - externe/interne Links zur Homepage hinzufügen
|
||||||
|
4. **Seiten bearbeiten** - existierende Content-Seiten (About, Notes) editieren
|
||||||
|
|
||||||
|
### Nice-to-Have (Phase 2):
|
||||||
|
5. Markdown-Editor mit Preview
|
||||||
|
6. Medien-Upload (Bilder für Notes)
|
||||||
|
7. Multi-Sprache (DE/EN synchron editieren)
|
||||||
|
8. Version History / Drafts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ Architektur-Entscheidungen
|
||||||
|
|
||||||
|
### ✅ Was bereits existiert:
|
||||||
|
|
||||||
|
**Infrastructure:**
|
||||||
|
- ✅ Authelia SSO auf `auth.w-make.com` (2FA-Login)
|
||||||
|
- ✅ Traefik Reverse Proxy (schützt `/admin` Routes)
|
||||||
|
- ✅ `updates-api` Service (Node.js + SQLite)
|
||||||
|
- Admin-Panel unter `https://updates.w-make.com/admin`
|
||||||
|
- Bearer-Token Auth für API
|
||||||
|
- SQLite-Datenbank `/data/updates.sqlite`
|
||||||
|
|
||||||
|
**Current Content Structure:**
|
||||||
|
```typescript
|
||||||
|
// Aktuell: TypeScript-Dateien (kompiliert in Bundle)
|
||||||
|
src/content/notes.ts // Blog-Posts
|
||||||
|
src/content/projects.ts // Case Studies
|
||||||
|
src/content/offers.ts // Services
|
||||||
|
src/content/person.ts // About-Content
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem:** Content ist im Code → Deployment nötig bei Änderungen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 Lösungsansatz: Hybrid CMS
|
||||||
|
|
||||||
|
### Strategie: "Code für Struktur, DB für Content"
|
||||||
|
|
||||||
|
**Prinzip:**
|
||||||
|
- Struktur (TypeScript-Types, Validierung) bleibt in Code
|
||||||
|
- Dynamischer Content (News, Updates, optionale Seiten) in SQLite
|
||||||
|
- Statische Inhalte (person.ts, offers.ts) bleiben vorerst im Code
|
||||||
|
|
||||||
|
**Warum Hybrid?**
|
||||||
|
- ✅ Schnelle Time-to-Market (updates-api erweitern statt neu bauen)
|
||||||
|
- ✅ Keine komplexe CMS-Migration
|
||||||
|
- ✅ SQLite = einfaches Backup, kein Postgres nötig
|
||||||
|
- ✅ Authelia = Production-ready SSO, keine eigene User-DB
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Umsetzungsplan: 3 Phasen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## **Phase 1: Admin-Panel erweitern (5-7 Tage)**
|
||||||
|
|
||||||
|
### 1.1 Updates-API erweitern für Content-Management
|
||||||
|
|
||||||
|
**Neue Endpoints:**
|
||||||
|
```typescript
|
||||||
|
// News (bereits vorhanden, nur erweitern)
|
||||||
|
POST /v1/admin/updates // News erstellen
|
||||||
|
PUT /v1/admin/updates/:id // News bearbeiten
|
||||||
|
DELETE /v1/admin/updates/:id // News löschen
|
||||||
|
POST /v1/admin/updates/:id/publish // News publizieren
|
||||||
|
|
||||||
|
// Links (neu)
|
||||||
|
GET /v1/links // Öffentlich: alle Links
|
||||||
|
POST /v1/admin/links // Link erstellen
|
||||||
|
PUT /v1/admin/links/:id // Link bearbeiten
|
||||||
|
DELETE /v1/admin/links/:id // Link löschen
|
||||||
|
|
||||||
|
// Pages (neu, optional)
|
||||||
|
GET /v1/pages/:slug // Öffentlich: dynamische Seiten
|
||||||
|
POST /v1/admin/pages // Seite erstellen
|
||||||
|
PUT /v1/admin/pages/:slug // Seite bearbeiten
|
||||||
|
DELETE /v1/admin/pages/:slug // Seite löschen
|
||||||
|
```
|
||||||
|
|
||||||
|
**Datenbank-Schema (SQLite):**
|
||||||
|
```sql
|
||||||
|
-- Bereits vorhanden
|
||||||
|
CREATE TABLE updates (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
product TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
summary TEXT,
|
||||||
|
slug TEXT UNIQUE,
|
||||||
|
published_at TEXT,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Neu hinzufügen
|
||||||
|
CREATE TABLE links (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
category TEXT, -- 'external' | 'internal' | 'resource'
|
||||||
|
description TEXT,
|
||||||
|
icon TEXT, -- optional: FontAwesome-Icon
|
||||||
|
sort_order INTEGER DEFAULT 0,
|
||||||
|
visible BOOLEAN DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE pages (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
slug TEXT UNIQUE NOT NULL,
|
||||||
|
title_de TEXT NOT NULL,
|
||||||
|
title_en TEXT NOT NULL,
|
||||||
|
content_de TEXT,
|
||||||
|
content_en TEXT,
|
||||||
|
meta_description_de TEXT,
|
||||||
|
meta_description_en TEXT,
|
||||||
|
published BOOLEAN DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Implementierung:**
|
||||||
|
```bash
|
||||||
|
services/updates-api/src/
|
||||||
|
routes/
|
||||||
|
admin/
|
||||||
|
updates.js # bereits vorhanden
|
||||||
|
links.js # NEU
|
||||||
|
pages.js # NEU
|
||||||
|
db/
|
||||||
|
schema.sql # Migration hinzufügen
|
||||||
|
migrations/
|
||||||
|
001-add-links.sql
|
||||||
|
002-add-pages.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.2 Admin-Frontend erweitern
|
||||||
|
|
||||||
|
**Aktuell:** `https://updates.w-make.com/admin` (vermutlich einfaches HTML/JS)
|
||||||
|
|
||||||
|
**Erweitern:**
|
||||||
|
```
|
||||||
|
services/updates-api/src/admin/
|
||||||
|
index.html # Dashboard
|
||||||
|
updates.html # News verwalten
|
||||||
|
links.html # NEU: Links verwalten
|
||||||
|
pages.html # NEU: Seiten verwalten
|
||||||
|
css/
|
||||||
|
admin.css
|
||||||
|
js/
|
||||||
|
api.js # Fetch-Wrapper mit Bearer-Token
|
||||||
|
updates.js
|
||||||
|
links.js # NEU
|
||||||
|
pages.js # NEU
|
||||||
|
```
|
||||||
|
|
||||||
|
**UI-Components (Plain HTML + Alpine.js oder Vanilla JS):**
|
||||||
|
```html
|
||||||
|
<!-- Beispiel: Links verwalten -->
|
||||||
|
<div class="admin-panel">
|
||||||
|
<h1>Links verwalten</h1>
|
||||||
|
|
||||||
|
<button @click="createLink()">Neuer Link</button>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Titel</th>
|
||||||
|
<th>URL</th>
|
||||||
|
<th>Kategorie</th>
|
||||||
|
<th>Sichtbar</th>
|
||||||
|
<th>Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="link in links">
|
||||||
|
<td>{{ link.title }}</td>
|
||||||
|
<td>{{ link.url }}</td>
|
||||||
|
<td>{{ link.category }}</td>
|
||||||
|
<td>{{ link.visible ? '✓' : '✗' }}</td>
|
||||||
|
<td>
|
||||||
|
<button @click="editLink(link.id)">Edit</button>
|
||||||
|
<button @click="deleteLink(link.id)">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Styling:** Design-System von w-make.com wiederverwenden (Copper/Ink/Paper Tokens)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.3 Frontend (Next.js) integrieren
|
||||||
|
|
||||||
|
**Homepage: Links-Section hinzufügen**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// src/app/[lang]/page.tsx
|
||||||
|
export default async function HomePage({ params }: PageProps<"/[lang]">) {
|
||||||
|
// ...
|
||||||
|
const links = await fetchLinks(); // NEU: von updates-api
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
{/* ... existierende Sections ... */}
|
||||||
|
|
||||||
|
{/* NEU: Links-Section */}
|
||||||
|
<section className="border-t border-line">
|
||||||
|
<div className="mx-auto w-full max-w-6xl px-6 py-20 md:px-10">
|
||||||
|
<p className="kicker">Nützliche Links</p>
|
||||||
|
<h2 className="display mt-4 text-4xl md:text-5xl">
|
||||||
|
Ressourcen und Werkzeuge
|
||||||
|
</h2>
|
||||||
|
<div className="mt-12 grid gap-8 md:grid-cols-3">
|
||||||
|
{links.map((link) => (
|
||||||
|
<a
|
||||||
|
key={link.id}
|
||||||
|
href={link.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="border-l border-line-strong pl-5 hover:border-copper"
|
||||||
|
>
|
||||||
|
<h3 className="font-serif text-xl">{link.title}</h3>
|
||||||
|
<p className="mt-2 text-sm text-mute-strong">
|
||||||
|
{link.description}
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**API-Client hinzufügen:**
|
||||||
|
```typescript
|
||||||
|
// src/lib/api-client.ts
|
||||||
|
const UPDATES_API_URL = process.env.UPDATES_API_URL ||
|
||||||
|
'http://localhost:8080';
|
||||||
|
|
||||||
|
export async function fetchLinks() {
|
||||||
|
const res = await fetch(`${UPDATES_API_URL}/v1/links`, {
|
||||||
|
next: { revalidate: 60 } // ISR: Cache 60s
|
||||||
|
});
|
||||||
|
if (!res.ok) return [];
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchUpdates(limit = 5) {
|
||||||
|
const res = await fetch(
|
||||||
|
`${UPDATES_API_URL}/v1/updates?product=batchmaker&limit=${limit}`,
|
||||||
|
{ next: { revalidate: 300 } }
|
||||||
|
);
|
||||||
|
if (!res.ok) return [];
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.4 Authelia-Integration absichern
|
||||||
|
|
||||||
|
**Traefik-Config (auf VPS prüfen):**
|
||||||
|
```yaml
|
||||||
|
# Bereits vorhanden in /opt/traefik/config/
|
||||||
|
http:
|
||||||
|
routers:
|
||||||
|
updates-admin:
|
||||||
|
rule: "Host(`updates.w-make.com`) && PathPrefix(`/admin`)"
|
||||||
|
middlewares:
|
||||||
|
- authelia@docker # Redirect zu auth.w-make.com
|
||||||
|
service: updates-api
|
||||||
|
```
|
||||||
|
|
||||||
|
**Admin-Panel: Session-Check**
|
||||||
|
```javascript
|
||||||
|
// services/updates-api/src/admin/js/auth.js
|
||||||
|
async function checkAuth() {
|
||||||
|
const token = localStorage.getItem('admin_token');
|
||||||
|
if (!token) {
|
||||||
|
// Authelia hat Remote-User-Header gesetzt
|
||||||
|
// Oder redirect zu /login
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bei API-Calls
|
||||||
|
fetch('/v1/admin/updates', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## **Phase 2: Markdown-Editor + Media-Upload (3-5 Tage)**
|
||||||
|
|
||||||
|
### 2.1 Rich-Text-Editor einbauen
|
||||||
|
|
||||||
|
**Library-Auswahl:**
|
||||||
|
- **TipTap** (modern, headless, gute TypeScript-Unterstützung)
|
||||||
|
- **SimpleMDE** (leichtgewichtig, Markdown-fokussiert)
|
||||||
|
- **Quill** (etabliert, aber schwerer)
|
||||||
|
|
||||||
|
**Empfehlung:** SimpleMDE für Notes/Pages
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- services/updates-api/src/admin/pages.html -->
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.css">
|
||||||
|
<script src="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.js"></script>
|
||||||
|
|
||||||
|
<textarea id="content-de"></textarea>
|
||||||
|
<textarea id="content-en"></textarea>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const editorDE = new SimpleMDE({ element: document.getElementById("content-de") });
|
||||||
|
const editorEN = new SimpleMDE({ element: document.getElementById("content-en") });
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.2 Bild-Upload
|
||||||
|
|
||||||
|
**Backend (updates-api erweitern):**
|
||||||
|
```javascript
|
||||||
|
// services/updates-api/src/routes/admin/media.js
|
||||||
|
import multer from 'multer';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: '/data/uploads/',
|
||||||
|
filename: (req, file, cb) => {
|
||||||
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||||
|
cb(null, file.fieldname + '-' + uniqueSuffix + path.extname(file.originalname));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
storage,
|
||||||
|
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
if (file.mimetype.startsWith('image/')) {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new Error('Only images allowed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/v1/admin/media', upload.single('image'), (req, res) => {
|
||||||
|
res.json({
|
||||||
|
url: `/uploads/${req.file.filename}`,
|
||||||
|
filename: req.file.filename
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Frontend: Drag & Drop Upload**
|
||||||
|
```html
|
||||||
|
<div class="dropzone" id="image-upload">
|
||||||
|
Bild hierher ziehen oder klicken
|
||||||
|
<input type="file" accept="image/*" style="display:none">
|
||||||
|
</div>
|
||||||
|
<img id="preview" style="max-width: 400px">
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## **Phase 3: Dynamische Seiten + Notes aus DB (3-4 Tage)**
|
||||||
|
|
||||||
|
### 3.1 Notes von TypeScript → SQLite migrieren
|
||||||
|
|
||||||
|
**Migration-Script:**
|
||||||
|
```typescript
|
||||||
|
// scripts/migrate-notes-to-db.ts
|
||||||
|
import { notes } from '../src/content/notes';
|
||||||
|
import { db } from '../services/updates-api/src/db';
|
||||||
|
|
||||||
|
for (const [slug, note] of Object.entries(notes)) {
|
||||||
|
db.run(`
|
||||||
|
INSERT INTO pages (slug, title_de, title_en, content_de, content_en, published)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 1)
|
||||||
|
`, [
|
||||||
|
`notizen/${slug}`,
|
||||||
|
note.de.title,
|
||||||
|
note.en.title,
|
||||||
|
note.de.body.join('\n\n'),
|
||||||
|
note.en.body.join('\n\n')
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Frontend: Dynamic Notes Route**
|
||||||
|
```typescript
|
||||||
|
// src/app/[lang]/notizen/[slug]/page.tsx
|
||||||
|
export default async function NotePage({ params }: PageProps) {
|
||||||
|
const locale = requireLocale((await params).lang);
|
||||||
|
const slug = (await params).slug;
|
||||||
|
|
||||||
|
// Zuerst DB prüfen
|
||||||
|
const dbNote = await fetchPageBySlug(`notizen/${slug}`);
|
||||||
|
if (dbNote) {
|
||||||
|
return <article>{/* Render DB-Content */}</article>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: statische notes.ts
|
||||||
|
const staticNote = getNote(slug as NoteSlug, locale);
|
||||||
|
return <article>{/* Render static content */}</article>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 Neue dynamische Seiten
|
||||||
|
|
||||||
|
**Use-Case:** Kundenwunsch "Changelog-Seite" oder "FAQ" hinzufügen ohne Deployment
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/app/[lang]/[slug]/page.tsx (Catch-All Dynamic Route)
|
||||||
|
export default async function DynamicPage({ params }: PageProps) {
|
||||||
|
const locale = requireLocale((await params).lang);
|
||||||
|
const slug = (await params).slug;
|
||||||
|
|
||||||
|
const page = await fetchPageBySlug(slug);
|
||||||
|
|
||||||
|
if (!page || !page.published) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = locale === 'de' ? page.content_de : page.content_en;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<article className="prose prose-invert mx-auto max-w-4xl px-6 py-20">
|
||||||
|
<h1>{page[`title_${locale}`]}</h1>
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: marked(content) }} />
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Statische Pfade für Build-Zeit
|
||||||
|
export async function generateStaticParams() {
|
||||||
|
const pages = await fetchAllPublishedPages();
|
||||||
|
return pages.map(page => ({ slug: page.slug }));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Deployment-Strategie
|
||||||
|
|
||||||
|
### Phase 1 Deployment:
|
||||||
|
```bash
|
||||||
|
# 1. updates-api erweitern
|
||||||
|
cd services/updates-api
|
||||||
|
npm install multer marked
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# 2. Datenbank migrieren
|
||||||
|
sqlite3 /data/updates.sqlite < src/db/migrations/001-add-links.sql
|
||||||
|
|
||||||
|
# 3. Docker neu bauen
|
||||||
|
docker compose build updates-api
|
||||||
|
docker compose up -d updates-api
|
||||||
|
|
||||||
|
# 4. Admin-Panel testen
|
||||||
|
open https://updates.w-make.com/admin
|
||||||
|
```
|
||||||
|
|
||||||
|
### Next.js ISR (Incremental Static Regeneration):
|
||||||
|
```typescript
|
||||||
|
// Automatisches Rebuild bei neuen Inhalten ohne Deployment
|
||||||
|
fetch(url, {
|
||||||
|
next: { revalidate: 60 } // Cache 60s, dann re-fetch
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Aufwandsschätzung
|
||||||
|
|
||||||
|
| Phase | Tasks | Aufwand | Priorität |
|
||||||
|
|-------|-------|---------|-----------|
|
||||||
|
| **Phase 1** | Admin-Panel + Links + API | 5-7 Tage | ⭐⭐⭐ HOCH |
|
||||||
|
| **Phase 2** | Editor + Upload | 3-5 Tage | ⭐⭐ MITTEL |
|
||||||
|
| **Phase 3** | Dynamic Pages + Migration | 3-4 Tage | ⭐ NIEDRIG |
|
||||||
|
| **Testing & Docs** | E2E-Tests, Anleitung | 2-3 Tage | ⭐⭐ MITTEL |
|
||||||
|
|
||||||
|
**Total:** 13-19 Tage (2-3 Wochen bei Vollzeit)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔒 Sicherheits-Checkliste
|
||||||
|
|
||||||
|
- [x] Authelia SSO (2FA bereits aktiv)
|
||||||
|
- [ ] CSRF-Protection für Admin-Endpoints
|
||||||
|
- [ ] Rate-Limiting für Uploads (max 10 MB/Stunde)
|
||||||
|
- [ ] Input-Sanitization (XSS-Schutz bei HTML-Content)
|
||||||
|
- [ ] File-Type-Validation (nur Bilder erlauben)
|
||||||
|
- [ ] SQL-Injection-Schutz (Prepared Statements)
|
||||||
|
- [ ] Backup-Strategie für SQLite-DB
|
||||||
|
- [ ] Rollback-Prozess dokumentieren
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 Alternativen (falls Hybrid-Ansatz nicht passt)
|
||||||
|
|
||||||
|
### Option B: Headless CMS (Strapi/Payload)
|
||||||
|
**Pro:**
|
||||||
|
- ✅ Professionelles Admin-UI out-of-the-box
|
||||||
|
- ✅ Media-Library, Rollen-Management, Webhooks
|
||||||
|
|
||||||
|
**Contra:**
|
||||||
|
- ❌ Overhead: PostgreSQL + Redis nötig
|
||||||
|
- ❌ Mehr Operational Complexity
|
||||||
|
- ❌ Höherer Server-Ressourcen-Bedarf
|
||||||
|
|
||||||
|
### Option C: Git-basiert (Decap CMS / Tina CMS)
|
||||||
|
**Pro:**
|
||||||
|
- ✅ Content bleibt in Git (Version Control)
|
||||||
|
- ✅ Keine separate Datenbank
|
||||||
|
|
||||||
|
**Contra:**
|
||||||
|
- ❌ Deployment bei jedem Content-Edit
|
||||||
|
- ❌ Langsamer für häufige Updates
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Empfehlung: Start mit Phase 1
|
||||||
|
|
||||||
|
**Warum:**
|
||||||
|
1. Schnellste Time-to-Market (baut auf vorhandener updates-api auf)
|
||||||
|
2. Minimaler Overhead (SQLite statt Postgres)
|
||||||
|
3. Authelia bereits Production-ready
|
||||||
|
4. Einfaches Backup/Restore
|
||||||
|
|
||||||
|
**Nächster Schritt:**
|
||||||
|
1. Phase 1.1 umsetzen (Links-Endpoints + DB-Schema)
|
||||||
|
2. Phase 1.2 umsetzen (Admin-UI erweitern)
|
||||||
|
3. Testen auf Staging
|
||||||
|
4. Phase 1.3 umsetzen (Next.js Integration)
|
||||||
|
5. Production Deploy
|
||||||
|
|
||||||
|
Soll ich mit **Phase 1.1** (API-Erweiterung) beginnen?
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
# ✅ W-Make Portfolio Deployment - Erfolgreich abgeschlossen
|
||||||
|
|
||||||
|
**Datum:** 2026-08-22
|
||||||
|
**Deployed auf:** https://w-make.com
|
||||||
|
**VPS:** free-warez.win (100.90.92.125)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎉 Deployment Status: ERFOLGREICH
|
||||||
|
|
||||||
|
### Validierung durchgeführt:
|
||||||
|
|
||||||
|
✅ **Health Endpoint:**
|
||||||
|
```json
|
||||||
|
{"status":"ok","timestamp":"2026-08-22T14:39:33.760Z","service":"w-make-portfolio"}
|
||||||
|
```
|
||||||
|
|
||||||
|
✅ **Homepage (200 OK):**
|
||||||
|
- URL: https://w-make.com
|
||||||
|
- Status: HTTP/2 200
|
||||||
|
- Content-Type: text/html; charset=utf-8
|
||||||
|
|
||||||
|
✅ **Content-Änderungen live:**
|
||||||
|
- Hero H1: ✓ "Wenn Zahlen zwischen Schichten verschwinden..."
|
||||||
|
- Demo-CTA: ✓ "W-Make Batch live erleben"
|
||||||
|
- CTA-Box: ✓ "30-Min. Erstanalyse · Kostenlos"
|
||||||
|
|
||||||
|
✅ **Meta-Tags aktualisiert:**
|
||||||
|
- og:title: "Jan Wagner · Software für industrielle Prozesse"
|
||||||
|
- og:description: "Software für industrielle Prozesse, in denen Rezept, Material und Schichtübergabe lückenlos zusammengehören müssen..."
|
||||||
|
- og:image: 1200x630 (https://w-make.com/de/opengraph-image)
|
||||||
|
- twitter:card: "summary_large_image"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Deployment Details
|
||||||
|
|
||||||
|
**Container:**
|
||||||
|
- Name: `w-make-portfolio`
|
||||||
|
- Image: `w-make-portfolio:latest` (neu gebaut)
|
||||||
|
- Status: Up 47 seconds (zum Zeitpunkt der Validierung)
|
||||||
|
- Port: 3000/tcp (hinter Traefik Reverse Proxy)
|
||||||
|
|
||||||
|
**Build:**
|
||||||
|
- Next.js: 16.3.1 (Turbopack)
|
||||||
|
- TypeScript: ✓ kompiliert in 2.3s
|
||||||
|
- Pages: 34/34 generiert
|
||||||
|
- Routes: 12 dynamische, 2 statische
|
||||||
|
- Build-Zeit: ~8 Sekunden
|
||||||
|
|
||||||
|
**Deployment-Methode:**
|
||||||
|
```bash
|
||||||
|
# 1. Dateien synchronisiert via rsync
|
||||||
|
rsync -avz /home/eldov-ryzen5/workspace/Coding/Web/w-make-com/ \
|
||||||
|
free-warez.win:/opt/containers/w-make-portfolio/
|
||||||
|
|
||||||
|
# 2. Docker Image neu gebaut
|
||||||
|
docker compose build w-make-portfolio
|
||||||
|
|
||||||
|
# 3. Container neu gestartet
|
||||||
|
docker compose up -d w-make-portfolio
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Git-Commits deployed
|
||||||
|
|
||||||
|
```
|
||||||
|
1f37edf - docs: add VPS deployment guide and script
|
||||||
|
4717404 - feat: complete SEO and mobile Quick Wins
|
||||||
|
7228fee - feat(homepage): improve hero copy and CTA conversion
|
||||||
|
c822a11 - chore: add ES module type, health endpoint, and security docs
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dateien geändert:**
|
||||||
|
- `src/app/[lang]/page.tsx` (Demo-CTA, CTA-Box)
|
||||||
|
- `src/i18n/dictionaries.ts` (neue Copy)
|
||||||
|
- `src/lib/metadata.ts` (Twitter Card, OG Images)
|
||||||
|
- `src/app/[lang]/opengraph-image.tsx` (neuer Hero-Text)
|
||||||
|
- `src/app/globals.css` (Mobile-Optimierungen)
|
||||||
|
- `src/app/api/health/route.ts` (neu)
|
||||||
|
- `package.json` ("type": "module")
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Validierungs-Checkliste
|
||||||
|
|
||||||
|
### Content
|
||||||
|
- [x] Hero H1: "Wenn Zahlen zwischen Schichten verschwinden..."
|
||||||
|
- [x] Aside: "Die Software kennt die Reihenfolge nicht."
|
||||||
|
- [x] Aside erklärt Versagensmodell: "stille Korrekturen"
|
||||||
|
- [x] Demo-CTA Section nach Hero vorhanden
|
||||||
|
- [x] CTA-Box mit Benefit-Liste (3 Checkmarks)
|
||||||
|
- [x] Button Hover-Effekt funktioniert
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
- [x] Health-Endpoint antwortet: /api/health
|
||||||
|
- [x] Open Graph Tags vorhanden
|
||||||
|
- [x] Twitter Card konfiguriert
|
||||||
|
- [x] Meta Description aktualisiert
|
||||||
|
- [x] OG Image mit neuem Text
|
||||||
|
- [x] Container läuft stabil
|
||||||
|
- [x] Next.js Build erfolgreich
|
||||||
|
|
||||||
|
### SEO Validation Tools (nächster Schritt)
|
||||||
|
- [ ] https://www.opengraph.xyz/ → w-make.com testen
|
||||||
|
- [ ] https://cards-dev.twitter.com/validator
|
||||||
|
- [ ] Google PageSpeed Insights
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📱 Mobile Testing (empfohlen)
|
||||||
|
|
||||||
|
```
|
||||||
|
Chrome DevTools → Toggle Device Toolbar (Ctrl+Shift+M)
|
||||||
|
|
||||||
|
Zu testen:
|
||||||
|
- iPhone SE (375px) - Touch-Targets mind. 44x44px
|
||||||
|
- iPad (768px) - Typography skaliert
|
||||||
|
- Desktop (1920px) - Voller Funktionsumfang
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📈 Erwartete Metriken (7 Tage)
|
||||||
|
|
||||||
|
| Metrik | Baseline | Ziel | Messung |
|
||||||
|
|--------|----------|------|---------|
|
||||||
|
| Bounce Rate | ? | -20% | Google Analytics |
|
||||||
|
| Avg. Session | ? | +45s | " |
|
||||||
|
| Demo-Klicks | 0% | 8-12% | batch.w-make.com Referrer |
|
||||||
|
| Contact Form | ? | +25% | /kontakt Conversions |
|
||||||
|
| Mobile Bounce | ? | -15% | GA Mobile Segment |
|
||||||
|
|
||||||
|
**Tracking einrichten:**
|
||||||
|
1. Google Analytics Event: Demo-CTA Click
|
||||||
|
2. Contact Form Submission
|
||||||
|
3. Scroll Depth (Hero → CTA)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Rollback (falls nötig)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh free-warez.win
|
||||||
|
cd /opt/containers/w-make-portfolio
|
||||||
|
|
||||||
|
# Zu vorherigem Commit
|
||||||
|
git reset --hard 042ea88
|
||||||
|
|
||||||
|
# Container neu bauen
|
||||||
|
docker compose build w-make-portfolio
|
||||||
|
docker compose up -d w-make-portfolio
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Nächste Schritte
|
||||||
|
|
||||||
|
**Sofort:**
|
||||||
|
1. ✅ Deployment abgeschlossen
|
||||||
|
2. Meta-Tags in Validation-Tools prüfen
|
||||||
|
3. Mobile Preview testen
|
||||||
|
|
||||||
|
**Diese Woche:**
|
||||||
|
4. Analytics-Events einrichten
|
||||||
|
5. Baseline-Metriken erfassen
|
||||||
|
6. Testimonials sammeln (anonymisiert)
|
||||||
|
|
||||||
|
**Nächster Monat:**
|
||||||
|
7. A/B-Test der Hero-Copy
|
||||||
|
8. Video-Demo aufnehmen
|
||||||
|
9. Social Proof Section hinzufügen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Deployment durchgeführt von:** Jcode
|
||||||
|
**Deployment-Zeitpunkt:** 2026-08-22 14:38 UTC
|
||||||
|
**Build-Logs:** Container-Logs verfügbar via `docker logs w-make-portfolio`
|
||||||
+237
@@ -0,0 +1,237 @@
|
|||||||
|
# W-Make Portfolio VPS Deployment Guide
|
||||||
|
**Generiert:** 2026-08-22
|
||||||
|
**Commits:** c822a11, 7228fee, 4717404
|
||||||
|
**Änderungen:** Hero-Copy, Demo-CTA, Benefit-Liste, SEO, Mobile-Optimierung
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Was wurde geändert
|
||||||
|
|
||||||
|
### Content:
|
||||||
|
- Hero H1: "Wenn Zahlen zwischen Schichten verschwinden..." (statt Fachsprache)
|
||||||
|
- Neue Demo-CTA Section nach Hero
|
||||||
|
- CTA-Box mit Benefit-Checklist (✓ 2 Tage Response, ✓ Ehrliche Einschätzung, ✓ Keine Folien)
|
||||||
|
- Aside erweitert: erklärt Versagensmodell
|
||||||
|
|
||||||
|
### Technical:
|
||||||
|
- `"type": "module"` in package.json (behebt Node.js Warnungen)
|
||||||
|
- `/api/health` endpoint hinzugefügt
|
||||||
|
- Twitter Card + OG Image Support
|
||||||
|
- Meta Descriptions aktualisiert
|
||||||
|
- Mobile Touch-Targets (44x44px WCAG 2.1 AA)
|
||||||
|
- Responsive Typography mit clamp()
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Deployment auf free-warez.win VPS
|
||||||
|
|
||||||
|
### Option A: Automatisches Script (empfohlen)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. SSH zum VPS
|
||||||
|
ssh eldov@free-warez.win # oder 100.90.92.125
|
||||||
|
|
||||||
|
# 2. Script von lokal kopieren (von deinem Laptop aus)
|
||||||
|
scp /home/eldov-ryzen5/workspace/Coding/Web/w-make-com/deploy-vps.sh \
|
||||||
|
eldov@free-warez.win:~/
|
||||||
|
|
||||||
|
# 3. Auf VPS ausführen
|
||||||
|
ssh eldov@free-warez.win
|
||||||
|
bash ~/deploy-vps.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option B: Manuelle Schritte
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. SSH zum VPS
|
||||||
|
ssh eldov@free-warez.win
|
||||||
|
|
||||||
|
# 2. Zum Projekt (Pfad anpassen falls anders)
|
||||||
|
cd /home/eldov/projects/w-make-portfolio # oder /opt/ oder /srv/
|
||||||
|
# Falls unsicher: find / -name "w-make-portfolio" -type d 2>/dev/null
|
||||||
|
|
||||||
|
# 3. Git pull
|
||||||
|
git pull origin main
|
||||||
|
|
||||||
|
# Erwartete Commits:
|
||||||
|
# 4717404 feat: complete SEO and mobile Quick Wins
|
||||||
|
# 7228fee feat(homepage): improve hero copy and CTA conversion
|
||||||
|
# c822a11 chore: add ES module type, health endpoint, and security docs
|
||||||
|
|
||||||
|
# 4. Docker neu bauen
|
||||||
|
docker-compose build w-make-portfolio
|
||||||
|
|
||||||
|
# 5. Container neu starten
|
||||||
|
docker-compose up -d w-make-portfolio
|
||||||
|
|
||||||
|
# 6. Warten & Health-Check
|
||||||
|
sleep 5
|
||||||
|
curl http://localhost:3000/api/health
|
||||||
|
|
||||||
|
# Erwartete Antwort:
|
||||||
|
# {"status":"ok","timestamp":"2026-08-22T...","service":"w-make-portfolio"}
|
||||||
|
|
||||||
|
# 7. Logs prüfen
|
||||||
|
docker-compose logs --tail=30 w-make-portfolio
|
||||||
|
|
||||||
|
# Erwartete Zeile:
|
||||||
|
# ✓ Ready in XXXms
|
||||||
|
# ○ Local: http://0.0.0.0:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Validierung nach Deployment
|
||||||
|
|
||||||
|
### 1. Homepage testen (https://w-make.com)
|
||||||
|
|
||||||
|
**Hero-Section:**
|
||||||
|
```
|
||||||
|
✓ H1: "Wenn Zahlen zwischen Schichten verschwinden, liegt es meist an der Software."
|
||||||
|
✓ Aside: "Die Software kennt die Reihenfolge nicht."
|
||||||
|
✓ Aside erwähnt: "stille Korrekturen und Zahlen, die morgens nicht mehr stimmen"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Demo-CTA:**
|
||||||
|
```
|
||||||
|
✓ Nach Hero erscheint Section mit dunklerem Hintergrund
|
||||||
|
✓ Text: "W-Make Batch live erleben"
|
||||||
|
✓ Subtext: "Öffentliche Demo ohne Anmeldung · batch.w-make.com"
|
||||||
|
✓ Button: "Demo öffnen →" (führt zu https://batch.w-make.com)
|
||||||
|
```
|
||||||
|
|
||||||
|
**CTA-Box (am Ende):**
|
||||||
|
```
|
||||||
|
✓ Kicker: "30-Min. Erstanalyse · Kostenlos"
|
||||||
|
✓ H2: "Beschreiben Sie den Prozess, der heute bricht."
|
||||||
|
✓ Benefit-Liste mit ✓-Symbolen:
|
||||||
|
✓ Antwort innerhalb von 2 Werktagen
|
||||||
|
✓ Ehrliche Einschätzung, ob ich den Fall führen kann
|
||||||
|
✓ Keine Folien nötig, nur Verständnis des Ablaufs
|
||||||
|
✓ Button hat Hover-Effekt (copper-deep)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Meta-Tags validieren
|
||||||
|
|
||||||
|
**Open Graph:**
|
||||||
|
```bash
|
||||||
|
curl -sI https://w-make.com | grep -i "content-type\|x-"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tools:**
|
||||||
|
- https://www.opengraph.xyz/ → URL eingeben
|
||||||
|
- https://cards-dev.twitter.com/validator
|
||||||
|
|
||||||
|
**Erwartete OG-Werte:**
|
||||||
|
- Title: "Jan Wagner · Software für industrielle Prozesse"
|
||||||
|
- Description: "Software für industrielle Prozesse, in denen Rezept, Material..."
|
||||||
|
- Image: 1200x630 mit neuem Hero-Text
|
||||||
|
|
||||||
|
### 3. Mobile-Test
|
||||||
|
|
||||||
|
**Chrome DevTools:**
|
||||||
|
```
|
||||||
|
F12 → Toggle Device Toolbar (Ctrl+Shift+M)
|
||||||
|
Testen auf:
|
||||||
|
- iPhone SE (375px)
|
||||||
|
- iPad (768px)
|
||||||
|
- Desktop (1920px)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Prüfen:**
|
||||||
|
```
|
||||||
|
✓ Touch-Targets mind. 44x44px
|
||||||
|
✓ Hero-H1 skaliert mit clamp() (nicht abgeschnitten)
|
||||||
|
✓ Sections haben reduzierten Padding auf Mobile
|
||||||
|
✓ Alle Links/Buttons klickbar ohne Zoom
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Health-Endpoint
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl https://w-make.com/api/health
|
||||||
|
|
||||||
|
# Erwartete Antwort:
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"timestamp": "2026-08-22T...",
|
||||||
|
"service": "w-make-portfolio"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
|
### Container startet nicht
|
||||||
|
```bash
|
||||||
|
# Logs prüfen
|
||||||
|
docker-compose logs w-make-portfolio
|
||||||
|
|
||||||
|
# Häufige Probleme:
|
||||||
|
# - Port 3000 bereits belegt
|
||||||
|
# - .env.local fehlt
|
||||||
|
# - Node.js Build-Fehler
|
||||||
|
```
|
||||||
|
|
||||||
|
### Health-Endpoint 404
|
||||||
|
```bash
|
||||||
|
# Prüfen ob Route existiert
|
||||||
|
docker-compose exec w-make-portfolio ls -la /app/.next/server/app/api/health/
|
||||||
|
|
||||||
|
# Falls nicht: Build war nicht vollständig
|
||||||
|
docker-compose build --no-cache w-make-portfolio
|
||||||
|
docker-compose up -d w-make-portfolio
|
||||||
|
```
|
||||||
|
|
||||||
|
### Alte Version noch online
|
||||||
|
```bash
|
||||||
|
# Hard-Refresh im Browser (Ctrl+Shift+R)
|
||||||
|
# Oder Traefik-Cache clearen (wenn vorhanden)
|
||||||
|
|
||||||
|
# Prüfen welche Version läuft:
|
||||||
|
docker-compose exec w-make-portfolio cat /app/package.json | grep version
|
||||||
|
```
|
||||||
|
|
||||||
|
### Git Pull schlägt fehl
|
||||||
|
```bash
|
||||||
|
# Auth-Problem mit Gitea
|
||||||
|
git remote -v
|
||||||
|
# Falls SSH: git remote set-url origin https://gitea.free-warez.win/eldov/w-make-portfolio.git
|
||||||
|
|
||||||
|
# Untracked files im Weg
|
||||||
|
git status
|
||||||
|
git stash # Lokale Änderungen sichern
|
||||||
|
git pull origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Erwartete Metriken (nach 7 Tagen)
|
||||||
|
|
||||||
|
| Metrik | Erwartet |
|
||||||
|
|--------|----------|
|
||||||
|
| Bounce Rate | -15% bis -25% |
|
||||||
|
| Avg. Session Duration | +30s bis +60s |
|
||||||
|
| Demo-Klicks | 5-10% aller Besucher |
|
||||||
|
| Contact Form Views | +20% bis +40% |
|
||||||
|
| Mobile Bounce | -10% bis -15% |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Notizen
|
||||||
|
|
||||||
|
- Code liegt auf Laptop: `/home/eldov-ryzen5/workspace/Coding/Web/w-make-com`
|
||||||
|
- Git-Remote: `https://gitea.free-warez.win/eldov/w-make-portfolio.git`
|
||||||
|
- VPS Tailscale: `100.90.92.125`
|
||||||
|
- Alle Tests bestanden (17/17)
|
||||||
|
- Build erfolgreich (Next.js 16.3.1)
|
||||||
|
|
||||||
|
**Commit-IDs für Rollback (falls nötig):**
|
||||||
|
```bash
|
||||||
|
git log --oneline -5
|
||||||
|
# 4717404 feat: complete SEO and mobile Quick Wins
|
||||||
|
# 7228fee feat(homepage): improve hero copy and CTA conversion
|
||||||
|
# c822a11 chore: add ES module type, health endpoint, and security docs
|
||||||
|
# 042ea88 feat(site): rewrite public copy around the batch chain ← Fallback
|
||||||
|
```
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
# ✅ Phase 1: Admin CMS - Links Management - ERFOLGREICH DEPLOYED
|
||||||
|
|
||||||
|
**Deployment:** 2026-08-22 14:58 UTC
|
||||||
|
**Status:** ✅ Live auf https://w-make.com
|
||||||
|
**Admin-Panel:** https://updates.w-make.com/admin/links (Authelia-geschützt)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎉 Was wurde umgesetzt
|
||||||
|
|
||||||
|
### **Backend (updates-api)**
|
||||||
|
✅ SQLite-Tabelle für Links erstellt (`links`)
|
||||||
|
✅ CRUD-API implementiert:
|
||||||
|
- `GET /v1/links` (öffentlich, nur sichtbare Links)
|
||||||
|
- `GET /v1/admin/links` (alle Links, Bearer-Token)
|
||||||
|
- `POST /v1/admin/links` (Link erstellen)
|
||||||
|
- `PATCH /v1/admin/links/:id` (Link bearbeiten)
|
||||||
|
- `DELETE /v1/admin/links/:id` (Link löschen)
|
||||||
|
|
||||||
|
✅ Admin-UI erstellt (`/admin/links`)
|
||||||
|
✅ Navigation zwischen Updates und Links
|
||||||
|
✅ Validation (Titel + URL required, Category enum)
|
||||||
|
✅ Tests: 7/7 passing (inkl. 2 neue Links-Tests)
|
||||||
|
|
||||||
|
### **Frontend (Next.js)**
|
||||||
|
✅ API-Client (`src/lib/api-client.ts`)
|
||||||
|
✅ LinksSection-Komponente mit Category-Icons
|
||||||
|
✅ Homepage-Integration (zwischen Notes und CTA)
|
||||||
|
✅ i18n (DE/EN)
|
||||||
|
✅ ISR-Caching: 60s für Links
|
||||||
|
✅ Responsive Grid (1/2/3 Spalten)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Features
|
||||||
|
|
||||||
|
**Link-Kategorien:**
|
||||||
|
- `external` → ↗ (öffnet in neuem Tab)
|
||||||
|
- `internal` → → (gleicher Tab)
|
||||||
|
- `resource` → 📚
|
||||||
|
- `tool` → 🔧
|
||||||
|
|
||||||
|
**Felder:**
|
||||||
|
- Titel (required)
|
||||||
|
- URL (required)
|
||||||
|
- Kategorie (external/internal/resource/tool)
|
||||||
|
- Beschreibung (optional)
|
||||||
|
- Icon (optional, überschreibt Category-Icon)
|
||||||
|
- Sortierung (0-999)
|
||||||
|
- Sichtbarkeit (toggle)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 Admin-Zugang
|
||||||
|
|
||||||
|
**URL:** https://updates.w-make.com/admin/links
|
||||||
|
|
||||||
|
**Auth:** Authelia SSO (2FA)
|
||||||
|
- Traefik schützt `/admin/*` und `/v1/admin/*`
|
||||||
|
- Bearer-Token für API-Calls
|
||||||
|
|
||||||
|
**Navigation:**
|
||||||
|
- `/admin` → Updates verwalten
|
||||||
|
- `/admin/links` → Links verwalten
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Validierung durchgeführt
|
||||||
|
|
||||||
|
```bash
|
||||||
|
✅ npm test → 7/7 Tests bestanden
|
||||||
|
✅ npm run build → Erfolgreich (34/34 Pages)
|
||||||
|
✅ Docker Build → updates-api + portfolio
|
||||||
|
✅ Container gestartet
|
||||||
|
✅ Health-Check: {"status":"ok"}
|
||||||
|
✅ API-Test: GET /v1/links → {"data":[]}
|
||||||
|
✅ Homepage: Links-Section vorhanden
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Nächste Schritte (Admin kann jetzt arbeiten)
|
||||||
|
|
||||||
|
1. **Admin-Panel öffnen:**
|
||||||
|
```
|
||||||
|
https://updates.w-make.com/admin/links
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Ersten Link erstellen:**
|
||||||
|
- Titel: "W-Make Batch Demo"
|
||||||
|
- URL: https://batch.w-make.com
|
||||||
|
- Kategorie: external
|
||||||
|
- Beschreibung: "Live-Demo der Batch-Verwaltung für Behälterglas-Produktion"
|
||||||
|
- Sichtbar: ✓
|
||||||
|
- Sortierung: 1
|
||||||
|
|
||||||
|
3. **Weitere Beispiel-Links:**
|
||||||
|
- Batchmaker Studio (tool)
|
||||||
|
- Dokumentation (resource)
|
||||||
|
- Kontakt (internal)
|
||||||
|
|
||||||
|
4. **Validierung:**
|
||||||
|
- Homepage neu laden: https://w-make.com
|
||||||
|
- Links-Section sollte erscheinen
|
||||||
|
- Grid-Layout testen (Desktop/Tablet/Mobile)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Phase 2: Markdown-Editor + Media-Upload (nächste Woche)
|
||||||
|
|
||||||
|
**Geplant:**
|
||||||
|
- SimpleMDE Markdown-Editor
|
||||||
|
- Bild-Upload (max 5MB)
|
||||||
|
- Live-Preview
|
||||||
|
- Medien-Bibliothek
|
||||||
|
|
||||||
|
**Aufwand:** 3-5 Tage
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Git-Commits deployed
|
||||||
|
|
||||||
|
```
|
||||||
|
9794487 - feat: Phase 1 - Admin CMS with Links Management
|
||||||
|
1f37edf - docs: add VPS deployment guide and script
|
||||||
|
4717404 - feat: complete SEO and mobile Quick Wins
|
||||||
|
7228fee - feat(homepage): improve hero copy and CTA conversion
|
||||||
|
c822a11 - chore: add ES module type, health endpoint, and security docs
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Rollback (falls nötig)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh free-warez.win
|
||||||
|
cd /opt/containers/w-make-portfolio
|
||||||
|
|
||||||
|
# Zu vorherigem Commit
|
||||||
|
git reset --hard 1f37edf
|
||||||
|
|
||||||
|
# Container neu bauen
|
||||||
|
docker compose build updates-api w-make-portfolio
|
||||||
|
docker compose up -d updates-api w-make-portfolio
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 Dokumentation
|
||||||
|
|
||||||
|
**Vollständiger Plan:** [`CMS-IMPLEMENTATION-PLAN.md`](./CMS-IMPLEMENTATION-PLAN.md)
|
||||||
|
|
||||||
|
**API-Dokumentation:**
|
||||||
|
```
|
||||||
|
GET /v1/links # Öffentlich: sichtbare Links
|
||||||
|
GET /v1/admin/links # Admin: alle Links
|
||||||
|
POST /v1/admin/links # Admin: Link erstellen
|
||||||
|
GET /v1/admin/links/:id # Admin: Link abrufen
|
||||||
|
PATCH /v1/admin/links/:id # Admin: Link bearbeiten
|
||||||
|
DELETE /v1/admin/links/:id # Admin: Link löschen
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request-Body (POST/PATCH):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "Link-Titel",
|
||||||
|
"url": "https://example.com",
|
||||||
|
"category": "external",
|
||||||
|
"description": "Optionale Beschreibung",
|
||||||
|
"icon": "🔗",
|
||||||
|
"sort_order": 5,
|
||||||
|
"visible": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"title": "Link-Titel",
|
||||||
|
"url": "https://example.com",
|
||||||
|
"category": "external",
|
||||||
|
"description": "Optionale Beschreibung",
|
||||||
|
"icon": "🔗",
|
||||||
|
"sort_order": 5,
|
||||||
|
"visible": 1,
|
||||||
|
"created_at": "2026-08-22T14:58:00.000Z",
|
||||||
|
"updated_at": "2026-08-22T14:58:00.000Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Phase 1 abgeschlossen! 🚀**
|
||||||
|
|
||||||
|
Der Admin kann jetzt über https://updates.w-make.com/admin/links Links verwalten.
|
||||||
|
Die Homepage zeigt automatisch alle sichtbaren Links in der neuen Section an.
|
||||||
@@ -1,40 +1,27 @@
|
|||||||
# w-make.com
|
# w-make.com
|
||||||
|
|
||||||
Portfolio von Jan Wagner für industrielle Softwareentwicklung, Systems Thinking und Glass Technology.
|
Öffentliche Firmen- und Portfolio-Site von Jan Wagner: industrielle Software, Systems Thinking, Glass Technology.
|
||||||
|
|
||||||
Die Startseite stellt veröffentlichbare Projekte und die dahinterliegenden Engineering-Entscheidungen vor. Inhalte liegen bewusst noch direkt in `src/app/page.tsx`: Ein CMS oder eigene Case-Study-Routen kommen erst hinzu, wenn mehrere freigegebene Langform-Projekte vorhanden sind.
|
Deutsch ist die Standardsprache ohne Prefix (`/`, `/arbeit`). Englisch liegt unter `/en`. Inhalte stehen in `src/content/`, UI-Texte in `src/i18n/dictionaries.ts`. Die öffentliche Präsenz folgt `PRESENCE-STRATEGY.md`.
|
||||||
|
|
||||||
## Getting Started
|
## Lokal
|
||||||
|
|
||||||
First, run the development server:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
npm install
|
||||||
|
npm test
|
||||||
npm run dev
|
npm run dev
|
||||||
# or
|
|
||||||
yarn dev
|
|
||||||
# or
|
|
||||||
pnpm dev
|
|
||||||
# or
|
|
||||||
bun dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
Kontaktformular speichert Anfragen in SQLite (`INQUIRIES_DB_PATH`) und sendet sie per SMTP, sobald `CONTACT_TO`, `SMTP_HOST`, `SMTP_USER` und `SMTP_PASS` gesetzt sind. Lokal liegen diese Werte in `.env.local` (nicht committen), bezogen auf `eldov@w-make.de`. Optional: `CONTACT_FROM`, `SMTP_PORT`, `NEXT_PUBLIC_SITE_URL`, `UPDATES_API_URL`.
|
||||||
|
|
||||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
## Routen
|
||||||
|
|
||||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
| DE | EN |
|
||||||
|
|---|---|
|
||||||
## Learn More
|
| `/` | `/en` |
|
||||||
|
| `/ueber-mich` | `/en/about` |
|
||||||
To learn more about Next.js, take a look at the following resources:
|
| `/arbeit` | `/en/work` |
|
||||||
|
| `/notizen` | `/en/notes` |
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
| `/kontakt` | `/en/contact` |
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
| `/impressum` | `/en/legal` |
|
||||||
|
| `/datenschutz` | `/en/privacy` |
|
||||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
|
||||||
|
|
||||||
## Deploy on Vercel
|
|
||||||
|
|
||||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
|
||||||
|
|||||||
Executable
+135
@@ -0,0 +1,135 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# deploy-w-make.sh
|
||||||
|
# Deployment-Script für w-make.com (Portfolio + Updates-API)
|
||||||
|
# Generiert von Jcode am 2026-08-22
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "🚀 W-Make.com Deployment"
|
||||||
|
echo "========================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 1. Zum Projekt-Verzeichnis navigieren
|
||||||
|
if [ -d "/home/eldov/projects/w-make-portfolio" ]; then
|
||||||
|
PROJECT_DIR="/home/eldov/projects/w-make-portfolio"
|
||||||
|
elif [ -d "/opt/containers/w-make-portfolio" ]; then
|
||||||
|
PROJECT_DIR="/opt/containers/w-make-portfolio"
|
||||||
|
elif [ -d "/srv/w-make-portfolio" ]; then
|
||||||
|
PROJECT_DIR="/srv/w-make-portfolio"
|
||||||
|
else
|
||||||
|
echo "❌ Projekt-Verzeichnis nicht gefunden. Bitte manuell anpassen:"
|
||||||
|
echo " PROJECT_DIR=\"/pfad/zu/w-make-portfolio\""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "📁 Projekt-Verzeichnis: $PROJECT_DIR"
|
||||||
|
cd "$PROJECT_DIR"
|
||||||
|
|
||||||
|
# 2. Git-Status prüfen
|
||||||
|
echo ""
|
||||||
|
echo "📊 Git-Status vor dem Pull:"
|
||||||
|
git status --short
|
||||||
|
git log --oneline -3
|
||||||
|
|
||||||
|
# 3. Neueste Änderungen holen
|
||||||
|
echo ""
|
||||||
|
echo "📥 Hole neueste Änderungen von origin/main..."
|
||||||
|
git fetch origin
|
||||||
|
git pull origin main
|
||||||
|
|
||||||
|
# 4. Zeige neue Commits
|
||||||
|
echo ""
|
||||||
|
echo "✨ Neue Commits:"
|
||||||
|
git log --oneline -5
|
||||||
|
|
||||||
|
# 5. .env für updates-api prüfen
|
||||||
|
echo ""
|
||||||
|
echo "🔑 Prüfe .env für updates-api..."
|
||||||
|
if [ ! -f ".env" ]; then
|
||||||
|
echo "⚠️ .env nicht gefunden, erstelle mit Placeholder..."
|
||||||
|
echo "UPDATES_ADMIN_TOKEN=$(openssl rand -hex 32)" > .env
|
||||||
|
echo "SMTP_PASS=placeholder" >> .env
|
||||||
|
echo " ⚠️ Bitte UPDATES_ADMIN_TOKEN und SMTP_PASS in .env anpassen!"
|
||||||
|
else
|
||||||
|
echo "✅ .env existiert"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 6. Docker Images neu bauen
|
||||||
|
echo ""
|
||||||
|
echo "🔨 Baue Docker Images neu..."
|
||||||
|
docker-compose build w-make-portfolio
|
||||||
|
docker-compose build updates-api
|
||||||
|
|
||||||
|
# 7. Container neu starten
|
||||||
|
echo ""
|
||||||
|
echo "🔄 Starte Container neu..."
|
||||||
|
docker-compose up -d w-make-portfolio
|
||||||
|
docker-compose up -d updates-api
|
||||||
|
|
||||||
|
# 8. Warte auf Startup
|
||||||
|
echo ""
|
||||||
|
echo "⏳ Warte 8 Sekunden auf Container-Start..."
|
||||||
|
sleep 8
|
||||||
|
|
||||||
|
# 9. Health-Checks
|
||||||
|
echo ""
|
||||||
|
echo "🏥 Health-Checks:"
|
||||||
|
if curl -sf http://localhost:3000/api/health > /dev/null 2>&1; then
|
||||||
|
echo "✅ Portfolio Health-Endpoint erreichbar"
|
||||||
|
curl -s http://localhost:3000/api/health | python3 -m json.tool
|
||||||
|
else
|
||||||
|
echo "⚠️ Portfolio Health-Endpoint nicht erreichbar (möglicherweise noch am Starten)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if curl -sf http://localhost:8080/healthz > /dev/null 2>&1; then
|
||||||
|
echo "✅ Updates-API Health-Endpoint erreichbar"
|
||||||
|
curl -s http://localhost:8080/healthz | python3 -m json.tool
|
||||||
|
else
|
||||||
|
echo "⚠️ Updates-API Health-Endpoint nicht erreichbar"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 10. Traefik Routing validieren
|
||||||
|
echo ""
|
||||||
|
echo "🌐 Routing-Tests:"
|
||||||
|
echo -n " https://w-make.com → "
|
||||||
|
if curl -sf -o /dev/null https://w-make.com 2>/dev/null; then
|
||||||
|
echo "✅ 200 OK"
|
||||||
|
else
|
||||||
|
echo "⚠️ Nicht erreichbar"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -n " https://w-make.com/admin → "
|
||||||
|
if curl -sf -o /dev/null https://w-make.com/admin 2>/dev/null; then
|
||||||
|
echo "✅ Erreichbar (Authelia-Redirect oder 401)"
|
||||||
|
else
|
||||||
|
echo "⚠️ Nicht erreichbar"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -n " https://w-make.com/v1/updates → "
|
||||||
|
if curl -sf -o /dev/null https://w-make.com/v1/updates 2>/dev/null; then
|
||||||
|
echo "✅ Erreichbar"
|
||||||
|
else
|
||||||
|
echo "⚠️ Nicht erreichbar"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 11. Container-Logs anzeigen
|
||||||
|
echo ""
|
||||||
|
echo "📋 Container-Logs (letzte 20 Zeilen):"
|
||||||
|
docker-compose logs --tail=20 w-make-portfolio
|
||||||
|
echo ""
|
||||||
|
docker-compose logs --tail=20 updates-api
|
||||||
|
|
||||||
|
# 12. Status-Zusammenfassung
|
||||||
|
echo ""
|
||||||
|
echo "========================="
|
||||||
|
echo "✅ Deployment abgeschlossen!"
|
||||||
|
echo ""
|
||||||
|
echo "Nächste Schritte:"
|
||||||
|
echo "1. Prüfe https://w-make.com im Browser"
|
||||||
|
echo "2. Prüfe https://w-make.com/admin (Authelia-Login)"
|
||||||
|
echo "3. Prüfe https://w-make.com/v1/updates (API)"
|
||||||
|
echo ""
|
||||||
|
echo "Bei Problemen:"
|
||||||
|
echo " docker-compose logs -f w-make-portfolio"
|
||||||
|
echo " docker-compose logs -f w-make-updates-api"
|
||||||
|
echo ""
|
||||||
+25
-5
@@ -7,6 +7,16 @@ services:
|
|||||||
restart: always
|
restart: always
|
||||||
environment:
|
environment:
|
||||||
- UPDATES_API_URL=http://updates-api:8080
|
- UPDATES_API_URL=http://updates-api:8080
|
||||||
|
- NEXT_PUBLIC_SITE_URL=https://w-make.com
|
||||||
|
- CONTACT_TO=${CONTACT_TO:-eldov@w-make.de}
|
||||||
|
- "CONTACT_FROM=${CONTACT_FROM:-W-MAKE <eldov@w-make.de>}"
|
||||||
|
- SMTP_HOST=${SMTP_HOST:-smtp.strato.de}
|
||||||
|
- SMTP_PORT=${SMTP_PORT:-465}
|
||||||
|
- SMTP_USER=${SMTP_USER:-eldov@w-make.de}
|
||||||
|
- SMTP_PASS=${SMTP_PASS}
|
||||||
|
- INQUIRIES_DB_PATH=/data/inquiries.sqlite
|
||||||
|
volumes:
|
||||||
|
- inquiries_data:/data
|
||||||
expose:
|
expose:
|
||||||
- "3000"
|
- "3000"
|
||||||
labels:
|
labels:
|
||||||
@@ -47,17 +57,27 @@ services:
|
|||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.docker.network=proxy"
|
- "traefik.docker.network=proxy"
|
||||||
- "traefik.http.routers.w-make-updates.rule=Host(`updates.w-make.com`)"
|
# Öffentliche API-Routen (hohe Priorität, before portfolio)
|
||||||
- "traefik.http.routers.w-make-updates.entrypoints=websecure"
|
- "traefik.http.routers.w-make-api.rule=Host(`w-make.com`) && (PathPrefix(`/v1`) || PathPrefix(`/feed.xml`))"
|
||||||
- "traefik.http.routers.w-make-updates.tls=true"
|
- "traefik.http.routers.w-make-api.priority=150"
|
||||||
- "traefik.http.routers.w-make-updates.tls.certresolver=http_resolver"
|
- "traefik.http.routers.w-make-api.entrypoints=websecure"
|
||||||
- "traefik.http.routers.w-make-updates.middlewares=default@file,crowdsec-bouncer-plugin@file"
|
- "traefik.http.routers.w-make-api.tls=true"
|
||||||
|
- "traefik.http.routers.w-make-api.tls.certresolver=http_resolver"
|
||||||
|
- "traefik.http.routers.w-make-api.middlewares=default@file,crowdsec-bouncer-plugin@file"
|
||||||
- "traefik.http.services.w-make-updates.loadbalancer.server.port=8080"
|
- "traefik.http.services.w-make-updates.loadbalancer.server.port=8080"
|
||||||
|
# Admin-UI (höchste Priorität, Authelia-Schutz)
|
||||||
|
- "traefik.http.routers.w-make-admin.rule=Host(`w-make.com`) && PathPrefix(`/admin`)"
|
||||||
|
- "traefik.http.routers.w-make-admin.priority=200"
|
||||||
|
- "traefik.http.routers.w-make-admin.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.w-make-admin.tls=true"
|
||||||
|
- "traefik.http.routers.w-make-admin.tls.certresolver=http_resolver"
|
||||||
|
- "traefik.http.routers.w-make-admin.middlewares=default@file,crowdsec-bouncer-plugin@file"
|
||||||
networks:
|
networks:
|
||||||
- proxy
|
- proxy
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
updates_data:
|
updates_data:
|
||||||
|
inquiries_data:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
proxy:
|
proxy:
|
||||||
|
|||||||
@@ -2,6 +2,15 @@ import type { NextConfig } from "next";
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
|
async redirects() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/projects/batchmaker-studio",
|
||||||
|
destination: "/arbeit/batchmaker-studio",
|
||||||
|
permanent: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
+3
-1
@@ -2,11 +2,13 @@
|
|||||||
"name": "w-make-com",
|
"name": "w-make-com",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"test": "node --experimental-strip-types --test src/i18n/routes.test.ts src/lib/contact/schema.test.ts src/lib/contact/deliver.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "16.3.1",
|
"next": "16.3.1",
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 173 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 202 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 229 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 187 KiB |
@@ -42,7 +42,11 @@ The service intentionally has no CMS, user system, comments, or direct access to
|
|||||||
|
|
||||||
ponytail: Keep the API contract stable; migrate to PostgreSQL/OIDC only when measured write volume or multi-author editing justifies the additional operational surface.
|
ponytail: Keep the API contract stable; migrate to PostgreSQL/OIDC only when measured write volume or multi-author editing justifies the additional operational surface.
|
||||||
|
|
||||||
## Current integration boundary
|
## Admin panel
|
||||||
|
|
||||||
|
Open `https://updates.w-make.com/admin` after Authelia login (`auth.w-make.com`). Traefik protects `/admin` and `/v1/admin`; public JSON and RSS stay open.
|
||||||
|
|
||||||
|
The editor accepts Authelia `Remote-User` or `Authorization: Bearer <token>` for scripted writes from the Docker network.
|
||||||
|
|
||||||
The portfolio is wired as the first client. Batchmaker and Standalone still need their own UI client changes in their canonical repositories after the runtime/deployment URLs are confirmed.
|
The portfolio is wired as the first client. Batchmaker and Standalone still need their own UI client changes in their canonical repositories after the runtime/deployment URLs are confirmed.
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,18 @@
|
|||||||
|
-- Migration: Add links table for link management
|
||||||
|
-- Created: 2026-08-22
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS links (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
category TEXT NOT NULL DEFAULT 'external' CHECK (category IN ('external', 'internal', 'resource', 'tool')),
|
||||||
|
description TEXT,
|
||||||
|
icon TEXT,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
visible INTEGER NOT NULL DEFAULT 1 CHECK (visible IN (0, 1)),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_links_visible ON links(visible, sort_order);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_links_category ON links(category, visible, sort_order);
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>W-MAKE Links Admin</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--ink: #12100e;
|
||||||
|
--raised: #1b1814;
|
||||||
|
--paper: #f4efe4;
|
||||||
|
--copper: #c9843a;
|
||||||
|
--mute: #9a9184;
|
||||||
|
--line: rgba(244, 239, 228, 0.14);
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--ink);
|
||||||
|
color: var(--paper);
|
||||||
|
font: 16px/1.5 ui-sans-serif, system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
header, main { max-width: 1200px; margin: 0 auto; padding: 1.25rem 1.5rem; }
|
||||||
|
header { display: flex; justify-content: space-between; gap: 1rem; align-items: baseline; border-bottom: 1px solid var(--line); }
|
||||||
|
h1 { font-size: 1.4rem; font-weight: 550; margin: 0; }
|
||||||
|
.kicker { color: var(--copper); font-size: 0.72rem; letter-spacing: 0.18em; text-transform: uppercase; }
|
||||||
|
nav { display: flex; gap: 1.5rem; margin-top: 1rem; border-bottom: 1px solid var(--line); }
|
||||||
|
nav a { color: var(--mute); text-decoration: none; padding: 0.5rem 0; border-bottom: 2px solid transparent; }
|
||||||
|
nav a:hover, nav a.active { color: var(--paper); border-bottom-color: var(--copper); }
|
||||||
|
.layout { display: grid; grid-template-columns: 320px 1fr; gap: 1.5rem; }
|
||||||
|
@media (max-width: 860px) { .layout { grid-template-columns: 1fr; } }
|
||||||
|
.list { border-right: 1px solid var(--line); padding-right: 1rem; }
|
||||||
|
.item {
|
||||||
|
display: block; width: 100%; text-align: left;
|
||||||
|
background: transparent; color: inherit; border: 0; border-bottom: 1px solid var(--line);
|
||||||
|
padding: 0.8rem 0; cursor: pointer;
|
||||||
|
}
|
||||||
|
.item.active { color: var(--copper); }
|
||||||
|
.item small { font-size: 0.75rem; color: var(--mute); display: block; margin-top: 0.2rem; }
|
||||||
|
.status { font-size: 0.7rem; letter-spacing: 0.12em; text-transform: uppercase; color: var(--mute); }
|
||||||
|
.status.visible { color: var(--copper); }
|
||||||
|
label { display: grid; gap: 0.35rem; margin-bottom: 0.9rem; font-size: 0.8rem; color: var(--mute); letter-spacing: 0.08em; text-transform: uppercase; }
|
||||||
|
input, select, textarea {
|
||||||
|
width: 100%; background: var(--raised); color: var(--paper);
|
||||||
|
border: 1px solid var(--line); padding: 0.65rem 0.75rem; font: inherit;
|
||||||
|
}
|
||||||
|
textarea { min-height: 120px; }
|
||||||
|
.split { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
|
||||||
|
@media (max-width: 860px) { .split { grid-template-columns: 1fr; } }
|
||||||
|
.checkbox-label { display: flex; align-items: center; gap: 0.5rem; cursor: pointer; }
|
||||||
|
.checkbox-label input[type="checkbox"] { width: auto; }
|
||||||
|
.actions { display: flex; flex-wrap: wrap; gap: 0.6rem; margin-top: 1rem; }
|
||||||
|
button {
|
||||||
|
border: 1px solid var(--copper); background: var(--copper); color: var(--ink);
|
||||||
|
padding: 0.55rem 0.9rem; cursor: pointer; font: inherit;
|
||||||
|
}
|
||||||
|
button.ghost { background: transparent; color: var(--paper); border-color: var(--line); }
|
||||||
|
button.danger { background: transparent; color: #e74c3c; border-color: #e74c3c; }
|
||||||
|
.msg { margin-top: 1rem; color: var(--copper); min-height: 1.4rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<p class="kicker">Authelia · w-make.com</p>
|
||||||
|
<h1>Links-Verwaltung</h1>
|
||||||
|
</div>
|
||||||
|
<button class="ghost" id="new-btn" type="button">Neuer Link</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav>
|
||||||
|
<a href="/admin">Updates</a>
|
||||||
|
<a href="/admin/links" class="active">Links</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="layout">
|
||||||
|
<aside class="list">
|
||||||
|
<p class="kicker">Alle Links (<span id="count">0</span>)</p>
|
||||||
|
<div id="items"></div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<form id="form">
|
||||||
|
<input type="hidden" name="id">
|
||||||
|
|
||||||
|
<label>Titel *
|
||||||
|
<input type="text" name="title" required maxlength="200">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>URL *
|
||||||
|
<input type="url" name="url" required placeholder="https://...">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="split">
|
||||||
|
<label>Kategorie
|
||||||
|
<select name="category">
|
||||||
|
<option value="external">Extern</option>
|
||||||
|
<option value="internal">Intern</option>
|
||||||
|
<option value="resource">Ressource</option>
|
||||||
|
<option value="tool">Tool</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>Sortierung
|
||||||
|
<input type="number" name="sort_order" value="0" min="0" max="999">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label>Beschreibung
|
||||||
|
<textarea name="description" maxlength="500" placeholder="Optionale Beschreibung..."></textarea>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>Icon (optional)
|
||||||
|
<input type="text" name="icon" placeholder="z.B. fa-external-link oder emoji">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" name="visible" checked>
|
||||||
|
<span>Sichtbar auf der Homepage</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit">Speichern</button>
|
||||||
|
<button type="button" class="ghost" id="cancel-btn">Abbrechen</button>
|
||||||
|
<button type="button" class="danger" id="delete-btn" style="margin-left: auto; display: none;">Löschen</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="msg" id="msg"></p>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
const API_BASE = '';
|
||||||
|
const token = localStorage.getItem('admin_token') || '';
|
||||||
|
|
||||||
|
let links = [];
|
||||||
|
let currentId = null;
|
||||||
|
|
||||||
|
const els = {
|
||||||
|
items: document.getElementById('items'),
|
||||||
|
count: document.getElementById('count'),
|
||||||
|
form: document.getElementById('form'),
|
||||||
|
msg: document.getElementById('msg'),
|
||||||
|
newBtn: document.getElementById('new-btn'),
|
||||||
|
cancelBtn: document.getElementById('cancel-btn'),
|
||||||
|
deleteBtn: document.getElementById('delete-btn')
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchLinks() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/v1/admin/links`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Fetch failed');
|
||||||
|
const data = await res.json();
|
||||||
|
links = data.data || [];
|
||||||
|
renderList();
|
||||||
|
} catch (e) {
|
||||||
|
showMsg('Fehler beim Laden der Links', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderList() {
|
||||||
|
els.count.textContent = links.length;
|
||||||
|
els.items.innerHTML = links.map(link => `
|
||||||
|
<button type="button" class="item ${link.id === currentId ? 'active' : ''}" data-id="${link.id}">
|
||||||
|
<div>${link.title}</div>
|
||||||
|
<small>${link.category} · ${link.visible ? '✓ sichtbar' : '✗ versteckt'}</small>
|
||||||
|
</button>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
els.items.querySelectorAll('.item').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const id = Number(btn.dataset.id);
|
||||||
|
selectLink(id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectLink(id) {
|
||||||
|
const link = links.find(l => l.id === id);
|
||||||
|
if (!link) return;
|
||||||
|
|
||||||
|
currentId = id;
|
||||||
|
els.form.elements.id.value = id;
|
||||||
|
els.form.elements.title.value = link.title;
|
||||||
|
els.form.elements.url.value = link.url;
|
||||||
|
els.form.elements.category.value = link.category;
|
||||||
|
els.form.elements.description.value = link.description || '';
|
||||||
|
els.form.elements.icon.value = link.icon || '';
|
||||||
|
els.form.elements.sort_order.value = link.sort_order;
|
||||||
|
els.form.elements.visible.checked = link.visible === 1;
|
||||||
|
|
||||||
|
els.deleteBtn.style.display = 'block';
|
||||||
|
renderList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
currentId = null;
|
||||||
|
els.form.reset();
|
||||||
|
els.form.elements.id.value = '';
|
||||||
|
els.form.elements.visible.checked = true;
|
||||||
|
els.deleteBtn.style.display = 'none';
|
||||||
|
renderList();
|
||||||
|
showMsg('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMsg(text, isError = false) {
|
||||||
|
els.msg.textContent = text;
|
||||||
|
els.msg.style.color = isError ? '#e74c3c' : 'var(--copper)';
|
||||||
|
}
|
||||||
|
|
||||||
|
els.newBtn.addEventListener('click', resetForm);
|
||||||
|
els.cancelBtn.addEventListener('click', resetForm);
|
||||||
|
|
||||||
|
els.form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
title: els.form.elements.title.value.trim(),
|
||||||
|
url: els.form.elements.url.value.trim(),
|
||||||
|
category: els.form.elements.category.value,
|
||||||
|
description: els.form.elements.description.value.trim() || null,
|
||||||
|
icon: els.form.elements.icon.value.trim() || null,
|
||||||
|
sort_order: Number(els.form.elements.sort_order.value),
|
||||||
|
visible: els.form.elements.visible.checked ? 1 : 0
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const isNew = !currentId;
|
||||||
|
const url = isNew
|
||||||
|
? `${API_BASE}/v1/admin/links`
|
||||||
|
: `${API_BASE}/v1/admin/links/${currentId}`;
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: isNew ? 'POST' : 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
throw new Error(err.error?.message || 'Speichern fehlgeschlagen');
|
||||||
|
}
|
||||||
|
|
||||||
|
showMsg(isNew ? 'Link erstellt' : 'Link aktualisiert');
|
||||||
|
await fetchLinks();
|
||||||
|
|
||||||
|
if (isNew) {
|
||||||
|
const data = await res.json();
|
||||||
|
selectLink(data.data.id);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showMsg(e.message, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
els.deleteBtn.addEventListener('click', async () => {
|
||||||
|
if (!currentId) return;
|
||||||
|
if (!confirm('Link wirklich löschen?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/v1/admin/links/${currentId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error('Löschen fehlgeschlagen');
|
||||||
|
|
||||||
|
showMsg('Link gelöscht');
|
||||||
|
resetForm();
|
||||||
|
await fetchLinks();
|
||||||
|
} catch (e) {
|
||||||
|
showMsg(e.message, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
fetchLinks();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>W-MAKE Updates</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--ink: #12100e;
|
||||||
|
--raised: #1b1814;
|
||||||
|
--paper: #f4efe4;
|
||||||
|
--copper: #c9843a;
|
||||||
|
--mute: #9a9184;
|
||||||
|
--line: rgba(244, 239, 228, 0.14);
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--ink);
|
||||||
|
color: var(--paper);
|
||||||
|
font: 16px/1.5 ui-sans-serif, system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
header, main { max-width: 1200px; margin: 0 auto; padding: 1.25rem 1.5rem; }
|
||||||
|
header { display: flex; justify-content: space-between; gap: 1rem; align-items: baseline; border-bottom: 1px solid var(--line); }
|
||||||
|
h1 { font-size: 1.4rem; font-weight: 550; margin: 0; }
|
||||||
|
nav { display: flex; gap: 1.5rem; margin-top: 1rem; border-bottom: 1px solid var(--line); }
|
||||||
|
nav a { color: var(--mute); text-decoration: none; padding: 0.5rem 0; border-bottom: 2px solid transparent; }
|
||||||
|
nav a:hover, nav a.active { color: var(--paper); border-bottom-color: var(--copper); }
|
||||||
|
.kicker { color: var(--copper); font-size: 0.72rem; letter-spacing: 0.18em; text-transform: uppercase; }
|
||||||
|
.layout { display: grid; grid-template-columns: 280px 1fr; gap: 1.5rem; }
|
||||||
|
@media (max-width: 860px) { .layout { grid-template-columns: 1fr; } }
|
||||||
|
.list { border-right: 1px solid var(--line); padding-right: 1rem; }
|
||||||
|
.item {
|
||||||
|
display: block; width: 100%; text-align: left;
|
||||||
|
background: transparent; color: inherit; border: 0; border-bottom: 1px solid var(--line);
|
||||||
|
padding: 0.8rem 0; cursor: pointer;
|
||||||
|
}
|
||||||
|
.item.active { color: var(--copper); }
|
||||||
|
.status { font-size: 0.7rem; letter-spacing: 0.12em; text-transform: uppercase; color: var(--mute); }
|
||||||
|
label { display: grid; gap: 0.35rem; margin-bottom: 0.9rem; font-size: 0.8rem; color: var(--mute); letter-spacing: 0.08em; text-transform: uppercase; }
|
||||||
|
input, select, textarea {
|
||||||
|
width: 100%; background: var(--raised); color: var(--paper);
|
||||||
|
border: 1px solid var(--line); padding: 0.65rem 0.75rem; font: inherit;
|
||||||
|
}
|
||||||
|
textarea { min-height: 220px; font-family: ui-monospace, SFMono-Regular, monospace; }
|
||||||
|
.split { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
|
||||||
|
@media (max-width: 860px) { .split { grid-template-columns: 1fr; } }
|
||||||
|
.preview {
|
||||||
|
min-height: 220px; border: 1px solid var(--line); padding: 0.75rem;
|
||||||
|
background: var(--raised); color: var(--paper);
|
||||||
|
}
|
||||||
|
.preview h1, .preview h2, .preview h3 { margin: 0.4rem 0; }
|
||||||
|
.actions { display: flex; flex-wrap: wrap; gap: 0.6rem; margin-top: 1rem; }
|
||||||
|
button {
|
||||||
|
border: 1px solid var(--copper); background: var(--copper); color: var(--ink);
|
||||||
|
padding: 0.55rem 0.9rem; cursor: pointer; font: inherit;
|
||||||
|
}
|
||||||
|
button.ghost { background: transparent; color: var(--paper); border-color: var(--line); }
|
||||||
|
.msg { margin-top: 1rem; color: var(--copper); min-height: 1.4rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<p class="kicker">Authelia · w-make.com</p>
|
||||||
|
<h1>Produkt-Updates</h1>
|
||||||
|
</div>
|
||||||
|
<button class="ghost" id="new-btn" type="button">Neues Update</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav>
|
||||||
|
<a href="/admin" class="active">Updates</a>
|
||||||
|
<a href="/admin/links">Links</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="layout">
|
||||||
|
<aside class="list">
|
||||||
|
<p class="kicker">Bestand</p>
|
||||||
|
<div id="items"></div>
|
||||||
|
</aside>
|
||||||
|
<form id="form">
|
||||||
|
<input type="hidden" name="id">
|
||||||
|
<label>Produkt
|
||||||
|
<select name="product">
|
||||||
|
<option value="batchmaker">W-Make Batch</option>
|
||||||
|
<option value="standalone">Batchmaker Studio</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Titel
|
||||||
|
<input name="title" required maxlength="300">
|
||||||
|
</label>
|
||||||
|
<label>Kurztext
|
||||||
|
<input name="summary" required maxlength="300">
|
||||||
|
</label>
|
||||||
|
<label>Slug (optional, nur beim Anlegen)
|
||||||
|
<input name="slug" maxlength="80" placeholder="batchmaker-schicht-handover">
|
||||||
|
</label>
|
||||||
|
<label>Link (optional)
|
||||||
|
<input name="link_url" type="url" placeholder="https://batch.w-make.com">
|
||||||
|
</label>
|
||||||
|
<div class="split">
|
||||||
|
<label>Markdown
|
||||||
|
<textarea name="body_markdown" id="body"></textarea>
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<p class="kicker">Vorschau</p>
|
||||||
|
<div class="preview" id="preview"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit">Speichern</button>
|
||||||
|
<button class="ghost" id="publish-btn" type="button">Publizieren</button>
|
||||||
|
<button class="ghost" id="archive-btn" type="button">Archivieren</button>
|
||||||
|
</div>
|
||||||
|
<p class="msg" id="msg"></p>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
const itemsEl = document.getElementById("items");
|
||||||
|
const form = document.getElementById("form");
|
||||||
|
const msg = document.getElementById("msg");
|
||||||
|
const preview = document.getElementById("preview");
|
||||||
|
const body = document.getElementById("body");
|
||||||
|
let currentId = "";
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value).replace(/[&<>"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[char]));
|
||||||
|
}
|
||||||
|
function renderMarkdown(source) {
|
||||||
|
const escaped = escapeHtml(source || "");
|
||||||
|
return escaped
|
||||||
|
.replace(/^### (.+)$/gm, "<h3>$1</h3>")
|
||||||
|
.replace(/^## (.+)$/gm, "<h2>$1</h2>")
|
||||||
|
.replace(/^# (.+)$/gm, "<h1>$1</h1>")
|
||||||
|
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
|
||||||
|
.replace(/^\- (.+)$/gm, "<li>$1</li>")
|
||||||
|
.replace(/(<li>.*<\/li>)/s, "<ul>$1</ul>")
|
||||||
|
.replace(/\[(.+?)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" rel="noreferrer">$1</a>')
|
||||||
|
.replace(/\n\n/g, "</p><p>")
|
||||||
|
.replace(/\n/g, "<br>");
|
||||||
|
}
|
||||||
|
function setMsg(text) { msg.textContent = text || ""; }
|
||||||
|
function fill(item) {
|
||||||
|
currentId = item?.id ? String(item.id) : "";
|
||||||
|
form.id.value = currentId;
|
||||||
|
form.product.value = item?.product || "batchmaker";
|
||||||
|
form.title.value = item?.title || "";
|
||||||
|
form.summary.value = item?.summary || "";
|
||||||
|
form.slug.value = item?.slug || "";
|
||||||
|
form.link_url.value = item?.link_url || "";
|
||||||
|
body.value = item?.body_markdown || "";
|
||||||
|
preview.innerHTML = `<p>${renderMarkdown(body.value)}</p>`;
|
||||||
|
for (const node of itemsEl.querySelectorAll(".item")) {
|
||||||
|
node.classList.toggle("active", node.dataset.id === currentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function api(path, options = {}) {
|
||||||
|
const response = await fetch(path, {
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { "content-type": "application/json", ...(options.headers || {}) },
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) throw new Error(payload.error?.message || `HTTP ${response.status}`);
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
async function refresh() {
|
||||||
|
const payload = await api("/v1/admin/updates?limit=50");
|
||||||
|
itemsEl.innerHTML = payload.data.map((item) => (
|
||||||
|
`<button class="item${String(item.id) === currentId ? " active" : ""}" data-id="${item.id}" type="button">
|
||||||
|
<span class="status">${escapeHtml(item.product)} · ${escapeHtml(item.status)}</span>
|
||||||
|
<div>${escapeHtml(item.title)}</div>
|
||||||
|
</button>`
|
||||||
|
)).join("") || "<p class='status'>Noch keine Updates.</p>";
|
||||||
|
for (const node of itemsEl.querySelectorAll(".item")) {
|
||||||
|
node.addEventListener("click", async () => {
|
||||||
|
const item = payload.data.find((entry) => String(entry.id) === node.dataset.id);
|
||||||
|
fill(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
form.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
product: form.product.value,
|
||||||
|
title: form.title.value,
|
||||||
|
summary: form.summary.value,
|
||||||
|
body_markdown: body.value,
|
||||||
|
link_url: form.link_url.value || null,
|
||||||
|
};
|
||||||
|
if (!currentId && form.slug.value.trim()) payload.slug = form.slug.value.trim();
|
||||||
|
const creating = !currentId;
|
||||||
|
const saved = creating
|
||||||
|
? await api("/v1/admin/updates", { method: "POST", body: JSON.stringify(payload) })
|
||||||
|
: await api(`/v1/admin/updates/${currentId}`, { method: "PATCH", body: JSON.stringify(payload) });
|
||||||
|
fill(saved.data);
|
||||||
|
await refresh();
|
||||||
|
setMsg(creating ? "Draft angelegt." : "Gespeichert.");
|
||||||
|
} catch (error) {
|
||||||
|
setMsg(error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
async function setStatus(action) {
|
||||||
|
if (!currentId) { setMsg("Zuerst speichern."); return; }
|
||||||
|
try {
|
||||||
|
const saved = await api(`/v1/admin/updates/${currentId}/${action}`, { method: "POST" });
|
||||||
|
fill(saved.data);
|
||||||
|
await refresh();
|
||||||
|
setMsg(action === "publish" ? "Veröffentlicht." : "Archiviert.");
|
||||||
|
} catch (error) {
|
||||||
|
setMsg(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.getElementById("new-btn").addEventListener("click", () => fill(null));
|
||||||
|
document.getElementById("publish-btn").addEventListener("click", () => setStatus("publish"));
|
||||||
|
document.getElementById("archive-btn").addEventListener("click", () => setStatus("archive"));
|
||||||
|
body.addEventListener("input", () => { preview.innerHTML = `<p>${renderMarkdown(body.value)}</p>`; });
|
||||||
|
refresh().catch((error) => setMsg(error.message));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
import { createServer } from 'node:http';
|
import { createServer } from 'node:http';
|
||||||
import { mkdirSync } from 'node:fs';
|
import { mkdirSync, readFileSync } from 'node:fs';
|
||||||
import { dirname } from 'node:path';
|
import { dirname } from 'node:path';
|
||||||
import { DatabaseSync } from 'node:sqlite';
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
const ADMIN_HTML = readFileSync(new URL('./admin.html', import.meta.url), 'utf8');
|
||||||
|
const ADMIN_LINKS_HTML = readFileSync(new URL('./admin-links.html', import.meta.url), 'utf8');
|
||||||
|
|
||||||
const PRODUCTS = new Set(['batchmaker', 'standalone']);
|
const PRODUCTS = new Set(['batchmaker', 'standalone']);
|
||||||
const STATUSES = new Set(['draft', 'published', 'archived']);
|
const STATUSES = new Set(['draft', 'published', 'archived']);
|
||||||
const MAX_BODY = 64 * 1024;
|
const MAX_BODY = 64 * 1024;
|
||||||
@@ -48,7 +51,11 @@ function validatePayload(payload, partial = false) {
|
|||||||
if (out.published_at !== undefined && out.published_at !== null && Number.isNaN(Date.parse(out.published_at))) throw Object.assign(new Error('published_at must be an ISO date'), { code: 'INVALID_FIELD' });
|
if (out.published_at !== undefined && out.published_at !== null && Number.isNaN(Date.parse(out.published_at))) throw Object.assign(new Error('published_at must be an ISO date'), { code: 'INVALID_FIELD' });
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
function auth(req, token) { return Boolean(token) && req.headers.authorization === `Bearer ${token}`; }
|
function auth(req, token) {
|
||||||
|
if (token && req.headers.authorization === `Bearer ${token}`) return true;
|
||||||
|
const user = req.headers['remote-user'];
|
||||||
|
return typeof user === 'string' && user.trim().length > 0;
|
||||||
|
}
|
||||||
function row(row) { return row ? { ...row } : null; }
|
function row(row) { return row ? { ...row } : null; }
|
||||||
function xml(value) {
|
function xml(value) {
|
||||||
return String(value ?? '').replace(/[<>&'\"]/g, character => ({ '<': '<', '>': '>', '&': '&', "'": ''', '\"': '"' })[character]);
|
return String(value ?? '').replace(/[<>&'\"]/g, character => ({ '<': '<', '>': '>', '&': '&', "'": ''', '\"': '"' })[character]);
|
||||||
@@ -76,6 +83,21 @@ export function createApp({ dbPath = process.env.UPDATES_DB_PATH || './data/upda
|
|||||||
link_url TEXT
|
link_url TEXT
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_updates_public ON updates(product, status, published_at DESC);`);
|
CREATE INDEX IF NOT EXISTS idx_updates_public ON updates(product, status, published_at DESC);`);
|
||||||
|
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS links (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
category TEXT NOT NULL DEFAULT 'external' CHECK (category IN ('external', 'internal', 'resource', 'tool')),
|
||||||
|
description TEXT,
|
||||||
|
icon TEXT,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
visible INTEGER NOT NULL DEFAULT 1 CHECK (visible IN (0, 1)),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_links_visible ON links(visible, sort_order);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_links_category ON links(category, visible, sort_order);`);
|
||||||
|
|
||||||
function publishedFilter(product) {
|
function publishedFilter(product) {
|
||||||
return product ? { sql: 'product = ? AND status = ? AND published_at IS NOT NULL AND published_at <= ?', args: [product, 'published', now()] } : { sql: 'status = ? AND published_at IS NOT NULL AND published_at <= ?', args: ['published', now()] };
|
return product ? { sql: 'product = ? AND status = ? AND published_at IS NOT NULL AND published_at <= ?', args: [product, 'published', now()] } : { sql: 'status = ? AND published_at IS NOT NULL AND published_at <= ?', args: ['published', now()] };
|
||||||
@@ -88,6 +110,25 @@ export function createApp({ dbPath = process.env.UPDATES_DB_PATH || './data/upda
|
|||||||
const f = publishedFilter();
|
const f = publishedFilter();
|
||||||
return row(db.prepare(`SELECT id, slug, product, title, summary, body_markdown, published_at, created_at, updated_at, link_url FROM updates WHERE slug = ? AND ${f.sql}`).get(slug, ...f.args));
|
return row(db.prepare(`SELECT id, slug, product, title, summary, body_markdown, published_at, created_at, updated_at, link_url FROM updates WHERE slug = ? AND ${f.sql}`).get(slug, ...f.args));
|
||||||
}
|
}
|
||||||
|
function adminList(product, status, limit) {
|
||||||
|
const clauses = [];
|
||||||
|
const args = [];
|
||||||
|
if (product) {
|
||||||
|
if (!validProduct(product)) throw Object.assign(new Error('product must be batchmaker or standalone'), { code: 'INVALID_PRODUCT' });
|
||||||
|
clauses.push('product = ?');
|
||||||
|
args.push(product);
|
||||||
|
}
|
||||||
|
if (status) {
|
||||||
|
if (!STATUSES.has(status)) throw Object.assign(new Error('invalid status'), { code: 'INVALID_STATUS' });
|
||||||
|
clauses.push('status = ?');
|
||||||
|
args.push(status);
|
||||||
|
}
|
||||||
|
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
|
||||||
|
return db.prepare(`SELECT * FROM updates ${where} ORDER BY updated_at DESC, id DESC LIMIT ?`).all(...args, limit).map(row);
|
||||||
|
}
|
||||||
|
function adminGet(id) {
|
||||||
|
return row(db.prepare('SELECT * FROM updates WHERE id = ?').get(id));
|
||||||
|
}
|
||||||
function adminCreate(payload) {
|
function adminCreate(payload) {
|
||||||
const fields = validatePayload(payload);
|
const fields = validatePayload(payload);
|
||||||
const created = now();
|
const created = now();
|
||||||
@@ -112,6 +153,91 @@ export function createApp({ dbPath = process.env.UPDATES_DB_PATH || './data/upda
|
|||||||
return row(db.prepare('SELECT * FROM updates WHERE id = ?').get(id));
|
return row(db.prepare('SELECT * FROM updates WHERE id = ?').get(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Links CRUD functions
|
||||||
|
const CATEGORIES = new Set(['external', 'internal', 'resource', 'tool']);
|
||||||
|
|
||||||
|
function validateLinkPayload(payload, partial = false) {
|
||||||
|
const fields = {};
|
||||||
|
if (!partial || 'title' in payload) {
|
||||||
|
if (typeof payload.title !== 'string' || !payload.title.trim()) {
|
||||||
|
throw Object.assign(new Error('title is required'), { code: 'INVALID_FIELD' });
|
||||||
|
}
|
||||||
|
fields.title = payload.title.trim();
|
||||||
|
}
|
||||||
|
if (!partial || 'url' in payload) {
|
||||||
|
if (typeof payload.url !== 'string' || !payload.url.trim()) {
|
||||||
|
throw Object.assign(new Error('url is required'), { code: 'INVALID_FIELD' });
|
||||||
|
}
|
||||||
|
fields.url = payload.url.trim();
|
||||||
|
}
|
||||||
|
if ('category' in payload) {
|
||||||
|
if (!CATEGORIES.has(payload.category)) {
|
||||||
|
throw Object.assign(new Error('category must be external, internal, resource, or tool'), { code: 'INVALID_FIELD' });
|
||||||
|
}
|
||||||
|
fields.category = payload.category;
|
||||||
|
}
|
||||||
|
if ('description' in payload) {
|
||||||
|
fields.description = typeof payload.description === 'string' ? payload.description.trim() : null;
|
||||||
|
}
|
||||||
|
if ('icon' in payload) {
|
||||||
|
fields.icon = typeof payload.icon === 'string' ? payload.icon.trim() : null;
|
||||||
|
}
|
||||||
|
if ('sort_order' in payload) {
|
||||||
|
fields.sort_order = Number(payload.sort_order) || 0;
|
||||||
|
}
|
||||||
|
if ('visible' in payload) {
|
||||||
|
fields.visible = payload.visible ? 1 : 0;
|
||||||
|
}
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
function linksGetPublic() {
|
||||||
|
return db.prepare('SELECT id, title, url, category, description, icon, sort_order FROM links WHERE visible = 1 ORDER BY sort_order, id').all();
|
||||||
|
}
|
||||||
|
|
||||||
|
function linksAdminList() {
|
||||||
|
return db.prepare('SELECT * FROM links ORDER BY sort_order, id').all();
|
||||||
|
}
|
||||||
|
|
||||||
|
function linksAdminGet(id) {
|
||||||
|
return db.prepare('SELECT * FROM links WHERE id = ?').get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function linksAdminCreate(payload) {
|
||||||
|
const fields = validateLinkPayload(payload);
|
||||||
|
const created = now();
|
||||||
|
db.prepare('INSERT INTO links (title, url, category, description, icon, sort_order, visible, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
|
||||||
|
.run(fields.title, fields.url, fields.category || 'external', fields.description || null, fields.icon || null, fields.sort_order || 0, fields.visible ?? 1, created, created);
|
||||||
|
return db.prepare('SELECT * FROM links WHERE id = last_insert_rowid()').get();
|
||||||
|
}
|
||||||
|
|
||||||
|
function linksAdminPatch(id, payload) {
|
||||||
|
const current = db.prepare('SELECT * FROM links WHERE id = ?').get(id);
|
||||||
|
if (!current) return null;
|
||||||
|
const fields = validateLinkPayload(payload, true);
|
||||||
|
const updated = now();
|
||||||
|
db.prepare('UPDATE links SET title = ?, url = ?, category = ?, description = ?, icon = ?, sort_order = ?, visible = ?, updated_at = ? WHERE id = ?')
|
||||||
|
.run(
|
||||||
|
fields.title ?? current.title,
|
||||||
|
fields.url ?? current.url,
|
||||||
|
fields.category ?? current.category,
|
||||||
|
fields.description ?? current.description,
|
||||||
|
fields.icon ?? current.icon,
|
||||||
|
fields.sort_order ?? current.sort_order,
|
||||||
|
fields.visible ?? current.visible,
|
||||||
|
updated,
|
||||||
|
id
|
||||||
|
);
|
||||||
|
return db.prepare('SELECT * FROM links WHERE id = ?').get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function linksAdminDelete(id) {
|
||||||
|
const current = db.prepare('SELECT * FROM links WHERE id = ?').get(id);
|
||||||
|
if (!current) return null;
|
||||||
|
db.prepare('DELETE FROM links WHERE id = ?').run(id);
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
async function handler(req, res) {
|
async function handler(req, res) {
|
||||||
const url = new URL(req.url, 'http://localhost');
|
const url = new URL(req.url, 'http://localhost');
|
||||||
const path = url.pathname;
|
const path = url.pathname;
|
||||||
@@ -139,13 +265,61 @@ export function createApp({ dbPath = process.env.UPDATES_DB_PATH || './data/upda
|
|||||||
const item = getBySlug(decodeURIComponent(slugMatch[1]));
|
const item = getBySlug(decodeURIComponent(slugMatch[1]));
|
||||||
return item ? json(res, 200, { data: item }, { 'cache-control': 'public, max-age=60' }) : error(res, 404, 'NOT_FOUND', 'update not found');
|
return item ? json(res, 200, { data: item }, { 'cache-control': 'public, max-age=60' }) : error(res, 404, 'NOT_FOUND', 'update not found');
|
||||||
}
|
}
|
||||||
if (path.startsWith('/v1/admin/') && !auth(req, adminToken)) return error(res, 401, 'UNAUTHORIZED', 'admin authentication required');
|
const isAdminUi = path === '/admin' || path === '/admin/';
|
||||||
|
const isAdminLinksUi = path === '/admin/links' || path === '/admin/links/';
|
||||||
|
if ((isAdminUi || isAdminLinksUi || path.startsWith('/v1/admin/')) && !auth(req, adminToken)) return error(res, 401, 'UNAUTHORIZED', 'admin authentication required');
|
||||||
|
if (req.method === 'GET' && isAdminUi) {
|
||||||
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
||||||
|
res.end(ADMIN_HTML);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.method === 'GET' && isAdminLinksUi) {
|
||||||
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
||||||
|
res.end(ADMIN_LINKS_HTML);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.method === 'GET' && path === '/v1/admin/updates') {
|
||||||
|
const product = url.searchParams.get('product') || null;
|
||||||
|
const status = url.searchParams.get('status') || null;
|
||||||
|
const requested = Number(url.searchParams.get('limit') || 50);
|
||||||
|
const limit = Number.isInteger(requested) && requested >= 1 && requested <= 50 ? requested : 50;
|
||||||
|
return json(res, 200, { data: adminList(product, status, limit) }, { 'cache-control': 'no-store' });
|
||||||
|
}
|
||||||
if (req.method === 'POST' && path === '/v1/admin/updates') return json(res, 201, { data: adminCreate(parsePayload(await readBody(req))) });
|
if (req.method === 'POST' && path === '/v1/admin/updates') return json(res, 201, { data: adminCreate(parsePayload(await readBody(req))) });
|
||||||
const adminMatch = path.match(/^\/v1\/admin\/updates\/(\d+)(?:\/(publish|archive))?$/);
|
const adminMatch = path.match(/^\/v1\/admin\/updates\/(\d+)(?:\/(publish|archive))?$/);
|
||||||
|
if (adminMatch && req.method === 'GET' && !adminMatch[2]) {
|
||||||
|
const item = adminGet(Number(adminMatch[1]));
|
||||||
|
return item ? json(res, 200, { data: item }, { 'cache-control': 'no-store' }) : error(res, 404, 'NOT_FOUND', 'update not found');
|
||||||
|
}
|
||||||
if (adminMatch && (req.method === 'PATCH' || req.method === 'POST')) {
|
if (adminMatch && (req.method === 'PATCH' || req.method === 'POST')) {
|
||||||
const item = adminMatch[2] ? setStatus(Number(adminMatch[1]), adminMatch[2] === 'publish' ? 'published' : 'archived') : adminPatch(Number(adminMatch[1]), parsePayload(await readBody(req)));
|
const item = adminMatch[2] ? setStatus(Number(adminMatch[1]), adminMatch[2] === 'publish' ? 'published' : 'archived') : adminPatch(Number(adminMatch[1]), parsePayload(await readBody(req)));
|
||||||
return item ? json(res, 200, { data: item }) : error(res, 404, 'NOT_FOUND', 'update not found');
|
return item ? json(res, 200, { data: item }) : error(res, 404, 'NOT_FOUND', 'update not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Links routes
|
||||||
|
if (req.method === 'GET' && path === '/v1/links') {
|
||||||
|
return json(res, 200, { data: linksGetPublic() }, { 'cache-control': 'public, max-age=60' });
|
||||||
|
}
|
||||||
|
if (req.method === 'GET' && path === '/v1/admin/links') {
|
||||||
|
return json(res, 200, { data: linksAdminList() }, { 'cache-control': 'no-store' });
|
||||||
|
}
|
||||||
|
if (req.method === 'POST' && path === '/v1/admin/links') {
|
||||||
|
return json(res, 201, { data: linksAdminCreate(parsePayload(await readBody(req))) });
|
||||||
|
}
|
||||||
|
const linkMatch = path.match(/^\/v1\/admin\/links\/(\d+)$/);
|
||||||
|
if (linkMatch && req.method === 'GET') {
|
||||||
|
const item = linksAdminGet(Number(linkMatch[1]));
|
||||||
|
return item ? json(res, 200, { data: item }, { 'cache-control': 'no-store' }) : error(res, 404, 'NOT_FOUND', 'link not found');
|
||||||
|
}
|
||||||
|
if (linkMatch && req.method === 'PATCH') {
|
||||||
|
const item = linksAdminPatch(Number(linkMatch[1]), parsePayload(await readBody(req)));
|
||||||
|
return item ? json(res, 200, { data: item }) : error(res, 404, 'NOT_FOUND', 'link not found');
|
||||||
|
}
|
||||||
|
if (linkMatch && req.method === 'DELETE') {
|
||||||
|
const item = linksAdminDelete(Number(linkMatch[1]));
|
||||||
|
return item ? json(res, 200, { data: item }) : error(res, 404, 'NOT_FOUND', 'link not found');
|
||||||
|
}
|
||||||
|
|
||||||
return error(res, 404, 'NOT_FOUND', 'route not found');
|
return error(res, 404, 'NOT_FOUND', 'route not found');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.code === 'BODY_TOO_LARGE') return error(res, 413, e.code, e.message);
|
if (e.code === 'BODY_TOO_LARGE') return error(res, 413, e.code, e.message);
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import { strict as assert } from 'node:assert';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { createApp } from '../src/server.js';
|
||||||
|
|
||||||
|
const TOKEN = 'test-token-' + randomUUID();
|
||||||
|
|
||||||
|
test('links CRUD workflow', async () => {
|
||||||
|
const dbPath = join(tmpdir(), `links-test-${randomUUID()}.sqlite`);
|
||||||
|
const app = createApp({ dbPath, adminToken: TOKEN });
|
||||||
|
const port = 3000 + Math.floor(Math.random() * 1000);
|
||||||
|
const server = app.server.listen(port);
|
||||||
|
const base = `http://127.0.0.1:${port}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create link
|
||||||
|
let res = await fetch(`${base}/v1/admin/links`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${TOKEN}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: 'Test Link',
|
||||||
|
url: 'https://example.com',
|
||||||
|
category: 'external',
|
||||||
|
description: 'A test link',
|
||||||
|
visible: true,
|
||||||
|
sort_order: 5
|
||||||
|
})
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 201, 'create should return 201');
|
||||||
|
const created = await res.json();
|
||||||
|
assert.ok(created.data.id, 'created link should have id');
|
||||||
|
assert.equal(created.data.title, 'Test Link');
|
||||||
|
const linkId = created.data.id;
|
||||||
|
|
||||||
|
// List all links (admin)
|
||||||
|
res = await fetch(`${base}/v1/admin/links`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${TOKEN}` }
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
let list = await res.json();
|
||||||
|
assert.equal(list.data.length, 1, 'should have 1 link');
|
||||||
|
|
||||||
|
// Get single link
|
||||||
|
res = await fetch(`${base}/v1/admin/links/${linkId}`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${TOKEN}` }
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const single = await res.json();
|
||||||
|
assert.equal(single.data.title, 'Test Link');
|
||||||
|
|
||||||
|
// Update link
|
||||||
|
res = await fetch(`${base}/v1/admin/links/${linkId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${TOKEN}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: 'Updated Link',
|
||||||
|
visible: false
|
||||||
|
})
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const updated = await res.json();
|
||||||
|
assert.equal(updated.data.title, 'Updated Link');
|
||||||
|
assert.equal(updated.data.visible, 0);
|
||||||
|
|
||||||
|
// Public endpoint should not show invisible links
|
||||||
|
res = await fetch(`${base}/v1/links`);
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const publicLinks = await res.json();
|
||||||
|
assert.equal(publicLinks.data.length, 0, 'invisible links should not appear in public endpoint');
|
||||||
|
|
||||||
|
// Make visible again
|
||||||
|
res = await fetch(`${base}/v1/admin/links/${linkId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${TOKEN}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ visible: true })
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
|
||||||
|
// Public endpoint should now show link
|
||||||
|
res = await fetch(`${base}/v1/links`);
|
||||||
|
const publicVisible = await res.json();
|
||||||
|
assert.equal(publicVisible.data.length, 1, 'visible links should appear in public endpoint');
|
||||||
|
|
||||||
|
// Delete link
|
||||||
|
res = await fetch(`${base}/v1/admin/links/${linkId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Authorization': `Bearer ${TOKEN}` }
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
|
||||||
|
// Verify deletion
|
||||||
|
res = await fetch(`${base}/v1/admin/links`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${TOKEN}` }
|
||||||
|
});
|
||||||
|
list = await res.json();
|
||||||
|
assert.equal(list.data.length, 0, 'link should be deleted');
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
server.close();
|
||||||
|
app.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('links validation', async () => {
|
||||||
|
const dbPath = join(tmpdir(), `links-validation-${randomUUID()}.sqlite`);
|
||||||
|
const app = createApp({ dbPath, adminToken: TOKEN });
|
||||||
|
const port = 3001 + Math.floor(Math.random() * 1000);
|
||||||
|
const server = app.server.listen(port);
|
||||||
|
const base = `http://127.0.0.1:${port}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Missing title
|
||||||
|
let res = await fetch(`${base}/v1/admin/links`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${TOKEN}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
url: 'https://example.com'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 400, 'should reject missing title');
|
||||||
|
|
||||||
|
// Missing URL
|
||||||
|
res = await fetch(`${base}/v1/admin/links`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${TOKEN}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: 'Test'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 400, 'should reject missing url');
|
||||||
|
|
||||||
|
// Invalid category
|
||||||
|
res = await fetch(`${base}/v1/admin/links`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${TOKEN}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: 'Test',
|
||||||
|
url: 'https://example.com',
|
||||||
|
category: 'invalid'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 400, 'should reject invalid category');
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
server.close();
|
||||||
|
app.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -52,3 +52,35 @@ test('rejects oversized request bodies', async () => {
|
|||||||
assert.equal(response.status, 413);
|
assert.equal(response.status, 413);
|
||||||
} finally { await teardown(app); }
|
} finally { await teardown(app); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('accepts Authelia Remote-User for admin writes and lists drafts', async () => {
|
||||||
|
const { app, request } = await setup();
|
||||||
|
try {
|
||||||
|
const created = await request('/v1/admin/updates', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json', 'remote-user': 'jan' },
|
||||||
|
body: JSON.stringify(draft),
|
||||||
|
});
|
||||||
|
assert.equal(created.status, 201);
|
||||||
|
const listed = await request('/v1/admin/updates', { headers: { 'remote-user': 'jan' } });
|
||||||
|
assert.equal(listed.status, 200);
|
||||||
|
const payload = await listed.json();
|
||||||
|
assert.equal(payload.data.length, 1);
|
||||||
|
assert.equal(payload.data[0].status, 'draft');
|
||||||
|
assert.equal(payload.data[0].title, 'Standalone 1');
|
||||||
|
} finally { await teardown(app); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('serves the admin editor only to authenticated operators', async () => {
|
||||||
|
const { app, request } = await setup();
|
||||||
|
try {
|
||||||
|
const denied = await request('/admin');
|
||||||
|
assert.equal(denied.status, 401);
|
||||||
|
const page = await request('/admin', { headers: { 'remote-user': 'jan' } });
|
||||||
|
assert.equal(page.status, 200);
|
||||||
|
assert.match(page.headers.get('content-type'), /text\/html/);
|
||||||
|
const html = await page.text();
|
||||||
|
assert.match(html, /body_markdown/);
|
||||||
|
assert.match(html, /Batchmaker/);
|
||||||
|
} finally { await teardown(app); }
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { getPerson } from "@/content/person";
|
||||||
|
import { getDictionary } from "@/i18n/dictionaries";
|
||||||
|
import { publicPath } from "@/i18n/routes";
|
||||||
|
import { pageMetadata } from "@/lib/metadata";
|
||||||
|
import { requireLocale } from "@/lib/locale";
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: PageProps<"/[lang]/about">) {
|
||||||
|
const locale = requireLocale((await params).lang);
|
||||||
|
const dict = getDictionary(locale);
|
||||||
|
return pageMetadata(locale, "about", dict.about.title, dict.about.lede);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AboutPage({ params }: PageProps<"/[lang]/about">) {
|
||||||
|
const locale = requireLocale((await params).lang);
|
||||||
|
const dict = getDictionary(locale);
|
||||||
|
const person = getPerson(locale);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto w-full max-w-6xl px-6 py-20 md:px-10 md:py-28">
|
||||||
|
<p className="kicker">{dict.about.kicker}</p>
|
||||||
|
<h1 className="display mt-5 max-w-4xl text-5xl md:text-7xl">{dict.about.title}</h1>
|
||||||
|
<p className="mt-8 max-w-2xl text-xl leading-9 text-mute-strong">{dict.about.lede}</p>
|
||||||
|
<p className="mt-6 font-mono text-xs uppercase tracking-[0.16em] text-mute">
|
||||||
|
{person.role} · {person.location}
|
||||||
|
</p>
|
||||||
|
<div className="mt-16 grid max-w-2xl gap-6 border-t border-line pt-12">
|
||||||
|
{person.story.map((paragraph) => (
|
||||||
|
<p key={paragraph.slice(0, 24)} className="leading-8 text-mute-strong">
|
||||||
|
{paragraph}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<ol className="mt-20 grid gap-10 border-t border-line pt-12">
|
||||||
|
{person.principles.map((principle, index) => (
|
||||||
|
<li key={principle.title} className="grid gap-3 md:grid-cols-[4rem_1fr]">
|
||||||
|
<span className="font-mono text-sm text-copper">0{index + 1}</span>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-serif text-3xl">{principle.title}</h2>
|
||||||
|
<p className="mt-3 max-w-2xl leading-7 text-mute-strong">{principle.text}</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
<section className="mt-20 border-t border-line pt-12">
|
||||||
|
<p className="max-w-2xl leading-8 text-mute-strong">{dict.about.close}</p>
|
||||||
|
<Link
|
||||||
|
href={publicPath(locale, "contact")}
|
||||||
|
className="mt-8 inline-block border border-copper bg-copper px-6 py-3 text-sm font-medium text-ink"
|
||||||
|
>
|
||||||
|
{dict.actions.startConversation}
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { ContactForm } from "@/components/contact-form";
|
||||||
|
import { isOfferId } from "@/content/offer-ids";
|
||||||
|
import { getOffers } from "@/content/offers";
|
||||||
|
import { site } from "@/content/site";
|
||||||
|
import { getDictionary } from "@/i18n/dictionaries";
|
||||||
|
import { publicPath } from "@/i18n/routes";
|
||||||
|
import { pageMetadata } from "@/lib/metadata";
|
||||||
|
import { requireLocale } from "@/lib/locale";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: PageProps<"/[lang]/contact">) {
|
||||||
|
const locale = requireLocale((await params).lang);
|
||||||
|
const dict = getDictionary(locale);
|
||||||
|
return pageMetadata(locale, "contact", dict.contact.title, dict.contact.lede);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ContactPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: PageProps<"/[lang]/contact">) {
|
||||||
|
const locale = requireLocale((await params).lang);
|
||||||
|
const dict = getDictionary(locale);
|
||||||
|
const offers = getOffers(locale);
|
||||||
|
const topicParam = (await searchParams).topic;
|
||||||
|
const topicValue = Array.isArray(topicParam) ? topicParam[0] : topicParam;
|
||||||
|
const defaultTopic = topicValue && isOfferId(topicValue) ? topicValue : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto grid w-full max-w-6xl gap-16 px-6 py-20 md:grid-cols-[1.1fr_0.9fr] md:px-10 md:py-28">
|
||||||
|
<div>
|
||||||
|
<p className="kicker">{dict.contact.kicker}</p>
|
||||||
|
<h1 className="display mt-5 text-5xl md:text-6xl">{dict.contact.title}</h1>
|
||||||
|
<p className="mt-8 max-w-xl text-lg leading-8 text-mute-strong">{dict.contact.lede}</p>
|
||||||
|
<div className="mt-8 max-w-xl">
|
||||||
|
<p className="font-mono text-xs uppercase tracking-[0.16em] text-mute">{dict.contact.needTitle}</p>
|
||||||
|
<ul className="mt-4 grid gap-3 text-sm leading-7 text-mute-strong">
|
||||||
|
{dict.contact.need.map((item) => (
|
||||||
|
<li key={item} className="border-l border-line-strong pl-4">
|
||||||
|
{item}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<p className="mt-6 max-w-xl text-sm leading-7 text-mute">{dict.contact.reply}</p>
|
||||||
|
<p className="mt-3 text-sm">
|
||||||
|
<a className="text-copper" href={`mailto:${site.email}`}>
|
||||||
|
{dict.contact.emailDirect}: {site.email}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<div className="mt-12">
|
||||||
|
<ContactForm locale={locale} dict={dict} offers={offers} defaultTopic={defaultTopic} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<aside className="border-t border-line pt-10 md:border-l md:border-t-0 md:pl-10 md:pt-0">
|
||||||
|
<p className="kicker">{dict.home.offerKicker}</p>
|
||||||
|
<div className="mt-8 grid gap-8">
|
||||||
|
{offers.map((offer) => (
|
||||||
|
<article key={offer.id}>
|
||||||
|
<h2 className="font-serif text-2xl">{offer.title}</h2>
|
||||||
|
<p className="mt-3 text-sm leading-7 text-mute-strong">{offer.text}</p>
|
||||||
|
<Link
|
||||||
|
href={`${publicPath(locale, "contact")}?topic=${offer.id}`}
|
||||||
|
className="mt-4 inline-block text-sm text-copper"
|
||||||
|
>
|
||||||
|
{dict.actions.startConversation} →
|
||||||
|
</Link>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { SiteFooter } from "@/components/site-footer";
|
||||||
|
import { SiteHeader } from "@/components/site-header";
|
||||||
|
import { getDictionary } from "@/i18n/dictionaries";
|
||||||
|
import { locales } from "@/i18n/routes";
|
||||||
|
import { requireLocale } from "@/lib/locale";
|
||||||
|
|
||||||
|
export async function generateStaticParams() {
|
||||||
|
return locales.map((lang) => ({ lang }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function LocaleLayout({
|
||||||
|
children,
|
||||||
|
params,
|
||||||
|
}: LayoutProps<"/[lang]">) {
|
||||||
|
const locale = requireLocale((await params).lang);
|
||||||
|
const dict = getDictionary(locale);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="site-shell flex min-h-full flex-col">
|
||||||
|
<SiteHeader locale={locale} dict={dict} />
|
||||||
|
<div id="content" className="flex-1">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
<SiteFooter locale={locale} dict={dict} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { getLegal } from "@/content/legal";
|
||||||
|
import { getDictionary } from "@/i18n/dictionaries";
|
||||||
|
import { pageMetadata } from "@/lib/metadata";
|
||||||
|
import { requireLocale } from "@/lib/locale";
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: PageProps<"/[lang]/legal">) {
|
||||||
|
const locale = requireLocale((await params).lang);
|
||||||
|
const dict = getDictionary(locale);
|
||||||
|
return pageMetadata(locale, "legal", dict.legal.imprintTitle, dict.legal.imprintTitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function LegalPage({ params }: PageProps<"/[lang]/legal">) {
|
||||||
|
const locale = requireLocale((await params).lang);
|
||||||
|
const dict = getDictionary(locale);
|
||||||
|
const legal = getLegal(locale);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto w-full max-w-3xl px-6 py-20 md:px-10 md:py-28">
|
||||||
|
<p className="kicker">{dict.legal.imprintKicker}</p>
|
||||||
|
<h1 className="display mt-5 text-5xl">{dict.legal.imprintTitle}</h1>
|
||||||
|
<div className="mt-12 grid gap-6">
|
||||||
|
{legal.imprint.map((paragraph) => (
|
||||||
|
<p key={paragraph.slice(0, 32)} className="leading-8 text-mute-strong">
|
||||||
|
{paragraph}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { getNote, noteSlugs } from "@/content/notes";
|
||||||
|
import { getDictionary } from "@/i18n/dictionaries";
|
||||||
|
import { locales, publicPath } from "@/i18n/routes";
|
||||||
|
import { pageMetadata } from "@/lib/metadata";
|
||||||
|
import { requireLocale } from "@/lib/locale";
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return locales.flatMap((lang) => noteSlugs.map((slug) => ({ lang, slug })));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: PageProps<"/[lang]/notes/[slug]">) {
|
||||||
|
const { lang, slug } = await params;
|
||||||
|
const locale = requireLocale(lang);
|
||||||
|
const note = getNote(slug, locale);
|
||||||
|
if (!note) notFound();
|
||||||
|
return pageMetadata(locale, "noteItem", note.title, note.summary, slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function NotePage({ params }: PageProps<"/[lang]/notes/[slug]">) {
|
||||||
|
const { lang, slug } = await params;
|
||||||
|
const locale = requireLocale(lang);
|
||||||
|
const dict = getDictionary(locale);
|
||||||
|
const note = getNote(slug, locale);
|
||||||
|
if (!note) notFound();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto w-full max-w-3xl px-6 py-20 md:px-10 md:py-28">
|
||||||
|
<Link href={publicPath(locale, "notes")} className="text-sm text-mute-strong hover:text-paper">
|
||||||
|
← {dict.actions.allNotes}
|
||||||
|
</Link>
|
||||||
|
<time className="kicker mt-10 block" dateTime={note.date}>
|
||||||
|
{new Date(note.date).toLocaleDateString(locale === "de" ? "de-DE" : "en-GB")}
|
||||||
|
</time>
|
||||||
|
<h1 className="display mt-5 text-5xl md:text-6xl">{note.title}</h1>
|
||||||
|
<p className="mt-8 text-xl leading-9 text-mute-strong">{note.summary}</p>
|
||||||
|
<div className="mt-12 grid gap-6">
|
||||||
|
{note.body.map((paragraph) => (
|
||||||
|
<p key={paragraph.slice(0, 32)} className="leading-8 text-mute-strong">
|
||||||
|
{paragraph}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { getNotes } from "@/content/notes";
|
||||||
|
import { getDictionary } from "@/i18n/dictionaries";
|
||||||
|
import { publicPath } from "@/i18n/routes";
|
||||||
|
import { pageMetadata } from "@/lib/metadata";
|
||||||
|
import { requireLocale } from "@/lib/locale";
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: PageProps<"/[lang]/notes">) {
|
||||||
|
const locale = requireLocale((await params).lang);
|
||||||
|
const dict = getDictionary(locale);
|
||||||
|
return pageMetadata(locale, "notes", dict.notes.title, dict.notes.lede);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function NotesPage({ params }: PageProps<"/[lang]/notes">) {
|
||||||
|
const locale = requireLocale((await params).lang);
|
||||||
|
const dict = getDictionary(locale);
|
||||||
|
const notes = getNotes(locale);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto w-full max-w-6xl px-6 py-20 md:px-10 md:py-28">
|
||||||
|
<p className="kicker">{dict.notes.kicker}</p>
|
||||||
|
<h1 className="display mt-5 max-w-4xl text-5xl md:text-7xl">{dict.notes.title}</h1>
|
||||||
|
<p className="mt-8 max-w-2xl text-xl leading-9 text-mute-strong">{dict.notes.lede}</p>
|
||||||
|
<div className="mt-16 grid gap-12">
|
||||||
|
{notes.map((note) => (
|
||||||
|
<article key={note.slug} className="border-t border-line pt-8">
|
||||||
|
<time className="font-mono text-xs text-mute" dateTime={note.date}>
|
||||||
|
{new Date(note.date).toLocaleDateString(locale === "de" ? "de-DE" : "en-GB")}
|
||||||
|
</time>
|
||||||
|
<h2 className="mt-4 font-serif text-3xl">{note.title}</h2>
|
||||||
|
<p className="mt-4 max-w-2xl leading-7 text-mute-strong">{note.summary}</p>
|
||||||
|
<Link href={publicPath(locale, "noteItem", note.slug)} className="mt-6 inline-block text-sm text-copper">
|
||||||
|
{dict.actions.openNote} →
|
||||||
|
</Link>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { ImageResponse } from "next/og";
|
||||||
|
import { requireLocale } from "@/lib/locale";
|
||||||
|
|
||||||
|
export const size = { width: 1200, height: 630 };
|
||||||
|
export const contentType = "image/png";
|
||||||
|
|
||||||
|
export default async function OpenGraphImage({ params }: { params: Promise<{ lang: string }> }) {
|
||||||
|
const locale = requireLocale((await params).lang);
|
||||||
|
const title =
|
||||||
|
locale === "de"
|
||||||
|
? "Wenn Zahlen zwischen Schichten verschwinden, liegt es meist an der Software."
|
||||||
|
: "When numbers vanish between shifts, it's usually the software.";
|
||||||
|
|
||||||
|
return new ImageResponse(
|
||||||
|
(
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "#12100e",
|
||||||
|
color: "#f4efe4",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "72px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", letterSpacing: "0.28em", fontSize: 22, color: "#c9843a" }}>
|
||||||
|
W-MAKE
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", maxWidth: 900 }}>
|
||||||
|
<div style={{ fontSize: 64, lineHeight: 1.05 }}>{title}</div>
|
||||||
|
<div style={{ marginTop: 28, fontSize: 28, color: "#9a9184" }}>Jan Wagner</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
size,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { FeaturedWork, WorkCard } from "@/components/work-preview";
|
||||||
|
import { HomeJsonLd } from "@/components/json-ld";
|
||||||
|
import { LinksSection } from "@/components/LinksSection";
|
||||||
|
import { getOffers } from "@/content/offers";
|
||||||
|
import { getNotes } from "@/content/notes";
|
||||||
|
import { getPerson } from "@/content/person";
|
||||||
|
import { getProjects } from "@/content/projects";
|
||||||
|
import { getDictionary } from "@/i18n/dictionaries";
|
||||||
|
import { publicPath } from "@/i18n/routes";
|
||||||
|
import { pageMetadata } from "@/lib/metadata";
|
||||||
|
import { requireLocale } from "@/lib/locale";
|
||||||
|
import { fetchLinks } from "@/lib/api-client";
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: PageProps<"/[lang]">) {
|
||||||
|
const locale = requireLocale((await params).lang);
|
||||||
|
const dict = getDictionary(locale);
|
||||||
|
const description = locale === "de"
|
||||||
|
? "Software für industrielle Prozesse, in denen Rezept, Material und Schichtübergabe lückenlos zusammengehören müssen. Wenn Zahlen zwischen Schichten verschwinden, liegt es meist an der Software."
|
||||||
|
: "Software for industrial processes where recipe, materials and shift handover must fit together seamlessly. When numbers vanish between shifts, it's usually the software.";
|
||||||
|
|
||||||
|
return pageMetadata(
|
||||||
|
locale,
|
||||||
|
"home",
|
||||||
|
locale === "de"
|
||||||
|
? "Jan Wagner · Software für industrielle Prozesse"
|
||||||
|
: "Jan Wagner · software for industrial processes",
|
||||||
|
description,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function HomePage({ params }: PageProps<"/[lang]">) {
|
||||||
|
const locale = requireLocale((await params).lang);
|
||||||
|
const dict = getDictionary(locale);
|
||||||
|
const person = getPerson(locale);
|
||||||
|
const [featured, ...rest] = getProjects(locale);
|
||||||
|
const offers = getOffers(locale);
|
||||||
|
const notes = getNotes(locale).slice(0, 4);
|
||||||
|
const links = await fetchLinks();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<HomeJsonLd />
|
||||||
|
<section className="mx-auto grid w-full max-w-6xl gap-16 px-6 py-20 md:grid-cols-[1.35fr_0.65fr] md:items-end md:px-10 md:py-28">
|
||||||
|
<div>
|
||||||
|
<p className="kicker">{dict.home.kicker}</p>
|
||||||
|
<h1 className="display mt-6 max-w-4xl text-5xl text-paper md:text-7xl">{dict.home.title}</h1>
|
||||||
|
<p className="mt-8 max-w-2xl text-lg leading-8 text-mute-strong md:text-xl">{dict.home.lede}</p>
|
||||||
|
<div className="mt-10 flex flex-wrap gap-8 text-sm">
|
||||||
|
<Link href={publicPath(locale, "work")} className="border-b border-copper pb-1 text-copper">
|
||||||
|
{dict.actions.viewWork}
|
||||||
|
</Link>
|
||||||
|
<Link href={publicPath(locale, "contact")} className="border-b border-line-strong pb-1 text-paper">
|
||||||
|
{dict.actions.startConversation}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<aside className="border-l border-copper/50 pl-6">
|
||||||
|
<p className="kicker">{dict.home.asideLabel}</p>
|
||||||
|
<p className="mt-4 font-serif text-2xl leading-snug text-paper">{dict.home.asideTitle}</p>
|
||||||
|
<p className="mt-5 text-sm leading-7 text-mute-strong">{dict.home.asideBody}</p>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="border-t border-line bg-ink-raised">
|
||||||
|
<div className="mx-auto flex w-full max-w-6xl flex-col items-start justify-between gap-6 px-6 py-12 md:flex-row md:items-center md:px-10">
|
||||||
|
<div>
|
||||||
|
<p className="font-serif text-2xl text-paper">W-Make Batch live erleben</p>
|
||||||
|
<p className="mt-2 text-sm text-mute-strong">Öffentliche Demo ohne Anmeldung · batch.w-make.com</p>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="https://batch.w-make.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-block border border-line-strong bg-transparent px-6 py-3 text-sm font-medium text-paper transition-all hover:border-copper hover:bg-copper/10"
|
||||||
|
>
|
||||||
|
Demo öffnen →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="border-t border-line">
|
||||||
|
<div className="mx-auto w-full max-w-6xl px-6 py-20 md:px-10">
|
||||||
|
<div className="mb-12 flex flex-wrap items-end justify-between gap-6">
|
||||||
|
<div>
|
||||||
|
<p className="kicker">{dict.home.workKicker}</p>
|
||||||
|
<h2 className="display mt-4 text-4xl md:text-5xl">{dict.home.workTitle}</h2>
|
||||||
|
</div>
|
||||||
|
<p className="max-w-md text-sm leading-7 text-mute-strong">{dict.home.workLede}</p>
|
||||||
|
</div>
|
||||||
|
{featured ? <FeaturedWork project={featured} locale={locale} dict={dict} /> : null}
|
||||||
|
{rest.length ? (
|
||||||
|
<div className="mt-16 grid gap-12 md:grid-cols-2">
|
||||||
|
{rest.map((project) => (
|
||||||
|
<WorkCard key={project.slug} project={project} locale={locale} dict={dict} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="border-t border-line">
|
||||||
|
<div className="mx-auto grid w-full max-w-6xl gap-12 px-6 py-20 md:grid-cols-[0.8fr_1.2fr] md:px-10">
|
||||||
|
<div>
|
||||||
|
<p className="kicker">{dict.home.methodKicker}</p>
|
||||||
|
<h2 className="display mt-4 text-4xl md:text-5xl">{dict.home.methodTitle}</h2>
|
||||||
|
<p className="mt-6 max-w-md leading-7 text-mute-strong">{dict.home.methodLede}</p>
|
||||||
|
</div>
|
||||||
|
<ol className="grid gap-8">
|
||||||
|
{person.principles.map((principle, index) => (
|
||||||
|
<li key={principle.title} className="grid gap-3 border-b border-line pb-8 md:grid-cols-[3rem_1fr]">
|
||||||
|
<span className="font-mono text-sm text-copper">0{index + 1}</span>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-serif text-2xl">{principle.title}</h3>
|
||||||
|
<p className="mt-2 max-w-xl leading-7 text-mute-strong">{principle.text}</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="border-t border-line">
|
||||||
|
<div className="mx-auto w-full max-w-6xl px-6 py-20 md:px-10">
|
||||||
|
<p className="kicker">{dict.home.offerKicker}</p>
|
||||||
|
<h2 className="display mt-4 max-w-2xl text-4xl md:text-5xl">{dict.home.offerTitle}</h2>
|
||||||
|
<div className="mt-12 grid gap-10 md:grid-cols-3">
|
||||||
|
{offers.map((offer, index) => (
|
||||||
|
<article key={offer.id} className="border-l border-line-strong pl-5">
|
||||||
|
<span className="font-mono text-xs text-copper">0{index + 1}</span>
|
||||||
|
<h3 className="mt-4 font-serif text-2xl">{offer.title}</h3>
|
||||||
|
<p className="mt-3 text-sm leading-7 text-mute-strong">{offer.text}</p>
|
||||||
|
<Link
|
||||||
|
href={`${publicPath(locale, "contact")}?topic=${offer.id}`}
|
||||||
|
className="mt-5 inline-block text-sm text-copper"
|
||||||
|
>
|
||||||
|
{dict.actions.startConversation} →
|
||||||
|
</Link>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="border-t border-line">
|
||||||
|
<div className="mx-auto w-full max-w-6xl px-6 py-20 md:px-10">
|
||||||
|
<div className="flex flex-wrap items-end justify-between gap-6">
|
||||||
|
<div>
|
||||||
|
<p className="kicker">{dict.home.notesKicker}</p>
|
||||||
|
<h2 className="display mt-4 text-4xl md:text-5xl">{dict.home.notesTitle}</h2>
|
||||||
|
</div>
|
||||||
|
<Link href={publicPath(locale, "notes")} className="text-sm text-copper">
|
||||||
|
{dict.actions.allNotes} →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="mt-12 grid gap-10 md:grid-cols-2">
|
||||||
|
{notes.map((note) => (
|
||||||
|
<article key={note.slug}>
|
||||||
|
<time className="font-mono text-xs text-mute" dateTime={note.date}>
|
||||||
|
{new Date(note.date).toLocaleDateString(locale === "de" ? "de-DE" : "en-GB")}
|
||||||
|
</time>
|
||||||
|
<h3 className="mt-3 font-serif text-2xl">{note.title}</h3>
|
||||||
|
<p className="mt-3 text-sm leading-7 text-mute-strong">{note.summary}</p>
|
||||||
|
<Link
|
||||||
|
href={publicPath(locale, "noteItem", note.slug)}
|
||||||
|
className="mt-5 inline-block text-sm text-copper"
|
||||||
|
>
|
||||||
|
{dict.actions.openNote} →
|
||||||
|
</Link>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<LinksSection
|
||||||
|
links={links}
|
||||||
|
dict={{
|
||||||
|
kicker: dict.home.linksKicker,
|
||||||
|
title: dict.home.linksTitle,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section className="border-t border-line">
|
||||||
|
<div className="mx-auto w-full max-w-6xl px-6 py-20 md:px-10">
|
||||||
|
<div className="border-2 border-copper/30 bg-copper/5 px-8 py-12 md:px-12">
|
||||||
|
<p className="kicker">{dict.home.ctaKicker}</p>
|
||||||
|
<h2 className="display mt-4 max-w-3xl text-4xl md:text-6xl">{dict.home.ctaTitle}</h2>
|
||||||
|
<p className="mt-6 max-w-2xl leading-7 text-mute-strong">{dict.home.ctaBody}</p>
|
||||||
|
<ul className="mt-8 space-y-3 max-w-2xl">
|
||||||
|
{dict.home.ctaBenefits.map((benefit) => (
|
||||||
|
<li key={benefit} className="flex items-start gap-3 text-sm leading-7 text-mute-strong">
|
||||||
|
<span className="text-copper">✓</span>
|
||||||
|
<span>{benefit}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<Link
|
||||||
|
href={publicPath(locale, "contact")}
|
||||||
|
className="mt-8 inline-block border-2 border-copper bg-copper px-6 py-3 text-sm font-medium text-ink transition-all hover:bg-copper-deep hover:border-copper-deep"
|
||||||
|
>
|
||||||
|
{dict.actions.startConversation}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { getLegal } from "@/content/legal";
|
||||||
|
import { getDictionary } from "@/i18n/dictionaries";
|
||||||
|
import { pageMetadata } from "@/lib/metadata";
|
||||||
|
import { requireLocale } from "@/lib/locale";
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: PageProps<"/[lang]/privacy">) {
|
||||||
|
const locale = requireLocale((await params).lang);
|
||||||
|
const dict = getDictionary(locale);
|
||||||
|
return pageMetadata(locale, "privacy", dict.legal.privacyTitle, dict.legal.privacyTitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function PrivacyPage({ params }: PageProps<"/[lang]/privacy">) {
|
||||||
|
const locale = requireLocale((await params).lang);
|
||||||
|
const dict = getDictionary(locale);
|
||||||
|
const legal = getLegal(locale);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto w-full max-w-3xl px-6 py-20 md:px-10 md:py-28">
|
||||||
|
<p className="kicker">{dict.legal.privacyKicker}</p>
|
||||||
|
<h1 className="display mt-5 text-5xl">{dict.legal.privacyTitle}</h1>
|
||||||
|
<div className="mt-12 grid gap-6">
|
||||||
|
{legal.privacy.map((paragraph) => (
|
||||||
|
<p key={paragraph.slice(0, 32)} className="leading-8 text-mute-strong">
|
||||||
|
{paragraph}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { ProcessRail } from "@/components/process-rail";
|
||||||
|
import { getProject, projectSlugs } from "@/content/projects";
|
||||||
|
import { getDictionary } from "@/i18n/dictionaries";
|
||||||
|
import { locales, publicPath } from "@/i18n/routes";
|
||||||
|
import { pageMetadata } from "@/lib/metadata";
|
||||||
|
import { requireLocale } from "@/lib/locale";
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return locales.flatMap((lang) => projectSlugs.map((slug) => ({ lang, slug })));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: PageProps<"/[lang]/work/[slug]">) {
|
||||||
|
const { lang, slug } = await params;
|
||||||
|
const locale = requireLocale(lang);
|
||||||
|
const project = getProject(slug, locale);
|
||||||
|
if (!project) notFound();
|
||||||
|
return pageMetadata(locale, "workItem", project.title, project.summary, slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function CaseStudyPage({ params }: PageProps<"/[lang]/work/[slug]">) {
|
||||||
|
const { lang, slug } = await params;
|
||||||
|
const locale = requireLocale(lang);
|
||||||
|
const dict = getDictionary(locale);
|
||||||
|
const project = getProject(slug, locale);
|
||||||
|
if (!project) notFound();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto w-full max-w-6xl px-6 py-20 md:px-10 md:py-28">
|
||||||
|
<Link href={publicPath(locale, "work")} className="text-sm text-mute-strong hover:text-paper">
|
||||||
|
← {dict.actions.backToWork}
|
||||||
|
</Link>
|
||||||
|
<p className="kicker mt-10">{project.label}</p>
|
||||||
|
<h1 className="display mt-5 max-w-4xl text-5xl md:text-7xl">{project.title}</h1>
|
||||||
|
<p className="mt-8 max-w-3xl text-xl leading-9 text-mute-strong">{project.summary}</p>
|
||||||
|
<dl className="mt-10 grid gap-6 border-y border-line py-8 text-sm md:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<dt className="font-mono text-xs uppercase tracking-[0.14em] text-mute">{dict.case.role}</dt>
|
||||||
|
<dd className="mt-2 text-paper">{project.role}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="font-mono text-xs uppercase tracking-[0.14em] text-mute">Stack</dt>
|
||||||
|
<dd className="mt-2 text-paper">{project.stack}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="font-mono text-xs uppercase tracking-[0.14em] text-mute">{dict.case.proof}</dt>
|
||||||
|
<dd className="mt-2 text-copper">{project.status}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<section className="grid gap-10 border-b border-line py-16 md:grid-cols-2">
|
||||||
|
<h2 className="font-serif text-3xl">{dict.case.situation}</h2>
|
||||||
|
<div className="grid gap-5">
|
||||||
|
{project.situation.map((paragraph) => (
|
||||||
|
<p key={paragraph.slice(0, 32)} className="leading-8 text-mute-strong">
|
||||||
|
{paragraph}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="border-b border-line py-16">
|
||||||
|
<h2 className="font-serif text-3xl">{dict.case.decisions}</h2>
|
||||||
|
<ol className="mt-8 grid gap-5">
|
||||||
|
{project.decisions.map((decision, index) => (
|
||||||
|
<li key={decision} className="grid gap-3 md:grid-cols-[3rem_1fr]">
|
||||||
|
<span className="font-mono text-sm text-copper">0{index + 1}</span>
|
||||||
|
<p className="leading-7 text-mute-strong">{decision}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{project.images.length ? (
|
||||||
|
<section className="border-b border-line py-16">
|
||||||
|
<p className="kicker">{dict.case.proof}</p>
|
||||||
|
<h2 className="mt-4 font-serif text-3xl md:text-4xl">{dict.case.galleryTitle}</h2>
|
||||||
|
<div className="mt-10 grid gap-8">
|
||||||
|
{project.images.map((image) => (
|
||||||
|
<figure key={image.src} className="overflow-hidden border border-line bg-ink-raised">
|
||||||
|
<Image
|
||||||
|
src={image.src}
|
||||||
|
alt={image[locale]}
|
||||||
|
width={1440}
|
||||||
|
height={900}
|
||||||
|
className="block h-auto w-full"
|
||||||
|
/>
|
||||||
|
<figcaption className="px-6 py-4 text-sm leading-6 text-mute-strong">{image[locale]}</figcaption>
|
||||||
|
</figure>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<section className="py-16">
|
||||||
|
<p className="kicker">{dict.case.decisions}</p>
|
||||||
|
<h2 className="mt-4 font-serif text-3xl md:text-4xl">{dict.case.modelTitle}</h2>
|
||||||
|
<div className="mt-10">
|
||||||
|
<ProcessRail stages={project.stages} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-10 border-t border-line py-16 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-serif text-3xl">{dict.case.outcome}</h2>
|
||||||
|
<div className="mt-4 grid gap-5">
|
||||||
|
{project.outcome.map((paragraph) => (
|
||||||
|
<p key={paragraph.slice(0, 32)} className="leading-8 text-mute-strong">
|
||||||
|
{paragraph}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-serif text-3xl">{dict.case.lesson}</h2>
|
||||||
|
<p className="mt-4 leading-8 text-mute-strong">{project.lesson}</p>
|
||||||
|
{project.href ? (
|
||||||
|
<a href={project.href} className="mt-6 inline-block text-sm text-copper" rel="noreferrer">
|
||||||
|
{project.href.replace(/^https?:\/\//, "")} →
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="border-t border-line pt-16">
|
||||||
|
<div className="border border-copper/30 bg-copper/5 px-8 py-10">
|
||||||
|
<p className="kicker">{dict.home.ctaKicker}</p>
|
||||||
|
<h2 className="display mt-4 max-w-3xl text-3xl md:text-5xl">{dict.home.ctaTitle}</h2>
|
||||||
|
<Link
|
||||||
|
href={publicPath(locale, "contact")}
|
||||||
|
className="mt-8 inline-block border border-copper bg-copper px-6 py-3 text-sm font-medium text-ink"
|
||||||
|
>
|
||||||
|
{dict.actions.startConversation}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { getProjects } from "@/content/projects";
|
||||||
|
import { getDictionary } from "@/i18n/dictionaries";
|
||||||
|
import { publicPath } from "@/i18n/routes";
|
||||||
|
import { pageMetadata } from "@/lib/metadata";
|
||||||
|
import { requireLocale } from "@/lib/locale";
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: PageProps<"/[lang]/work">) {
|
||||||
|
const locale = requireLocale((await params).lang);
|
||||||
|
const dict = getDictionary(locale);
|
||||||
|
return pageMetadata(locale, "work", dict.work.title, dict.work.lede);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function WorkPage({ params }: PageProps<"/[lang]/work">) {
|
||||||
|
const locale = requireLocale((await params).lang);
|
||||||
|
const dict = getDictionary(locale);
|
||||||
|
const projects = getProjects(locale);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto w-full max-w-6xl px-6 py-20 md:px-10 md:py-28">
|
||||||
|
<p className="kicker">{dict.work.kicker}</p>
|
||||||
|
<h1 className="display mt-5 max-w-4xl text-5xl md:text-7xl">{dict.work.title}</h1>
|
||||||
|
<p className="mt-8 max-w-2xl text-xl leading-9 text-mute-strong">{dict.work.lede}</p>
|
||||||
|
<div className="mt-16 grid gap-16">
|
||||||
|
{projects.map((project) => {
|
||||||
|
const image = project.images[0];
|
||||||
|
return (
|
||||||
|
<article key={project.slug} className="grid gap-8 border-t border-line pt-10 md:grid-cols-[0.9fr_1.1fr] md:items-start">
|
||||||
|
{image ? (
|
||||||
|
<figure className="overflow-hidden border border-line bg-ink-raised">
|
||||||
|
<Image src={image.src} alt={image[locale]} width={1440} height={900} className="block h-auto w-full" />
|
||||||
|
</figure>
|
||||||
|
) : (
|
||||||
|
<div />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="font-mono text-xs text-copper">{project.number}</p>
|
||||||
|
<p className="kicker mt-4">{project.label}</p>
|
||||||
|
<h2 className="mt-4 font-serif text-4xl">{project.title}</h2>
|
||||||
|
<p className="mt-3 text-sm text-copper">{project.stance}</p>
|
||||||
|
<p className="mt-5 max-w-2xl leading-8 text-mute-strong">{project.summary}</p>
|
||||||
|
<p className="mt-6 text-sm text-mute">{project.stack}</p>
|
||||||
|
<div className="mt-8 flex flex-wrap gap-6 text-sm">
|
||||||
|
<Link
|
||||||
|
href={publicPath(locale, "workItem", project.slug)}
|
||||||
|
className="border-b border-copper pb-1 text-copper"
|
||||||
|
>
|
||||||
|
{dict.actions.openCase} →
|
||||||
|
</Link>
|
||||||
|
{project.href ? (
|
||||||
|
<a href={project.href} className="border-b border-line-strong pb-1 text-paper" rel="noreferrer">
|
||||||
|
{project.href.replace(/^https?:\/\//, "")} →
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json({
|
||||||
|
status: "ok",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
service: "w-make-portfolio",
|
||||||
|
});
|
||||||
|
}
|
||||||
+134
-32
@@ -1,45 +1,147 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* Tech Style Dark Theme by Default */
|
--ink: #12100e;
|
||||||
--background: #020617; /* Slate 950 */
|
--ink-raised: #1b1814;
|
||||||
--foreground: #f8fafc; /* Slate 50 */
|
--paper: #f4efe4;
|
||||||
|
--paper-dim: #e4dccb;
|
||||||
/* Primary Action Colors (Electric Blue / Cyan) */
|
--copper: #c9843a;
|
||||||
--primary: #0ea5e9;
|
--copper-deep: #a86b2a;
|
||||||
--primary-foreground: #ffffff;
|
--mute: #9a9184;
|
||||||
|
--mute-strong: #c9c1b3;
|
||||||
/* Metallic / Professional Accents */
|
--line: rgba(244, 239, 228, 0.12);
|
||||||
--accent: #334155; /* Slate 700 */
|
--line-strong: rgba(244, 239, 228, 0.22);
|
||||||
--accent-foreground: #f8fafc;
|
|
||||||
|
|
||||||
--border: #1e293b; /* Slate 800 */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-ink: var(--ink);
|
||||||
--color-foreground: var(--foreground);
|
--color-ink-raised: var(--ink-raised);
|
||||||
--color-primary: var(--primary);
|
--color-paper: var(--paper);
|
||||||
--color-primary-foreground: var(--primary-foreground);
|
--color-paper-dim: var(--paper-dim);
|
||||||
--color-accent: var(--accent);
|
--color-copper: var(--copper);
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
--color-copper-deep: var(--copper-deep);
|
||||||
--color-border: var(--border);
|
--color-mute: var(--mute);
|
||||||
|
--color-mute-strong: var(--mute-strong);
|
||||||
|
--color-line: var(--line);
|
||||||
--font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
|
--font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--font-serif: var(--font-newsreader), "Iowan Old Style", "Palatino Linotype", serif;
|
||||||
--font-mono: var(--font-geist-mono), ui-monospace, SFMono-Regular, monospace;
|
--font-mono: var(--font-geist-mono), ui-monospace, SFMono-Regular, monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
html {
|
||||||
background-color: var(--background);
|
scroll-behavior: smooth;
|
||||||
color: var(--foreground);
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Subtle Grid Background for that "Tech" feel */
|
body {
|
||||||
.bg-grid-pattern {
|
background: var(--ink);
|
||||||
background-image: linear-gradient(to right, rgba(255, 255, 255, 0.05) 1px, transparent 1px),
|
color: var(--paper);
|
||||||
linear-gradient(to bottom, rgba(255, 255, 255, 0.05) 1px, transparent 1px);
|
font-family: var(--font-sans);
|
||||||
background-size: 40px 40px;
|
-webkit-font-smoothing: antialiased;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: rgba(201, 132, 58, 0.35);
|
||||||
|
color: var(--paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-shell {
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-shell::before {
|
||||||
|
content: "";
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(1200px 500px at 10% -10%, rgba(201, 132, 58, 0.08), transparent 55%),
|
||||||
|
linear-gradient(180deg, rgba(244, 239, 228, 0.03), transparent 28%);
|
||||||
|
z-index: -2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-shell::after {
|
||||||
|
content: "";
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.35'/%3E%3C/svg%3E");
|
||||||
|
opacity: 0.035;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kicker {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
letter-spacing: 0.22em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--copper);
|
||||||
|
}
|
||||||
|
|
||||||
|
.display {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-weight: 450;
|
||||||
|
letter-spacing: -0.035em;
|
||||||
|
line-height: 0.96;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-quiet {
|
||||||
|
color: var(--mute-strong);
|
||||||
|
transition: color 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-quiet:hover {
|
||||||
|
color: var(--paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--copper);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-link {
|
||||||
|
position: absolute;
|
||||||
|
left: 1rem;
|
||||||
|
top: -4rem;
|
||||||
|
z-index: 50;
|
||||||
|
background: var(--copper);
|
||||||
|
color: var(--ink);
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-link:focus {
|
||||||
|
top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.honeypot {
|
||||||
|
position: absolute;
|
||||||
|
left: -9999px;
|
||||||
|
height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile-optimized touch targets */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.display {
|
||||||
|
font-size: clamp(2rem, 8vw, 4rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure all interactive elements are at least 44x44px */
|
||||||
|
a, button, input, select, textarea {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reduce excessive whitespace on mobile */
|
||||||
|
section {
|
||||||
|
padding-top: clamp(2rem, 5vw, 5rem) !important;
|
||||||
|
padding-bottom: clamp(2rem, 5vw, 5rem) !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-8
@@ -1,5 +1,8 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { headers } from "next/headers";
|
||||||
|
import { Geist, Geist_Mono, Newsreader } from "next/font/google";
|
||||||
|
import { site } from "@/content/site";
|
||||||
|
import { isLocale } from "@/i18n/routes";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
@@ -12,17 +15,34 @@ const geistMono = Geist_Mono({
|
|||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const newsreader = Newsreader({
|
||||||
|
variable: "--font-newsreader",
|
||||||
|
subsets: ["latin"],
|
||||||
|
style: ["normal", "italic"],
|
||||||
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Jan Wagner — Software für anspruchsvolle Prozesse",
|
metadataBase: new URL(site.url),
|
||||||
description: "Portfolio von Jan Wagner: industrielle Software, Full-Stack Engineering, AI-Tooling und Glass Technology.",
|
title: {
|
||||||
|
default: "Jan Wagner · Software für Satzzettel, Silo und Schichtabschluss",
|
||||||
|
template: "%s · W-MAKE",
|
||||||
|
},
|
||||||
|
description:
|
||||||
|
"Jan Wagner entwickelt Software für Rezept, Satzzettel, Siloabzug und Tagesabschluss in der Behälterglas-Produktion. Deutschland, remote und vor Ort.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }: LayoutProps<"/">) {
|
export default async function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{ children: React.ReactNode }>) {
|
||||||
|
const headerLocale = (await headers()).get("x-locale");
|
||||||
|
const lang = headerLocale && isLocale(headerLocale) ? headerLocale : "de";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="de" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}>
|
<html
|
||||||
<body className="min-h-full flex flex-col">{children}</body>
|
lang={lang}
|
||||||
|
className={`${geistSans.variable} ${geistMono.variable} ${newsreader.variable} h-full antialiased`}
|
||||||
|
>
|
||||||
|
<body className="min-h-full bg-ink text-paper">{children}</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ponytail: Open Graph, structured data and canonical URL follow after the public domain is final.
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<main className="mx-auto flex min-h-[70vh] max-w-3xl flex-col justify-center px-6">
|
||||||
|
<p className="kicker">404</p>
|
||||||
|
<h1 className="display mt-4 text-5xl">Seite nicht gefunden.</h1>
|
||||||
|
<p className="mt-6 text-mute-strong">Die Adresse existiert nicht oder wurde verschoben.</p>
|
||||||
|
<Link href="/" className="mt-8 text-copper">
|
||||||
|
Zur Startseite →
|
||||||
|
</Link>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
type Project = {
|
|
||||||
number: string;
|
|
||||||
label: string;
|
|
||||||
title: string;
|
|
||||||
summary: string;
|
|
||||||
details: string;
|
|
||||||
role: string;
|
|
||||||
stack: string;
|
|
||||||
status: string;
|
|
||||||
href: string;
|
|
||||||
cta: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Update = {
|
|
||||||
slug: string;
|
|
||||||
product: "batchmaker" | "standalone";
|
|
||||||
title: string;
|
|
||||||
summary: string;
|
|
||||||
published_at: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
async function getUpdates(): Promise<Update[]> {
|
|
||||||
const baseUrl = process.env.UPDATES_API_URL || "http://updates-api:8080";
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${baseUrl}/v1/updates?limit=3`, {
|
|
||||||
next: { revalidate: 60 },
|
|
||||||
signal: AbortSignal.timeout(1500),
|
|
||||||
});
|
|
||||||
if (!response.ok) return [];
|
|
||||||
const payload = await response.json() as { data?: Update[] };
|
|
||||||
return payload.data || [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const projects: Project[] = [
|
|
||||||
{
|
|
||||||
number: "01",
|
|
||||||
label: "Industrie-Software · Produktentwicklung",
|
|
||||||
title: "W-Make Batch",
|
|
||||||
summary: "Batchmanagement für die Behälterglas-Produktion — vom Satzzettel bis zum Tagesprotokoll.",
|
|
||||||
details: "Das System bildet eine reale Produktionskette ab: Rezepte, Materialverbrauch, Silos, Schichten und Berechnungen bleiben fachlich nachvollziehbar verbunden. Der Schwerpunkt liegt nicht auf möglichst vielen Screens, sondern auf sicheren Zuständen und belastbaren Buchungen.",
|
|
||||||
role: "Domänenmodell, Full-Stack-Entwicklung und technische Qualität",
|
|
||||||
stack: "Node.js · Express · SQLite · Web Components · native Tests · Playwright",
|
|
||||||
status: "öffentliche Produkt-Demo",
|
|
||||||
href: "https://batch.w-make.com",
|
|
||||||
cta: "Produkt-Showroom öffnen",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
number: "02",
|
|
||||||
label: "Standalone-Produkt · Recipe Engineering",
|
|
||||||
title: "Batchmaker Studio",
|
|
||||||
summary: "Schlankes Recipe-Studio für Rezepte, Rohstoffe, Satzzettel und Glaschemie.",
|
|
||||||
details: "Die eigenständig betreibbare Variante konzentriert sich auf Rezeptentwicklung und Berechnung — ohne den vollständigen Produktions- und Silo-Overhead von W-Make Batch.",
|
|
||||||
role: "Produktarchitektur, Full-Stack-Entwicklung und Calculation Engine",
|
|
||||||
stack: "Node.js · Express · node:sqlite · Vanilla JS · Vite · native Tests",
|
|
||||||
status: "eigenständig betreibbares Produkt",
|
|
||||||
href: "/projects/batchmaker-studio",
|
|
||||||
cta: "Case Study öffnen",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
number: "03",
|
|
||||||
label: "Domänenwissen · Systems Thinking",
|
|
||||||
title: "Glas-Technologie als Engineering-Vorteil",
|
|
||||||
summary: "Technische Software entsteht besser, wenn der Prozess nicht erst nachträglich erklärt werden muss.",
|
|
||||||
details: "Der Hintergrund in der Behälterglas-Produktion prägt die Art, wie Systeme entworfen werden: Materialflüsse, Messwerte, Zeitbezug und irreversible fachliche Entscheidungen werden als Teil der Architektur behandelt — nicht als Randnotiz im UI.",
|
|
||||||
role: "Domänenanalyse, Modellbildung und Übersetzung in Software",
|
|
||||||
stack: "Prozessverständnis · Datenmodelle · Berechnungen · Dokumentation",
|
|
||||||
status: "dauerhafter fachlicher Schwerpunkt",
|
|
||||||
href: "#kontakt",
|
|
||||||
cta: "Ähnlichen Prozess besprechen",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const principles = [
|
|
||||||
["Fachlichkeit zuerst", "Ich beginne beim realen Prozess, nicht beim Framework. Fachliche Regeln werden sichtbar, prüfbar und nachvollziehbar umgesetzt."],
|
|
||||||
["Verlässliche Zustände", "Buchungen, Migrationen und Fehlerpfade werden so entworfen, dass ein System auch unter realen Bedingungen verständlich bleibt."],
|
|
||||||
["Kleine, tragfähige Lösungen", "Keine Architektur um ihrer selbst willen. Erst die kleinste Lösung, die den Prozess dauerhaft besser macht."],
|
|
||||||
];
|
|
||||||
|
|
||||||
const offers = [
|
|
||||||
["Prozessanalyse", "Bestehende Abläufe verstehen, Bruchstellen sichtbar machen und eine priorisierte technische Roadmap ableiten."],
|
|
||||||
["Produkt- und Backend-Entwicklung", "Anwendungen entwickeln oder stabilisieren, bei denen Datenmodell und Geschäftslogik wichtiger sind als schnelle CRUD-Oberflächen."],
|
|
||||||
["Technische Zusammenarbeit", "Architekturentscheidungen, Integrationen und gewachsenen Code gemeinsam in wartbare Systeme überführen."],
|
|
||||||
];
|
|
||||||
|
|
||||||
export default async function Home() {
|
|
||||||
const updates = await getUpdates();
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-grid-pattern">
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-background/90 to-background pointer-events-none" />
|
|
||||||
<div className="relative z-10 mx-auto w-full max-w-6xl px-6 py-8 md:px-10 md:py-12">
|
|
||||||
<nav className="flex items-center justify-between border-b border-slate-800/80 pb-6" aria-label="Hauptnavigation">
|
|
||||||
<a href="#top" className="font-mono text-sm tracking-[0.22em] text-primary">W-MAKE</a>
|
|
||||||
<div className="flex gap-4 text-sm text-slate-400 md:gap-6">
|
|
||||||
<a href="#projekte" className="hover:text-white">Projekte</a>
|
|
||||||
<a href="#arbeitsweise" className="hover:text-white">Arbeitsweise</a>
|
|
||||||
<a href="#kontakt" className="hover:text-white">Kontakt</a>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<section id="top" className="grid gap-12 py-24 md:grid-cols-[1.25fr_0.75fr] md:items-end md:py-32">
|
|
||||||
<div>
|
|
||||||
<p className="mb-6 font-mono text-sm uppercase tracking-[0.22em] text-primary">Jan Wagner · Software Engineering</p>
|
|
||||||
<h1 className="max-w-4xl text-5xl font-semibold tracking-tight text-white md:text-7xl">Software für Prozesse, auf die man sich verlassen muss.</h1>
|
|
||||||
<p className="mt-8 max-w-2xl text-lg leading-8 text-slate-300 md:text-xl">Ich entwickle robuste digitale Systeme für industrielle und anspruchsvolle Geschäftsprozesse — mit Domänenverständnis, klarer Architektur und Verantwortung für den Betrieb.</p>
|
|
||||||
<div className="mt-10 flex flex-wrap gap-4">
|
|
||||||
<a href="#projekte" className="rounded-full bg-primary px-6 py-3 font-medium text-slate-950 transition hover:bg-cyan-300">Arbeiten ansehen</a>
|
|
||||||
<a href="#kontakt" className="rounded-full border border-slate-700 px-6 py-3 font-medium text-white transition hover:border-primary hover:text-primary">Projekt besprechen</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<aside className="border-l border-primary/50 pl-6 text-sm leading-7 text-slate-400">
|
|
||||||
<p className="font-mono text-primary">WAS ICH VERBINDE</p>
|
|
||||||
<p className="mt-4 text-base text-slate-200">Glass Technology, Full-Stack Engineering und Systems Thinking.</p>
|
|
||||||
<p className="mt-6">Der gemeinsame Nenner: reale Abläufe in Software übersetzen, ohne die fachliche Wahrheit zu verlieren.</p>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="projekte" className="scroll-mt-8 border-t border-slate-800 py-20">
|
|
||||||
<div className="mb-10 flex flex-wrap items-end justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<p className="font-mono text-sm uppercase tracking-[0.22em] text-primary">Ausgewählte Arbeit</p>
|
|
||||||
<h2 className="mt-3 text-3xl font-semibold text-white md:text-4xl">Nicht nur Features. Systeme.</h2>
|
|
||||||
</div>
|
|
||||||
<p className="max-w-md text-sm leading-6 text-slate-400">Jedes Projekt zeigt eine andere Seite derselben Arbeit: Domäne verstehen, Entscheidungen explizit machen und ein System in der Realität betreiben.</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-5 md:grid-cols-3">
|
|
||||||
{projects.map((project) => (
|
|
||||||
<article key={project.title} className="flex flex-col rounded-2xl border border-slate-800 bg-slate-950/60 p-7 transition hover:-translate-y-1 hover:border-primary/60">
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<p className="font-mono text-xs uppercase tracking-[0.16em] text-primary">{project.label}</p>
|
|
||||||
<span className="font-mono text-xs text-slate-600">{project.number}</span>
|
|
||||||
</div>
|
|
||||||
<h3 className="mt-5 text-2xl font-semibold text-white">{project.title}</h3>
|
|
||||||
<p className="mt-4 text-sm leading-7 text-slate-300">{project.summary}</p>
|
|
||||||
<p className="mt-4 flex-1 text-sm leading-7 text-slate-500">{project.details}</p>
|
|
||||||
<div className="mt-6 space-y-3 border-t border-slate-800 pt-4 text-xs leading-5">
|
|
||||||
<p><span className="font-mono uppercase text-slate-600">Rolle</span><br /><span className="text-slate-400">{project.role}</span></p>
|
|
||||||
<p><span className="font-mono uppercase text-slate-600">Werkzeuge</span><br /><span className="text-slate-400">{project.stack}</span></p>
|
|
||||||
<p><span className="font-mono uppercase text-slate-600">Status</span><br /><span className="text-primary/80">{project.status}</span></p>
|
|
||||||
</div>
|
|
||||||
<a href={project.href} className="mt-6 text-sm font-medium text-primary hover:text-cyan-300">{project.cta} <span aria-hidden="true">→</span></a>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="batchmaker-studio" className="scroll-mt-8 border-t border-slate-800 py-20">
|
|
||||||
<div className="grid gap-10 md:grid-cols-[0.75fr_1.25fr] md:items-start">
|
|
||||||
<div>
|
|
||||||
<p className="font-mono text-sm uppercase tracking-[0.22em] text-primary">Batchmaker Studio</p>
|
|
||||||
<h2 className="mt-3 text-3xl font-semibold text-white md:text-4xl">Recipe Engineering ohne Produktions-Overhead.</h2>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-6 text-sm leading-7 text-slate-400 md:grid-cols-2">
|
|
||||||
<p>Studio fokussiert die fachliche Arbeit vor der Produktion: Rezepte entwickeln, Rohstoffanalysen verwalten, Glaschemie berechnen und Satzzettel druckfertig machen.</p>
|
|
||||||
<p>Die eigenständige Variante nutzt denselben Domänenkern, bleibt aber bewusst kleiner: eigener Express-Server, eigene SQLite-Datenbank und eine schlanke Browser-Oberfläche.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-10 grid gap-5 md:grid-cols-3">
|
|
||||||
{[
|
|
||||||
["Rezepte", "Versionieren, vergleichen, importieren und exportieren."],
|
|
||||||
["Glaschemie", "Oxide, Redox, Physik, Spektral- und NNLS-Berechnungen."],
|
|
||||||
["Satzzettel", "Entwürfe, Skalierung, Vorschau und Schmelzer-Druck."],
|
|
||||||
].map(([title, text]) => (
|
|
||||||
<div key={title} className="rounded-2xl border border-slate-800 bg-slate-950/50 p-6">
|
|
||||||
<h3 className="text-lg font-medium text-white">{title}</h3>
|
|
||||||
<p className="mt-3 text-sm leading-6 text-slate-400">{text}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="mt-8 flex flex-wrap items-center gap-5 text-xs text-slate-500">
|
|
||||||
<p>Status: eigenständig betreibbares Produkt · Browser-Ansichten verifiziert.</p>
|
|
||||||
<a href="/projects/batchmaker-studio" className="text-primary hover:text-cyan-300">Zur Case Study →</a>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="updates" className="scroll-mt-8 border-t border-slate-800 py-20">
|
|
||||||
<p className="font-mono text-sm uppercase tracking-[0.22em] text-primary">Produkt-Updates</p>
|
|
||||||
<h2 className="mt-3 text-3xl font-semibold text-white md:text-4xl">Was sich bei Batchmaker bewegt.</h2>
|
|
||||||
{updates.length ? (
|
|
||||||
<div className="mt-10 grid gap-5 md:grid-cols-3">
|
|
||||||
{updates.map((update) => (
|
|
||||||
<article key={update.slug} className="rounded-2xl border border-slate-800 bg-slate-950/50 p-6">
|
|
||||||
<p className="font-mono text-xs uppercase tracking-[0.16em] text-primary">{update.product === "standalone" ? "Standalone" : "Batchmaker"}</p>
|
|
||||||
<h3 className="mt-4 text-xl font-medium text-white">{update.title}</h3>
|
|
||||||
<p className="mt-3 text-sm leading-6 text-slate-400">{update.summary}</p>
|
|
||||||
<time className="mt-5 block text-xs text-slate-600" dateTime={update.published_at}>{new Date(update.published_at).toLocaleDateString("de-DE")}</time>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="mt-8 max-w-xl text-sm leading-6 text-slate-500">Noch keine veröffentlichten Updates. Neue Releases und fachliche Fortschritte erscheinen hier.</p>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="arbeitsweise" className="scroll-mt-8 border-t border-slate-800 py-20">
|
|
||||||
<div className="grid gap-12 md:grid-cols-[0.8fr_1.2fr]">
|
|
||||||
<div>
|
|
||||||
<p className="font-mono text-sm uppercase tracking-[0.22em] text-primary">Arbeitsweise</p>
|
|
||||||
<h2 className="mt-3 text-3xl font-semibold text-white md:text-4xl">Senior heißt: Entscheidungen verantworten.</h2>
|
|
||||||
<p className="mt-6 max-w-md leading-7 text-slate-400">Technologie ist Mittel zum Zweck. Entscheidend ist, ob ein System die richtigen fachlichen Zustände abbildet und im Alltag erklärbar bleibt.</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-8">
|
|
||||||
{principles.map(([title, text], index) => (
|
|
||||||
<div key={title} className="grid gap-3 border-b border-slate-800 pb-7 md:grid-cols-[3rem_1fr]">
|
|
||||||
<span className="font-mono text-sm text-primary">0{index + 1}</span>
|
|
||||||
<div><h3 className="text-xl font-medium text-white">{title}</h3><p className="mt-2 max-w-xl leading-7 text-slate-400">{text}</p></div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="leistungen" className="scroll-mt-8 border-t border-slate-800 py-20">
|
|
||||||
<p className="font-mono text-sm uppercase tracking-[0.22em] text-primary">Zusammenarbeit</p>
|
|
||||||
<h2 className="mt-3 max-w-2xl text-3xl font-semibold text-white md:text-4xl">Wo ich am meisten beitragen kann.</h2>
|
|
||||||
<div className="mt-10 grid gap-5 md:grid-cols-3">
|
|
||||||
{offers.map(([title, text], index) => (
|
|
||||||
<div key={title} className="border-l border-slate-700 pl-5">
|
|
||||||
<span className="font-mono text-xs text-primary">0{index + 1}</span>
|
|
||||||
<h3 className="mt-4 text-xl font-medium text-white">{title}</h3>
|
|
||||||
<p className="mt-3 text-sm leading-7 text-slate-400">{text}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="kontakt" className="scroll-mt-8 border-t border-slate-800 py-20">
|
|
||||||
<div className="rounded-3xl border border-primary/30 bg-primary/5 p-8 md:p-12">
|
|
||||||
<p className="font-mono text-sm uppercase tracking-[0.22em] text-primary">Kontakt</p>
|
|
||||||
<h2 className="mt-4 max-w-2xl text-3xl font-semibold text-white md:text-5xl">Sie haben einen Prozess, der bessere Software verdient?</h2>
|
|
||||||
<p className="mt-6 max-w-2xl leading-7 text-slate-300">Lassen Sie uns klären, wo der größte Hebel liegt — in einem neuen Produkt, einer bestehenden Anwendung oder einer technischen Entscheidung.</p>
|
|
||||||
<p className="mt-8 text-sm text-slate-400">Kontaktkanal und Impressum werden vor dem öffentlichen Launch ergänzt.</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<footer className="flex flex-wrap justify-between gap-4 border-t border-slate-800 py-8 text-xs text-slate-500">
|
|
||||||
<span>© {new Date().getFullYear()} Jan Wagner · w-make.com</span>
|
|
||||||
<span>Software Engineering · Glass Technology · Systems Thinking</span>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ponytail: Kontaktadresse und rechtliche Angaben erst ergänzen, wenn die UG-/Domain-Daten feststehen.
|
|
||||||
// ponytail: Detailseiten erst abspalten, wenn mehrere veröffentlichbare Case Studies existieren.
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import type { Metadata } from "next";
|
|
||||||
import Image from "next/image";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "Batchmaker Studio — Case Study | Jan Wagner",
|
|
||||||
description: "Case Study zum eigenständig betreibbaren Recipe Studio für Glaschemie und Satzzettel.",
|
|
||||||
};
|
|
||||||
|
|
||||||
const screens = [
|
|
||||||
["Rezeptarbeitsplatz", "Rezepte, Rohstoffe und Versionen als fokussierter Arbeitsbereich.", "/portfolio/batchmaker-recipes.jpg"],
|
|
||||||
["Validierung", "Fachliche Prüfung als eigener Schritt statt versteckter Nebenwirkung.", "/portfolio/batchmaker-validation.jpg"],
|
|
||||||
["Konfiguration", "Redox-Grenzwerte, Kalibrierung und Oxid-Panel bleiben sichtbar steuerbar.", "/portfolio/batchmaker-settings.jpg"],
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function BatchmakerStudioCaseStudy() {
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-grid-pattern">
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-background/90 to-background pointer-events-none" />
|
|
||||||
<div className="relative z-10 mx-auto w-full max-w-6xl px-6 py-8 md:px-10 md:py-12">
|
|
||||||
<nav className="flex items-center justify-between border-b border-slate-800/80 pb-6" aria-label="Hauptnavigation">
|
|
||||||
<Link href="/" className="font-mono text-sm tracking-[0.22em] text-primary">W-MAKE</Link>
|
|
||||||
<Link href="/#projekte" className="text-sm text-slate-400 hover:text-white">← Projekte</Link>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<section className="max-w-4xl py-20 md:py-28">
|
|
||||||
<p className="font-mono text-sm uppercase tracking-[0.22em] text-primary">Case Study · Standalone Product</p>
|
|
||||||
<h1 className="mt-5 text-5xl font-semibold tracking-tight text-white md:text-7xl">Batchmaker Studio</h1>
|
|
||||||
<p className="mt-8 max-w-3xl text-xl leading-9 text-slate-300">Recipe Engineering für die Glasindustrie — bewusst kleiner als ein Produktionssystem, aber fachlich belastbar genug für echte Rezeptarbeit.</p>
|
|
||||||
<div className="mt-10 grid gap-6 border-y border-slate-800 py-6 text-sm md:grid-cols-3">
|
|
||||||
<p><span className="font-mono text-xs uppercase text-slate-600">Rolle</span><br /><span className="text-slate-300">Produktarchitektur und Full-Stack-Entwicklung</span></p>
|
|
||||||
<p><span className="font-mono text-xs uppercase text-slate-600">Stack</span><br /><span className="text-slate-300">Node.js · Express · SQLite · Vanilla JS · Vite</span></p>
|
|
||||||
<p><span className="font-mono text-xs uppercase text-slate-600">Evidenz</span><br /><span className="text-primary/80">Lokale Browser-Session verifiziert</span></p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="grid gap-10 border-t border-slate-800 py-20 md:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<p className="font-mono text-sm uppercase tracking-[0.22em] text-primary">Der Zuschnitt</p>
|
|
||||||
<h2 className="mt-3 text-3xl font-semibold text-white">Ein klarer fachlicher Kern statt Produktions-Overhead.</h2>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-5 leading-7 text-slate-400">
|
|
||||||
<p>Batchmaker Studio konzentriert sich auf Rezepte, Rohstoffe, Glaschemie, Validierung und druckfertige Satzzettel. Silos, Schichten und laufende Produktionsbuchungen bleiben bewusst außerhalb des Produkts.</p>
|
|
||||||
<p>Diese Grenze ist eine Designentscheidung: Das Studio kann eigenständig betrieben und für Rezeptentwicklung eingesetzt werden, ohne die vollständige Betriebslogik von W-Make Batch mitzuschleppen.</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="border-t border-slate-800 py-20">
|
|
||||||
<p className="font-mono text-sm uppercase tracking-[0.22em] text-primary">Verifizierte Ansichten</p>
|
|
||||||
<h2 className="mt-3 text-3xl font-semibold text-white md:text-4xl">Die Oberfläche zeigt den fachlichen Prozess.</h2>
|
|
||||||
<div className="mt-10 grid gap-8">
|
|
||||||
{screens.map(([title, text, image]) => (
|
|
||||||
<figure key={title} className="overflow-hidden rounded-2xl border border-slate-800 bg-slate-950/70">
|
|
||||||
<Image src={image} alt={`${title} im Batchmaker Studio`} width={948} height={403} className="block h-auto w-full" />
|
|
||||||
<figcaption className="grid gap-2 p-6 md:grid-cols-[0.4fr_1fr]">
|
|
||||||
<h3 className="text-xl font-medium text-white">{title}</h3>
|
|
||||||
<p className="text-sm leading-6 text-slate-400">{text}</p>
|
|
||||||
</figcaption>
|
|
||||||
</figure>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="border-t border-slate-800 py-20">
|
|
||||||
<div className="rounded-3xl border border-primary/30 bg-primary/5 p-8 md:p-12">
|
|
||||||
<p className="font-mono text-sm uppercase tracking-[0.22em] text-primary">Engineering-Lektion</p>
|
|
||||||
<h2 className="mt-4 max-w-3xl text-3xl font-semibold text-white md:text-5xl">Produktgrenzen sind Teil der Architektur.</h2>
|
|
||||||
<p className="mt-6 max-w-3xl leading-7 text-slate-300">Ein gutes Werkzeug muss nicht den gesamten Nachbarprozess abbilden. Der kleinere Zuschnitt macht die fachliche Aufgabe verständlicher, testbarer und eigenständig betreibbar.</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<footer className="flex flex-wrap justify-between gap-4 border-t border-slate-800 py-8 text-xs text-slate-500">
|
|
||||||
<Link href="/" className="hover:text-white">Jan Wagner · W-MAKE</Link>
|
|
||||||
<span>Software Engineering · Glass Technology · Systems Thinking</span>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { MetadataRoute } from "next";
|
||||||
|
import { siteUrl } from "@/content/site";
|
||||||
|
|
||||||
|
export default function robots(): MetadataRoute.Robots {
|
||||||
|
return {
|
||||||
|
rules: { userAgent: "*", allow: "/" },
|
||||||
|
sitemap: siteUrl("/sitemap.xml"),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { MetadataRoute } from "next";
|
||||||
|
import { noteSlugs } from "@/content/notes";
|
||||||
|
import { projectSlugs } from "@/content/projects";
|
||||||
|
import { siteUrl } from "@/content/site";
|
||||||
|
import { locales, publicPath, type RouteId } from "@/i18n/routes";
|
||||||
|
|
||||||
|
const staticRoutes: RouteId[] = ["home", "about", "work", "notes", "contact", "legal", "privacy"];
|
||||||
|
|
||||||
|
export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
|
const entries: MetadataRoute.Sitemap = [];
|
||||||
|
|
||||||
|
for (const locale of locales) {
|
||||||
|
for (const route of staticRoutes) {
|
||||||
|
entries.push({
|
||||||
|
url: siteUrl(publicPath(locale, route)),
|
||||||
|
changeFrequency: route === "home" ? "weekly" : "monthly",
|
||||||
|
priority: route === "home" ? 1 : 0.7,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const slug of projectSlugs) {
|
||||||
|
entries.push({
|
||||||
|
url: siteUrl(publicPath(locale, "workItem", slug)),
|
||||||
|
changeFrequency: "monthly",
|
||||||
|
priority: 0.8,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const slug of noteSlugs) {
|
||||||
|
entries.push({
|
||||||
|
url: siteUrl(publicPath(locale, "noteItem", slug)),
|
||||||
|
changeFrequency: "monthly",
|
||||||
|
priority: 0.6,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type { Link } from '@/lib/api-client';
|
||||||
|
|
||||||
|
type LinksProps = {
|
||||||
|
links: Link[];
|
||||||
|
dict: {
|
||||||
|
kicker: string;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LinksSection({ links, dict }: LinksProps) {
|
||||||
|
if (links.length === 0) return null;
|
||||||
|
|
||||||
|
const categoryIcons: Record<string, string> = {
|
||||||
|
external: '↗',
|
||||||
|
internal: '→',
|
||||||
|
resource: '📚',
|
||||||
|
tool: '🔧',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="border-t border-line">
|
||||||
|
<div className="mx-auto w-full max-w-6xl px-6 py-20 md:px-10">
|
||||||
|
<p className="kicker">{dict.kicker}</p>
|
||||||
|
<h2 className="display mt-4 text-4xl md:text-5xl">{dict.title}</h2>
|
||||||
|
|
||||||
|
<div className="mt-12 grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{links.map((link) => (
|
||||||
|
<a
|
||||||
|
key={link.id}
|
||||||
|
href={link.url}
|
||||||
|
target={link.category === 'external' ? '_blank' : undefined}
|
||||||
|
rel={link.category === 'external' ? 'noopener noreferrer' : undefined}
|
||||||
|
className="group block border-l-2 border-line pl-5 transition-colors hover:border-copper"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<h3 className="font-serif text-xl font-medium text-paper group-hover:text-copper">
|
||||||
|
{link.title}
|
||||||
|
</h3>
|
||||||
|
<span className="text-xl" aria-hidden="true">
|
||||||
|
{link.icon || categoryIcons[link.category] || '→'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{link.description && (
|
||||||
|
<p className="mt-2 text-sm leading-relaxed text-mute">
|
||||||
|
{link.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="mt-2 text-xs uppercase tracking-wider text-mute-strong">
|
||||||
|
{link.category}
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { site } from "@/content/site";
|
||||||
|
import { submitInquiry } from "@/lib/contact/actions";
|
||||||
|
import type { Dictionary } from "@/i18n/dictionaries";
|
||||||
|
import type { Locale } from "@/i18n/routes";
|
||||||
|
import { isOfferId, type OfferId } from "@/content/offer-ids";
|
||||||
|
|
||||||
|
type OfferOption = {
|
||||||
|
id: OfferId;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ContactForm({
|
||||||
|
locale,
|
||||||
|
dict,
|
||||||
|
offers,
|
||||||
|
defaultTopic,
|
||||||
|
}: {
|
||||||
|
locale: Locale;
|
||||||
|
dict: Dictionary;
|
||||||
|
offers: readonly OfferOption[];
|
||||||
|
defaultTopic?: OfferId;
|
||||||
|
}) {
|
||||||
|
const [status, setStatus] = useState<"idle" | "sending" | "success" | "error" | "delivery" | "rate">("idle");
|
||||||
|
const [field, setField] = useState<string | undefined>();
|
||||||
|
|
||||||
|
async function onSubmit(formData: FormData) {
|
||||||
|
setStatus("sending");
|
||||||
|
setField(undefined);
|
||||||
|
const result = await submitInquiry(formData);
|
||||||
|
if (result.ok) {
|
||||||
|
setStatus("success");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.code === "delivery") {
|
||||||
|
setStatus("delivery");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.code === "rate") {
|
||||||
|
setStatus("rate");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus("error");
|
||||||
|
setField(result.field);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "success") {
|
||||||
|
return (
|
||||||
|
<p className="border border-copper/40 bg-copper/10 px-6 py-8 text-lg text-paper">
|
||||||
|
{dict.contact.success}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalid = (name: string) => field === name;
|
||||||
|
const failMessage =
|
||||||
|
status === "delivery" ? dict.contact.delivery : status === "rate" ? dict.contact.rate : status === "error" ? dict.contact.error : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={onSubmit} className="grid gap-6">
|
||||||
|
<input type="hidden" name="locale" value={locale} />
|
||||||
|
<label className="honeypot">
|
||||||
|
{dict.contact.honeypot}
|
||||||
|
<input type="text" name="website" tabIndex={-1} autoComplete="off" />
|
||||||
|
</label>
|
||||||
|
<label className="grid gap-2 text-sm">
|
||||||
|
<span className="font-mono text-xs uppercase tracking-[0.16em] text-mute">{dict.contact.topic}</span>
|
||||||
|
<select
|
||||||
|
name="topic"
|
||||||
|
defaultValue={defaultTopic && isOfferId(defaultTopic) ? defaultTopic : ""}
|
||||||
|
className={`border bg-ink-raised px-4 py-3 text-paper outline-none focus:border-copper ${invalid("topic") ? "border-copper" : "border-line-strong"}`}
|
||||||
|
>
|
||||||
|
<option value="">{dict.contact.topicAny}</option>
|
||||||
|
{offers.map((offer) => (
|
||||||
|
<option key={offer.id} value={offer.id}>
|
||||||
|
{offer.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="grid gap-2 text-sm">
|
||||||
|
<span className="font-mono text-xs uppercase tracking-[0.16em] text-mute">{dict.contact.name}</span>
|
||||||
|
<input
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
autoComplete="name"
|
||||||
|
className={`border bg-ink-raised px-4 py-3 text-paper outline-none focus:border-copper ${invalid("name") ? "border-copper" : "border-line-strong"}`}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="grid gap-2 text-sm">
|
||||||
|
<span className="font-mono text-xs uppercase tracking-[0.16em] text-mute">{dict.contact.email}</span>
|
||||||
|
<input
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
autoComplete="email"
|
||||||
|
className={`border bg-ink-raised px-4 py-3 text-paper outline-none focus:border-copper ${invalid("email") ? "border-copper" : "border-line-strong"}`}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="grid gap-2 text-sm">
|
||||||
|
<span className="font-mono text-xs uppercase tracking-[0.16em] text-mute">{dict.contact.company}</span>
|
||||||
|
<input
|
||||||
|
name="company"
|
||||||
|
autoComplete="organization"
|
||||||
|
className="border border-line-strong bg-ink-raised px-4 py-3 text-paper outline-none focus:border-copper"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="grid gap-2 text-sm">
|
||||||
|
<span className="font-mono text-xs uppercase tracking-[0.16em] text-mute">{dict.contact.message}</span>
|
||||||
|
<textarea
|
||||||
|
name="message"
|
||||||
|
required
|
||||||
|
rows={7}
|
||||||
|
className={`border bg-ink-raised px-4 py-3 text-paper outline-none focus:border-copper ${invalid("message") ? "border-copper" : "border-line-strong"}`}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{failMessage ? (
|
||||||
|
<p className="text-sm text-copper">
|
||||||
|
{failMessage}
|
||||||
|
{status === "delivery" || status === "rate" ? (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
<a className="underline decoration-copper/50 underline-offset-4" href={`mailto:${site.email}`}>
|
||||||
|
{site.email}
|
||||||
|
</a>
|
||||||
|
{status === "delivery" ? "." : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={status === "sending"}
|
||||||
|
className="justify-self-start border border-copper bg-copper px-6 py-3 text-sm font-medium text-ink transition hover:bg-copper-deep disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{status === "sending" ? dict.actions.sending : dict.actions.send}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { site } from "@/content/site";
|
||||||
|
|
||||||
|
export function HomeJsonLd() {
|
||||||
|
const data = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "ProfessionalService",
|
||||||
|
name: "W-MAKE",
|
||||||
|
url: site.url,
|
||||||
|
founder: {
|
||||||
|
"@type": "Person",
|
||||||
|
name: "Jan Wagner",
|
||||||
|
jobTitle: "Software Engineer",
|
||||||
|
},
|
||||||
|
areaServed: "DE",
|
||||||
|
description:
|
||||||
|
"Software für Rezept, Satzzettel, Silo und Tagesabschluss in der Behälterglas-Produktion.",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import type { Dictionary } from "@/i18n/dictionaries";
|
||||||
|
import { switchLocale, toPublicFromInternal, type Locale } from "@/i18n/routes";
|
||||||
|
|
||||||
|
export function LanguageSwitcher({
|
||||||
|
locale,
|
||||||
|
dict,
|
||||||
|
}: {
|
||||||
|
locale: Locale;
|
||||||
|
dict: Dictionary;
|
||||||
|
}) {
|
||||||
|
const pathname = usePathname() || "/";
|
||||||
|
const publicPath = toPublicFromInternal(pathname) ?? pathname;
|
||||||
|
const target = locale === "de" ? "en" : "de";
|
||||||
|
const href = switchLocale(publicPath, target) ?? (target === "en" ? "/en" : "/");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav aria-label={dict.language.switch} className="flex items-center gap-3 font-mono text-[0.7rem] uppercase tracking-[0.18em]">
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
hrefLang={target}
|
||||||
|
className="text-mute-strong transition hover:text-paper"
|
||||||
|
>
|
||||||
|
{target === "en" ? dict.language.en : dict.language.de}
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export function Mark({ className = "h-7 w-7" }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 48 48" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M8 36V12l8 16 8-16 8 16 8-16v24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path d="M6 38h36" stroke="currentColor" strokeWidth="1.2" opacity="0.55" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export function ProcessRail({
|
||||||
|
stages,
|
||||||
|
}: {
|
||||||
|
stages: readonly { title: string; text: string }[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ol className="grid gap-px bg-line md:grid-cols-3">
|
||||||
|
{stages.map((stage, index) => (
|
||||||
|
<li key={stage.title} className="bg-ink px-6 py-8">
|
||||||
|
<p className="font-mono text-xs text-copper">0{index + 1}</p>
|
||||||
|
<h3 className="mt-4 font-serif text-2xl text-paper">{stage.title}</h3>
|
||||||
|
<p className="mt-3 text-sm leading-7 text-mute-strong">{stage.text}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { site } from "@/content/site";
|
||||||
|
import type { Dictionary } from "@/i18n/dictionaries";
|
||||||
|
import { publicPath, type Locale } from "@/i18n/routes";
|
||||||
|
|
||||||
|
export function SiteFooter({ locale, dict }: { locale: Locale; dict: Dictionary }) {
|
||||||
|
return (
|
||||||
|
<footer className="border-t border-line">
|
||||||
|
<div className="mx-auto flex w-full max-w-6xl flex-wrap items-center justify-between gap-4 px-6 py-8 text-xs text-mute md:px-10">
|
||||||
|
<p>© {new Date().getFullYear()} Jan Wagner · w-make.com</p>
|
||||||
|
<p className="hidden sm:block">{dict.footer.line}</p>
|
||||||
|
<div className="flex flex-wrap gap-5">
|
||||||
|
<a href={`mailto:${site.email}`} className="link-quiet">
|
||||||
|
{site.email}
|
||||||
|
</a>
|
||||||
|
<Link href={publicPath(locale, "legal")} className="link-quiet">
|
||||||
|
{dict.footer.imprint}
|
||||||
|
</Link>
|
||||||
|
<Link href={publicPath(locale, "privacy")} className="link-quiet">
|
||||||
|
{dict.footer.privacy}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||||
|
import { Mark } from "@/components/mark";
|
||||||
|
import type { Dictionary } from "@/i18n/dictionaries";
|
||||||
|
import { publicPath, type Locale } from "@/i18n/routes";
|
||||||
|
|
||||||
|
export function SiteHeader({ locale, dict }: { locale: Locale; dict: Dictionary }) {
|
||||||
|
const links = [
|
||||||
|
[dict.nav.work, publicPath(locale, "work")],
|
||||||
|
[dict.nav.about, publicPath(locale, "about")],
|
||||||
|
[dict.nav.notes, publicPath(locale, "notes")],
|
||||||
|
[dict.nav.contact, publicPath(locale, "contact")],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-20 border-b border-line bg-ink/80 backdrop-blur-md">
|
||||||
|
<a href="#content" className="skip-link">
|
||||||
|
{dict.a11y.skip}
|
||||||
|
</a>
|
||||||
|
<div className="mx-auto flex w-full max-w-6xl flex-wrap items-center justify-between gap-4 px-6 py-4 md:px-10">
|
||||||
|
<Link href={publicPath(locale, "home")} className="flex items-center gap-3 text-copper">
|
||||||
|
<Mark />
|
||||||
|
<span className="font-mono text-sm tracking-[0.28em]">W-MAKE</span>
|
||||||
|
</Link>
|
||||||
|
<div className="flex flex-wrap items-center gap-5 md:gap-8">
|
||||||
|
<nav aria-label={dict.a11y.nav} className="flex flex-wrap items-center gap-4 text-sm md:gap-6">
|
||||||
|
{links.map(([label, href]) => (
|
||||||
|
<Link key={href} href={href} className="link-quiet">
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<LanguageSwitcher locale={locale} dict={dict} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { LocalizedProject } from "@/content/projects";
|
||||||
|
import type { Dictionary } from "@/i18n/dictionaries";
|
||||||
|
import { publicPath, type Locale } from "@/i18n/routes";
|
||||||
|
|
||||||
|
export function FeaturedWork({
|
||||||
|
project,
|
||||||
|
locale,
|
||||||
|
dict,
|
||||||
|
}: {
|
||||||
|
project: LocalizedProject;
|
||||||
|
locale: Locale;
|
||||||
|
dict: Dictionary;
|
||||||
|
}) {
|
||||||
|
const image = project.images[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="grid gap-10 border-t border-line pt-10 lg:grid-cols-[1.15fr_0.85fr] lg:items-start">
|
||||||
|
{image ? (
|
||||||
|
<figure className="overflow-hidden border border-line bg-ink-raised">
|
||||||
|
<Image
|
||||||
|
src={image.src}
|
||||||
|
alt={image[locale]}
|
||||||
|
width={1440}
|
||||||
|
height={900}
|
||||||
|
priority
|
||||||
|
className="block h-auto w-full"
|
||||||
|
/>
|
||||||
|
<figcaption className="px-5 py-3 text-sm leading-6 text-mute-strong">{image[locale]}</figcaption>
|
||||||
|
</figure>
|
||||||
|
) : null}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-baseline justify-between gap-4">
|
||||||
|
<p className="kicker">{project.label}</p>
|
||||||
|
<span className="font-mono text-xs text-mute">{project.number}</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-5 font-serif text-4xl md:text-5xl">{project.title}</h3>
|
||||||
|
<p className="mt-4 text-sm text-copper">{project.stance}</p>
|
||||||
|
<p className="mt-4 leading-7 text-mute-strong">{project.summary}</p>
|
||||||
|
<ol className="mt-8 grid gap-4">
|
||||||
|
{project.stages.map((stage, index) => (
|
||||||
|
<li key={stage.title} className="grid grid-cols-[2rem_1fr] gap-3">
|
||||||
|
<span className="font-mono text-xs text-copper">0{index + 1}</span>
|
||||||
|
<div>
|
||||||
|
<p className="font-serif text-lg">{stage.title}</p>
|
||||||
|
<p className="mt-1 text-sm leading-6 text-mute-strong">{stage.text}</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
<div className="mt-8 flex flex-wrap gap-6 text-sm">
|
||||||
|
<Link href={publicPath(locale, "workItem", project.slug)} className="border-b border-copper pb-1 text-copper">
|
||||||
|
{dict.actions.openCase} →
|
||||||
|
</Link>
|
||||||
|
{project.href ? (
|
||||||
|
<a href={project.href} className="border-b border-line-strong pb-1 text-paper" rel="noreferrer">
|
||||||
|
{project.href.replace(/^https?:\/\//, "")} →
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkCard({
|
||||||
|
project,
|
||||||
|
locale,
|
||||||
|
dict,
|
||||||
|
}: {
|
||||||
|
project: LocalizedProject;
|
||||||
|
locale: Locale;
|
||||||
|
dict: Dictionary;
|
||||||
|
}) {
|
||||||
|
const image = project.images[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="border-t border-line pt-8">
|
||||||
|
{image ? (
|
||||||
|
<figure className="mb-6 overflow-hidden border border-line bg-ink-raised">
|
||||||
|
<Image src={image.src} alt={image[locale]} width={1440} height={900} className="block h-auto w-full" />
|
||||||
|
</figure>
|
||||||
|
) : null}
|
||||||
|
<div className="flex items-baseline justify-between gap-4">
|
||||||
|
<p className="kicker">{project.label}</p>
|
||||||
|
<span className="font-mono text-xs text-mute">{project.number}</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-5 font-serif text-3xl">{project.title}</h3>
|
||||||
|
<p className="mt-3 text-sm text-copper">{project.stance}</p>
|
||||||
|
<p className="mt-4 leading-7 text-mute-strong">{project.summary}</p>
|
||||||
|
<p className="mt-6 font-mono text-xs uppercase tracking-[0.14em] text-mute">{project.status}</p>
|
||||||
|
<Link
|
||||||
|
href={publicPath(locale, "workItem", project.slug)}
|
||||||
|
className="mt-6 inline-block border-b border-copper pb-1 text-sm text-copper"
|
||||||
|
>
|
||||||
|
{dict.actions.openCase} →
|
||||||
|
</Link>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { Locale } from "@/i18n/routes";
|
||||||
|
|
||||||
|
const copy = {
|
||||||
|
de: {
|
||||||
|
imprint: [
|
||||||
|
"Verantwortlich für diese Website ist Jan Wagner.",
|
||||||
|
"E-Mail: eldov@w-make.de",
|
||||||
|
"Website: https://w-make.com",
|
||||||
|
"Anschrift und Firmierung nach § 5 DDG werden ergänzt, sobald die Unternehmensdaten für den öffentlichen Auftritt feststehen. Bis dahin gilt die genannte E-Mail als Kontakt für redaktionelle und rechtliche Anliegen.",
|
||||||
|
"Diese Site stellt die öffentliche Arbeit von Jan Wagner dar: Positionierung, zwei Produkte, Notizen und ein Kontaktformular. Sie ist kein Kundenportal und enthält keine vertraulichen Hallendaten.",
|
||||||
|
"Inhalte zu Projekten enthalten keine Kundennamen, keine internen Mengen und keine Betriebsgeheimnisse. Externe Links, etwa zur Produkt-Demo unter batch.w-make.com, liegen außerhalb dieser Site. Für den Inhalt verlinkter Seiten sind deren Betreiber verantwortlich.",
|
||||||
|
],
|
||||||
|
privacy: [
|
||||||
|
"Diese Website speichert Anfragen aus dem Kontaktformular, um sie zu beantworten. Pflichtfelder sind Name, E-Mail und Nachricht. Das optionale Feld Unternehmen hilft bei der Einordnung. Das optionale Feld Einstieg ordnet die Anfrage einer der drei Zusammenarbeitsformen zu, falls Sie eine gewählt haben.",
|
||||||
|
"Die Daten werden in einer lokalen SQLite-Datei auf dem Server abgelegt und per E-Mail an eldov@w-make.de weitergeleitet. Es gibt kein Tracking-Pixel, kein Marketing-Cookie und keine Weitergabe an Werbenetzwerke. Es findet keine Profilbildung und keine Reichweitenmessung statt.",
|
||||||
|
"Server-Logs des Hosters können IP-Adresse, Zeitpunkt und aufgerufenen Pfad einer Anfrage enthalten. Das ist Betrieb, nicht Analyse. Die Logs dienen der Störungssuche und der Absicherung, nicht der Werbung.",
|
||||||
|
"Sie können Auskunft, Berichtigung oder Löschung Ihrer Anfrage verlangen, indem Sie an eldov@w-make.de schreiben. Nennen Sie dafür bitte die verwendete E-Mail-Adresse und den ungefähren Zeitpunkt. Eine vollständige Datenschutzerklärung nach DSGVO folgt, sobald Anschrift und Firmierung feststehen.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
imprint: [
|
||||||
|
"This website is published by Jan Wagner.",
|
||||||
|
"Email: eldov@w-make.de",
|
||||||
|
"Website: https://w-make.com",
|
||||||
|
"Street address and legal entity under German law will be added once the public company data is final. Until then the email above is the contact for editorial and legal matters.",
|
||||||
|
"This site presents Jan Wagner’s public work: positioning, two products, notes and a contact form. It is not a client portal and does not hold confidential plant data.",
|
||||||
|
"Project pages do not include client names, internal volumes or trade secrets. External links, such as the product demo at batch.w-make.com, live outside this site. The operators of linked pages are responsible for their content.",
|
||||||
|
],
|
||||||
|
privacy: [
|
||||||
|
"This website stores inquiries from the contact form in order to reply. Required fields are name, email and message. The optional company field helps with context. The optional starting-point field maps the inquiry to one of the three ways of working together, if you chose one.",
|
||||||
|
"Data is written to a local SQLite file on the server and forwarded by email to eldov@w-make.de. There is no tracking pixel, no marketing cookie and no sharing with advertising networks. There is no profiling and no audience measurement.",
|
||||||
|
"The host’s server logs may include IP address, request time and the path requested. That is operations, not analytics. Logs are used for incident response and hardening, not for advertising.",
|
||||||
|
"You can request access, correction or deletion of your inquiry by writing to eldov@w-make.de. Please include the email address you used and the approximate time. A full GDPR privacy notice will follow once street address and legal entity are final.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function getLegal(locale: Locale) {
|
||||||
|
return copy[locale];
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import type { Locale } from "@/i18n/routes";
|
||||||
|
|
||||||
|
export type NoteSlug = "buchungen" | "satzzettel" | "domaene" | "betrieb" | "grenzen";
|
||||||
|
|
||||||
|
export type LocalizedNote = {
|
||||||
|
slug: NoteSlug;
|
||||||
|
date: string;
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
body: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const notes: Record<NoteSlug, { date: string; de: Omit<LocalizedNote, "slug" | "date">; en: Omit<LocalizedNote, "slug" | "date"> }> = {
|
||||||
|
betrieb: {
|
||||||
|
date: "2026-07-22",
|
||||||
|
de: {
|
||||||
|
title: "Migration, Sicherung, Schichtbeginn: Betrieb ist Teil des Produkts",
|
||||||
|
summary: "Ein Abschluss, den man nur in der Demo hinbekommt, hält der Halle nicht stand. Die gemeinsame Uhr und die Wiederholbarkeit gehören in die Anwendung.",
|
||||||
|
body: [
|
||||||
|
"In vielen Projekten kommt der Betrieb hinterher. Zuerst die Masken, dann irgendwann eine Sicherung, irgendwann eine Migration, irgendwann die Frage, warum der Schichtbeginn auf jedem Rechner anders ist. In der Behälterglas-Produktion ist das keine Nachsorge. Der Tag hat eine Uhr. Der Bestand hat eine Datei. Der Abschluss muss sich wiederholen lassen, wenn um sechs jemand denselben Knopf noch einmal drückt.",
|
||||||
|
"Schichtbeginn als gemeinsamer Zeitpunkt klingt klein. Er ist es nicht. Ohne ihn schreibt die eine Station auf gestern, die andere auf heute, das Protokoll auf eine dritte Variante. Alarme, Satzzettel und Übergabe beziehen sich dann auf verschiedene Tage, obwohl alle im selben Raum stehen. Die Uhr gehört in das System, nicht in die lokale Zeitzone des Browsers und nicht in ein Formularfeld, das jemand einmal gesetzt hat.",
|
||||||
|
"Dasselbe gilt für den Tagesabschluss. Ein Export als CSV ist kein Abschluss. Ein Abschluss ist ein Zustand: welche Sätze gelten, welcher Silostand steht, welche Übergabe die nächste Schicht vorfindet. Wenn dieser Zustand nur unter Laborbedingungen entsteht, weil drei Felder zufällig gefüllt waren, ist er in der Halle wertlos. Man muss denselben Abschluss am nächsten Tag wieder erzeugen können, mit denselben Regeln.",
|
||||||
|
"Migrationen sind der unauffällige Teil. Eine Rezeptversion, ein Buchungstyp, ein neues Feld am Silo: das ändert das, was gestern schon gebucht war. Eine Migration, die alte Sätze umschreibt, macht die Geschichte weich. Eine Migration, die vorwärts gilt und die Vergangenheit in Ruhe lässt, hält den Tag erklärbar. Das ist langsamer zu schreiben. Es spart die Nacht, in der niemand mehr sagen kann, welche Zahl zur Wanne gehört.",
|
||||||
|
"Sicherung ist verwandt. Eine SQLite-Datei, die den Bestand trägt, ist kein Anhängsel. Sie ist der Bestand. Wer sie nicht regelmäßig und prüfbar sichert, hat den Betrieb an die Festplatte eines einzelnen Rechners gebunden. Wiederherstellung muss geübt sein, nicht nur dokumentiert. Ein Wiederanlauf um Schichtbeginn ist der Test, nicht ein Restore am Sonntagnachmittag.",
|
||||||
|
"Für technische Entscheider: nehmen Sie Migration, Sicherung und die gemeinsame Uhr in den Umfang des Produkts auf, bevor die erste Maske festgezogen wird. Was danach nachgerüstet wird, sitzt daneben und wird im Ernstfall umgangen.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
title: "Migration, backup, shift start: operations are part of the product",
|
||||||
|
summary: "A close that only works in the demo will not hold on the floor. The shared clock and the ability to repeat the close belong in the application.",
|
||||||
|
body: [
|
||||||
|
"In many projects operations come afterwards. Screens first, then a backup at some point, a migration at some point, and eventually the question why shift start differs on every machine. In container-glass production that is not aftercare. The day has a clock. Stock has a file. The close must be repeatable if someone presses the same control again at six.",
|
||||||
|
"Shift start as a shared instant sounds small. It is not. Without it one station writes to yesterday, another to today, the log to a third variant. Alarms, tickets and handover then refer to different days while everyone stands in the same room. The clock belongs in the system, not in the browser’s local timezone and not in a form field someone set once.",
|
||||||
|
"The same holds for the day close. A CSV export is not a close. A close is a state: which batches hold, where the silo stands, what handover the next shift will find. If that state only appears under lab conditions because three fields happened to be filled, it is worthless on the floor. You must be able to produce the same close the next day, with the same rules.",
|
||||||
|
"Migrations are the quiet part. A recipe version, a booking type, a new field on the silo: that changes what was already booked yesterday. A migration that rewrites old batches softens history. A migration that applies forwards and leaves the past alone keeps the day explainable. It is slower to write. It saves the night in which nobody can say which figure belongs to the furnace.",
|
||||||
|
"Backup is related. A SQLite file that carries stock is not an accessory. It is the stock. If you do not back it up regularly and in a way you can check, you have tied operations to one machine’s disk. Restore has to be practised, not only documented. A restart at shift start is the test, not a restore on Sunday afternoon.",
|
||||||
|
"For technical decision-makers: put migration, backup and the shared clock into the product scope before the first screen is locked. What is bolted on later sits beside the flow and will be bypassed when it matters.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
buchungen: {
|
||||||
|
date: "2026-08-01",
|
||||||
|
de: {
|
||||||
|
title: "Warum fachliche Buchungen nicht einfach gelöscht werden dürfen",
|
||||||
|
summary: "Nach dem Wiegen hat eine Buchung Silo und Protokoll schon verändert. Korrektur heißt Gegenbuchung, Storno oder Abgleich, nicht DELETE.",
|
||||||
|
body: [
|
||||||
|
"In vielen Anwendungen ist Delete der schnellste Weg, einen Fehler zu korrigieren. Der Datensatz war falsch, also verschwindet er. In einem Batch- oder Materialfluss ist das gefährlich. Eine Buchung hat bereits Mengen, Zeiten und nachgelagerte Zustände verändert. Der Silo ist leichter, das Gemengebuch hat eine Zeile, das Tagesprotokoll rechnet mit diesem Satz. Die Zeile zu löschen macht den Fehler unsichtbar. Den Effekt beseitigt sie nicht.",
|
||||||
|
"Ein praktisches Bild: jemand hat den falschen Rohstoff gewählt, die Waage hat gewogen, der Abzug ist gelaufen. Soft-Delete auf dieser Zeile lässt den Silostand stehen, wo er nach dem Abzug ist, während die Maske so tut, als sei nichts geschehen. Die nächste Schicht erbt eine Differenz, die niemand mehr an einem Beleg festmachen kann. Auswertungen werden höflich. Der Bestand wird es nicht.",
|
||||||
|
"Die bessere Operation ist eine Gegenbuchung. Sie erhält die Geschichte, macht die Korrektur sichtbar und hält Auswertungen ehrlich. Storno ist der verwandte Fall, wenn der Satz nicht stattfinden soll, der Abzug aber schon geschrieben hat. Abgleich ist der Fall, in dem Halle und System auseinandergelaufen sind und jemand den Unterschied benennen muss, statt ihn zu überschreiben.",
|
||||||
|
"Das klingt nach mehr Aufwand in der Anwendung. Es spart den größeren: nächtliche Differenzen zwischen Silo, Rezept und Protokoll, die niemand mehr erklären kann. Eine Gegenbuchung kostet eine Maske und eine Regel. Eine unsichtbare Korrektur kostet eine Schichtsuche.",
|
||||||
|
"CRUD hilft hier wenig als Denkmodell. Create und Read passen. Update nach dem Wiegen ist oft schon zu viel. Delete ist fast immer falsch, sobald ein nachgelagerter Zustand geschrieben hat. Wer das ORM eine Cascade anbieten lässt, hat die fachliche Frage an die Bibliothek abgegeben.",
|
||||||
|
"Für technische Entscheider: modellieren Sie Unumkehrbarkeit dort, wo die Halle sie verlangt. Nicht dort, wo das Formular einen Papierkorb-Knopf hergibt. Wenn Sie unsicher sind, fragen Sie, was um sechs noch stimmen muss, wenn jemand um halb drei den falschen Satz angelegt hat.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
title: "Why domain bookings must not simply be deleted",
|
||||||
|
summary: "After weighing, a booking has already changed silo and log. Correction means a reversing entry, a cancellation or a reconcile, not DELETE.",
|
||||||
|
body: [
|
||||||
|
"In many applications, delete is the fastest way to fix a mistake. The record was wrong, so it disappears. In a batch or material flow that is dangerous. A booking has already changed quantities, times and downstream state. The silo is lighter, the batch book has a line, the daily log is counting on that batch. Deleting the row hides the mistake. It does not undo the effect.",
|
||||||
|
"A practical picture: someone picked the wrong raw material, the scale weighed, the deduction ran. Soft-delete on that row leaves the silo where it is after the deduction, while the screen pretends nothing happened. The next shift inherits a gap that nobody can pin to a voucher. Reports become polite. Stock does not.",
|
||||||
|
"The better operation is a reversing entry. It keeps history, makes the correction visible and keeps reports honest. Cancellation is the related case when the batch should not take place but the deduction has already been written. Reconcile is the case in which hall and system have drifted and someone has to name the difference instead of overwriting it.",
|
||||||
|
"It looks like more work in the application. It avoids the larger kind: overnight gaps between silo, recipe and log that nobody can explain. A reversing entry costs a screen and a rule. An invisible correction costs a shift spent searching.",
|
||||||
|
"CRUD is a weak model here. Create and read still fit. Update after weighing is often already too much. Delete is almost always wrong once a downstream state has been written. If you let the ORM offer a cascade, you have handed the domain question to the library.",
|
||||||
|
"For technical decision-makers: model irreversibility where the hall requires it. Not where the form offers a bin button. If you are unsure, ask what still has to be true at six if someone opened the wrong batch at half past two.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
satzzettel: {
|
||||||
|
date: "2026-08-10",
|
||||||
|
de: {
|
||||||
|
title: "Vom Satzzettel zum Tagesprotokoll",
|
||||||
|
summary: "Zwei Dokumente, ein Modell. Leben sie getrennt, entsteht der Bruch genau dort, wo die Schicht übergibt.",
|
||||||
|
body: [
|
||||||
|
"Der Satzzettel sagt, was in die Wanne soll: welche Rezeptversion, welcher Zielsatz, welche Redox-Lage. Das Tagesprotokoll sagt, was der Tag daraus gemacht hat: welche Sätze gelaufen sind, was die Schicht der nächsten hinterlässt, ob der Tag geschlossen ist. In Papierstapeln sind das zwei Welten. In Software dürfen sie das nicht bleiben.",
|
||||||
|
"Der Bruch sieht harmlos aus. Labor oder Arbeitsvorbereitung druckt den Zettel. Die Waage arbeitet ihn ab. Jemand überträgt Verbrauch in eine zweite Liste. Schichtende wird in einem dritten Blatt festgehalten, oft mit Zahlen, die unterwegs gerundet oder mündlich korrigiert wurden. Am nächsten Morgen streiten drei Blätter, und niemand hat Unrecht in seinem System. Sie haben nur kein gemeinsames.",
|
||||||
|
"Ein durchgängiges Modell verbindet Rezept, Satz, Verbrauch und Abschluss. Nicht weil Integration modern ist, sondern weil die Schichtübergabe sonst wieder in Kopien und Nebenrechnungen stattfindet. Der Zettel ist dann der Auftrag, das Gemengebuch die Ausführung, das Protokoll der Zustand am Abend. Dieselbe Rezeptversion steht an allen drei Stellen. Ändert sich die Version, gilt das für neue Sätze, nicht für die, die schon gewogen sind.",
|
||||||
|
"Die Oberfläche kann schlank sein. Der Schmelzer braucht keine Architekturzeichnung. Er braucht den nächsten Satz, die Lage von Oxid und Redox, und am Abend eine Übergabe, die ohne Zusatzzettel auskommt. Die Verbindung darf trotzdem nicht nur in der Oberfläche liegen. Wer nur Screens liefert und die Daten getrennt hält, liefert den Bruch mit. Er sieht nur ordentlicher aus.",
|
||||||
|
"Ein Test für das Modell: kann die nächste Schicht den Tag erklären, ohne den Vorgänger anzurufen. Wenn dafür noch eine Nebenrechnung in einem Heft nötig ist, ist die Kette nicht geschlossen. Wenn der Abschluss denselben Tag morgen noch einmal erzeugen lässt, ist sie es eher.",
|
||||||
|
"Lernpunkt für die Architektur: zeichnen Sie zuerst den Tag eines Schmelzers, nicht die Sitemap der Anwendung. Welche Zahl um sechs, welche nach dem Wiegen, welche um Schichtende. Die Masken folgen daraus. Umgekehrt entstehen hübsche Formulare und dieselbe Übergabe wie auf Papier.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
title: "From batch ticket to daily log",
|
||||||
|
summary: "Two documents, one model. If they live apart, the fracture appears exactly where the shift hands over.",
|
||||||
|
body: [
|
||||||
|
"The batch ticket says what should go into the furnace: which recipe version, which target weight, which redox state. The daily log says what the day made of it: which batches ran, what the shift leaves the next, whether the day is closed. On paper these are two worlds. In software they must not stay that way.",
|
||||||
|
"The fracture looks harmless. Lab or planning prints the ticket. The scale works it off. Someone copies consumption into a second list. End of shift is recorded on a third sheet, often with figures rounded or corrected by mouth along the way. The next morning three sheets disagree, and nobody is wrong inside their own system. They simply do not share one.",
|
||||||
|
"A continuous model connects recipe, batch, consumption and close. Not because integration is fashionable, but because otherwise the handover happens again in copies and side calculations. The ticket is then the order, the batch book the execution, the log the state in the evening. The same recipe version stands at all three points. If the version changes, that applies to new batches, not to those already weighed.",
|
||||||
|
"The interface can stay lean. A melter does not need an architecture diagram. He needs the next batch, the oxide and redox position, and in the evening a handover that does not need an extra sheet. The connection still must not live only in the interface. Screens alone, with the data kept apart, ship the fracture with them. It only looks tidier.",
|
||||||
|
"A test for the model: can the next shift explain the day without calling the previous one. If a side calculation in a notebook is still required, the chain is not closed. If the close can produce the same day again tomorrow, it is closer.",
|
||||||
|
"Takeaway for architecture: draw a melter’s day first, not the application sitemap. Which figure at six, which after weighing, which at the end of the shift. The screens follow from that. The other way round you get handsome forms and the same handover as on paper.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
grenzen: {
|
||||||
|
date: "2026-08-14",
|
||||||
|
de: {
|
||||||
|
title: "Warum Batch und Studio zwei Produkte sind",
|
||||||
|
summary: "Derselbe Fachkern, zwei Zuschnitte. Wer Rezeptarbeit und Hallenbetrieb in eine Anwendung zwingt, trägt Entwürfe in den Bestand oder den Bestand an den Schreibtisch.",
|
||||||
|
body: [
|
||||||
|
"W-Make Batch und Batchmaker Studio teilen den Kern für Rezept, Rohstoff und Glaschemie. Sie teilen nicht den Zuschnitt. Batch trägt Silos, Schichten, Buchungen und den Tagesabschluss. Studio trägt Versionen, Validierung und den druckfertigen Satzzettel. Die Trennung ist keine Marketinglinie. Sie folgt aus zwei verschiedenen Tagen.",
|
||||||
|
"Der Tag in der Halle beginnt mit einer Uhr, einem Bestand und Sätzen, die nach dem Wiegen nicht mehr Entwurf sind. Der Tag am Schreibtisch beginnt mit einer Rezeptversion, die sich noch ändern darf, einer Oxidrechnung, die man prüfen will, und einem Zettel, der noch nicht gebucht ist. In ein Produkt gezwungen, gelten Hallenregeln für Entwürfe oder Entwurfsregeln für den Bestand. Beides ist falsch.",
|
||||||
|
"Ein Beispiel: in Studio soll eine Rezeptversion scheitern dürfen. Die Validierung ist ein Schritt, den jemand sieht. In Batch darf dieselbe Version, sobald Sätze darauf laufen, nicht still durch eine neue ersetzt werden. Die Kostenrechnung hängt an der Version, die gewogen wurde, nicht an der, die gerade in der Maske offen ist. Vermischt man die Produkte, landet genau diese stille Ersetzung in der Halle.",
|
||||||
|
"Umgekehrt: wer Studio mit Silos und Schichten vollpackt, zwingt die Rezeptarbeit in einen Betriebsmodus, den niemand am Schreibtisch braucht. Man eröffnet eine Schicht, um einen Zettel zu drucken. Man pflegt Bestände, die dort keine sind. Das Werkzeug wird schwer, und der Fachkern verschwindet hinter Masken, die zur Halle gehören.",
|
||||||
|
"Technisch heißt die Grenze: eigener Server, eigene Datei, eigene Oberfläche. Der Kern für Oxid, Redox und Satzgröße kann verwandt sein. Die Buchung darf es nicht. Ein Satzzettel aus Studio ist Entwurf und Druckvorlage. Gebucht wird er erst in Batch, wenn die Waage und der Silo dazukommen.",
|
||||||
|
"Für technische Entscheider: Produktgrenzen sind Architektur. Fragen Sie, welcher Tag in der Anwendung stattfinden soll, bevor Sie Module zusammenlegen, weil der Kern ähnlich aussieht. Ähnliche Rechnung ist kein Grund für dasselbe Betriebsmodell.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
title: "Why Batch and Studio are two products",
|
||||||
|
summary: "The same domain core, two cuts. Force recipe work and plant operations into one application and you carry drafts into stock, or stock onto the desk.",
|
||||||
|
body: [
|
||||||
|
"W-Make Batch and Batchmaker Studio share the core for recipe, raw material and glass chemistry. They do not share the cut. Batch carries silos, shifts, bookings and the day close. Studio carries versions, validation and the print-ready batch ticket. The split is not a marketing line. It follows from two different days.",
|
||||||
|
"The day in the hall begins with a clock, a stock position and batches that are no longer drafts after weighing. The day at the desk begins with a recipe version that may still change, an oxide calculation you want to inspect, and a ticket that is not yet booked. Forced into one product, hall rules apply to drafts or draft rules apply to stock. Both are wrong.",
|
||||||
|
"An example: in Studio a recipe version is allowed to fail. Validation is a step someone can see. In Batch, once batches are running on that version, it must not be silently replaced by a new one. Cost hangs on the version that was weighed, not on the one currently open in the screen. Mix the products and that silent replacement lands in the hall.",
|
||||||
|
"The other way round: pack Studio with silos and shifts and you force recipe work into an operations mode nobody needs at the desk. You open a shift in order to print a ticket. You maintain stock that is not stock there. The tool gets heavy, and the domain core disappears behind screens that belong to the hall.",
|
||||||
|
"Technically the boundary means: its own server, its own file, its own interface. The core for oxide, redox and batch size can be related. The booking must not. A ticket from Studio is a draft and a print-ready sheet. It is booked in Batch only when the scale and the silo join in.",
|
||||||
|
"For technical decision-makers: product boundaries are architecture. Ask which day is supposed to take place in the application before you merge modules because the core looks similar. Similar calculation is not a reason for the same operating model.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
domaene: {
|
||||||
|
date: "2026-08-16",
|
||||||
|
de: {
|
||||||
|
title: "Was Domänenwissen in der Produktentwicklung verändert",
|
||||||
|
summary: "Wer den Prozess kennt, stellt andere Fragen. Es entstehen andere Regeln, nicht mehr Felder auf derselben Maske.",
|
||||||
|
body: [
|
||||||
|
"In der Behälterglas-Produktion ist ein Rezept keine Liste von Zutaten. Es ist eine Entscheidung über Temperatur, Redox, Kosten und das, was die nächste Schicht vorfindet. Wer das nur als Formularfelder modelliert, verliert die Reihenfolge, in der die Realität zuschlägt. Zuerst gilt eine Version. Dann wird gewogen. Dann ist der Silo leichter. Dann muss die Wanne zur Rechnung passen. Dann übergibt die Schicht. Die Maske, die alle Felder gleichzeitig anbietet, kennt diese Folge nicht.",
|
||||||
|
"Domänenwissen ändert deshalb zuerst die Fragen. Nicht welche Maske fehlt, sondern was nach dem Wiegen nicht mehr still verschwinden darf. Was eine Schicht der nächsten hinterlassen muss, ohne mündliche Zusatzrechnung. Welche Zahl am Abend noch zur Halle passt, wenn drei Stationen geschrieben haben. Aus diesen Fragen folgen Invarianten. Aus Invarianten folgt das Modell. Aus dem Modell folgen Masken, nicht umgekehrt.",
|
||||||
|
"Die technischen Konsequenzen sind selten spektakulär. Buchungen werden Zustände. Berechnungen bleiben an Versionen gebunden. Validierung ist ein Schritt, den jemand sehen kann. Migrationen und Sicherungen gehören zum Produkt. Schichtbeginn ist eine Uhr. Nichts davon gewinnt einen Architekturpreis. Alles davon entscheidet, ob um sechs noch jemand den Vortag erklären kann.",
|
||||||
|
"Ohne Domäne entstehen andere Fehler, oft spät. Ein Soft-Delete, das den Silo nicht zurückdreht. Eine Rezeptänderung, die in bereits gewogene Sätze hineinschreibt. Eine Kostenrechnung, die die offene Maske nimmt statt der Version an der Waage. Eine Schichtübergabe, die nur als Kommentarfeld existiert. Jeder einzelne Punkt sieht in der Demo unauffällig aus. In der Halle addieren sie sich zu einer Nacht.",
|
||||||
|
"Deshalb gehört jemand, der den Prozess erklären kann, an den Anfang der Architektur, nicht an das Ende der Abnahme. Abnahme findet den fehlenden Knopf. Sie findet selten die fehlende Unumkehrbarkeit. Der teuerste Fehler ist der, den das System erlaubt, weil niemand ihn für fachlich gehalten hat.",
|
||||||
|
"Für Auftraggeber und Teams: verlangen Sie die Erklärung des Tages, bevor Sie die Erklärung des Stacks verlangen. Wenn der Tag nicht erzählbar ist, wird die Software ihn auch nicht tragen. Wenn er erzählbar ist, wird der Stack vergleichsweise langweilig. Das ist in diesem Feld ein gutes Zeichen.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
title: "What domain knowledge changes in product development",
|
||||||
|
summary: "Knowing the process changes the questions. You get different rules, not more fields on the same screen.",
|
||||||
|
body: [
|
||||||
|
"In container-glass production a recipe is not a list of ingredients. It is a decision about temperature, redox, cost and what the next shift will find. Model that as form fields and you lose the order in which reality hits. First a version holds. Then weighing happens. Then the silo is lighter. Then the furnace has to match the calculation. Then the shift hands over. The screen that offers every field at once does not know that sequence.",
|
||||||
|
"Domain knowledge therefore changes the questions first. Not which screen is missing, but what must not silently disappear after weighing. What one shift must leave the next without a side calculation in someone’s head. Which figure still has to match the hall in the evening after three stations have written. Invariants follow from those questions. The model follows from the invariants. Screens follow from the model, not the other way round.",
|
||||||
|
"The technical consequences are rarely spectacular. Bookings become states. Calculations stay bound to versions. Validation is a step someone can see. Migrations and backups belong to the product. Shift start is a clock. None of that wins an architecture prize. All of it decides whether someone can still explain yesterday at six.",
|
||||||
|
"Without the domain, different defects appear, often late. A soft-delete that does not reverse the silo. A recipe change that writes into batches already weighed. A cost calculation that takes the open screen instead of the version at the scale. A shift handover that exists only as a comment field. Each point looks quiet in the demo. On the floor they add up to a night.",
|
||||||
|
"That is why someone who can explain the process belongs at the start of the architecture, not at the end of acceptance. Acceptance finds the missing button. It rarely finds the missing irreversibility. The most expensive defect is the one the system allows because nobody treated it as domain.",
|
||||||
|
"For clients and teams: ask for the explanation of the day before you ask for the explanation of the stack. If the day cannot be told, the software will not carry it either. If it can be told, the stack will look comparatively dull. In this field that is a good sign.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const noteSlugs = Object.keys(notes) as NoteSlug[];
|
||||||
|
|
||||||
|
export function getNote(slug: string, locale: Locale): LocalizedNote | null {
|
||||||
|
if (!(slug in notes)) return null;
|
||||||
|
const entry = notes[slug as NoteSlug];
|
||||||
|
return { slug: slug as NoteSlug, date: entry.date, ...entry[locale] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNotes(locale: Locale): LocalizedNote[] {
|
||||||
|
return noteSlugs
|
||||||
|
.map((slug) => getNote(slug, locale)!)
|
||||||
|
.sort((a, b) => b.date.localeCompare(a.date));
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export const offerIds = ["process", "product", "collaboration"] as const;
|
||||||
|
export type OfferId = (typeof offerIds)[number];
|
||||||
|
|
||||||
|
export function isOfferId(value: string): value is OfferId {
|
||||||
|
return (offerIds as readonly string[]).includes(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { Locale } from "@/i18n/routes";
|
||||||
|
|
||||||
|
export { offerIds, type OfferId, isOfferId } from "./offer-ids";
|
||||||
|
|
||||||
|
const copy = {
|
||||||
|
de: [
|
||||||
|
{
|
||||||
|
id: "process",
|
||||||
|
title: "Prozessanalyse",
|
||||||
|
text: "Wir gehen den Ablauf durch, der heute in Kopien, Nebenrechnungen oder stillen Korrekturen endet. Das Ergebnis ist eine Karte der Bruchstellen und eine Reihenfolge, in der sich der Aufwand lohnt. Der Gang durch die Halle kommt zuerst. Ein Workshop oder ein Tool-Vergleich ersetzt ihn nicht.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "product",
|
||||||
|
title: "Produkt und Backend",
|
||||||
|
text: "Ich entwickle oder stabilisiere die Anwendung, in der Datenmodell, Fachlogik und Betrieb zusammenfallen. Typisch sind Buchungsketten, Stammdaten mit Versionen, Berechnungen an der Rezeptversion und ein Abschluss, den eine Schicht wiederholen kann.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "collaboration",
|
||||||
|
title: "Mitarbeit im Team",
|
||||||
|
text: "Ich sitze an der Stelle, an der Fachbereich und Engineering sich sonst verfehlen: Integrationen, gewachsener Code, Entscheidungen, die nach dem Go-Live teuer werden. Ich liefere nicht nebenher eine zweite Architektur.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
en: [
|
||||||
|
{
|
||||||
|
id: "process",
|
||||||
|
title: "Process analysis",
|
||||||
|
text: "We walk the flow that currently ends in copies, side calculations or quiet corrections. You get a map of the breaks and an order in which the work is worth doing. The walk through the hall comes first. A workshop or a tool comparison does not replace it.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "product",
|
||||||
|
title: "Product and backend",
|
||||||
|
text: "I build or steady the application where the data model, domain logic and operations meet. Typical work is booking chains, versioned master data, calculations bound to the recipe version, and a close a shift can repeat.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "collaboration",
|
||||||
|
title: "Work inside a team",
|
||||||
|
text: "I sit where operations and engineering otherwise miss each other: integrations, accumulated code, decisions that get expensive after go-live. I do not deliver a second architecture on the side.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function getOffers(locale: Locale) {
|
||||||
|
return copy[locale];
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import type { Locale } from "@/i18n/routes";
|
||||||
|
|
||||||
|
const copy = {
|
||||||
|
de: {
|
||||||
|
role: "Software Engineer für industrielle Prozesse",
|
||||||
|
location: "Deutschland, remote und vor Ort",
|
||||||
|
story: [
|
||||||
|
"Ich komme aus der Behälterglas-Produktion. Dort habe ich gelernt, wo ein Rezept aufhört, Zutatenliste zu sein: an der Wanne. Temperatur, Redox, Kosten und das, was die nächste Schicht vorfindet, hängen an derselben Entscheidung. Wer das in voneinander unabhängige Masken zerlegt, baut den Bruch in die Software ein.",
|
||||||
|
"Deshalb beginne ich nicht beim Framework. Ich frage, welche Buchung nach dem Wiegen nicht mehr still verschwinden darf, was eine Schicht der nächsten hinterlassen muss, und welche Zahl am Abend noch zur Halle passt. Erst daraus entsteht ein Datenmodell.",
|
||||||
|
"In der Praxis heißt das oft: Satzzettel, Gemengebuch, Siloabzug, Oxidrechnung und Tagesprotokoll gehören in eine Kette. Eine Rezeptversion, die am Morgen galt, darf am Abend nicht still durch eine andere ersetzt werden, nur weil jemand in der Maske gespeichert hat. Änderungen gelten vorwärts, nicht in die bereits gebuchte Vergangenheit.",
|
||||||
|
"W-Make Batch ist der öffentliche Beleg für diese Haltung. Die Demo unter batch.w-make.com zeigt den Ablauf, nicht einen Kundenstand. Vertrauliche Mengen, Rezepte und Hallendaten bleiben dort, wo sie hingehören. Batchmaker Studio ist die Werkbank daneben: derselbe Fachkern für Rezept und Glaschemie, ohne Silos, ohne Schichten, ohne laufende Buchungen.",
|
||||||
|
"Für Auftraggeber bedeutet das jemanden, der den Prozess erklären kann, bevor er eine Architektur vorschlägt. Für ein Team bedeutet das, dass ich den Teil übernehme, an dem Fachlichkeit, Daten und Betrieb zusammenfallen, und ihn in einem Zustand lasse, den ein anderer Engineer lesen kann.",
|
||||||
|
"Ich arbeite aus Deutschland, remote und vor Ort. Ich nehme Fälle an, bei denen der Ablauf wichtiger ist als die Demo. Casino, Trading und interne Infrastruktur gehören nicht auf diese Site und nicht in ein Erstgespräch mit einem Industriekunden.",
|
||||||
|
],
|
||||||
|
principles: [
|
||||||
|
{
|
||||||
|
title: "Die Halle vor der Sitemap",
|
||||||
|
text: "Ich zeichne den Tag eines Schmelzers, eines Mischers, eines Schichtführers. Welche Zahl braucht er um sechs, welche um Schichtende, welche darf er nicht mehr überschreiben. Daraus folgt die Anwendung.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Buchungen als Zustände",
|
||||||
|
text: "Ein Verbrauch, der Silo und Protokoll verändert hat, ist kein Datensatz zum Löschen. Korrektur heißt Gegenbuchung, Storno oder Abgleich. Soft-Delete kaschiert den Effekt, beseitigt ihn nicht.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Rechnung dort, wo man sie prüfen kann",
|
||||||
|
text: "Oxid, Redox, Satzgröße und Kosten gehören in eigene Module, nicht in die Oberfläche und nicht in den Router. Wenn die Zahl später streitet, muss der Weg zur Formel ohne Umweg über drei Schichten Code liegen.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Betrieb ist Teil des Produkts",
|
||||||
|
text: "Migrationen, Sicherung, Schichtbeginn als gemeinsame Uhr, ein Abschluss, den man wiederholen kann: das ist keine Nachsorge. Ein System, das nur in der Demo schließt, schließt in der Halle nicht.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
role: "Software engineer for industrial processes",
|
||||||
|
location: "Germany, remote and on site",
|
||||||
|
story: [
|
||||||
|
"I come from container-glass production. That is where I learned where a recipe stops being a list of ingredients: at the furnace. Temperature, redox, cost and what the next shift finds all hang on the same decision. Split that into unrelated screens and you ship the fracture with the software.",
|
||||||
|
"So I do not start with the framework. I ask which booking must not vanish quietly after weighing, what one shift must leave the next, and which figure still has to match the hall in the evening. Only then does a data model appear.",
|
||||||
|
"In practice that often means: batch ticket, batch book, silo deduction, oxide calculation and daily log belong in one chain. A recipe version that held in the morning must not be silently replaced in the evening just because someone hit save. Changes apply forwards, not into the booked past.",
|
||||||
|
"W-Make Batch is the public evidence for that stance. The demo at batch.w-make.com shows the flow, not a client installation. Confidential volumes, recipes and plant data stay where they belong. Batchmaker Studio is the workbench beside it: the same domain core for recipes and glass chemistry, without silos, without shifts, without live bookings.",
|
||||||
|
"For a client that means someone who can explain the process before proposing an architecture. For a team it means I take the part where domain, data and operations meet, and leave it in a state another engineer can read.",
|
||||||
|
"I work from Germany, remotely and on site. I take cases where the flow matters more than the demo. Casino, trading and internal infrastructure do not belong on this site, and not in a first conversation with an industrial customer.",
|
||||||
|
],
|
||||||
|
principles: [
|
||||||
|
{
|
||||||
|
title: "The hall before the sitemap",
|
||||||
|
text: "I draw the day of a melter, a mixer, a shift lead. Which figure they need at six, which at the end of the shift, which they must no longer overwrite. The application follows from that.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Bookings as states",
|
||||||
|
text: "A consumption that has already changed silo and log is not a row you delete. Correction means a reversing entry, a cancellation or a reconcile. Soft-delete hides the effect. It does not undo it.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Put the calculation where it can be checked",
|
||||||
|
text: "Oxide, redox, batch size and cost belong in their own modules, not in the interface and not in the router. If the figure is later disputed, the path to the formula must not run through three layers of code.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Operations are part of the product",
|
||||||
|
text: "Migrations, backup, a shared clock for shift start, a close you can repeat: that is not aftercare. A system that only closes in the demo will not close on the floor.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function getPerson(locale: Locale) {
|
||||||
|
return copy[locale];
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import type { Locale } from "@/i18n/routes";
|
||||||
|
|
||||||
|
export type ProjectSlug = "w-make-batch" | "batchmaker-studio";
|
||||||
|
|
||||||
|
export type ProjectImage = {
|
||||||
|
src: string;
|
||||||
|
de: string;
|
||||||
|
en: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LocalizedProject = {
|
||||||
|
slug: ProjectSlug;
|
||||||
|
number: string;
|
||||||
|
href?: string;
|
||||||
|
images: ProjectImage[];
|
||||||
|
status: string;
|
||||||
|
label: string;
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
stance: string;
|
||||||
|
stack: string;
|
||||||
|
role: string;
|
||||||
|
situation: string[];
|
||||||
|
decisions: string[];
|
||||||
|
outcome: string[];
|
||||||
|
proof: string;
|
||||||
|
lesson: string;
|
||||||
|
stages: { title: string; text: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const projects: Record<ProjectSlug, { number: string; href?: string; images: ProjectImage[]; de: Omit<LocalizedProject, "slug" | "number" | "href" | "images">; en: Omit<LocalizedProject, "slug" | "number" | "href" | "images"> }> = {
|
||||||
|
"w-make-batch": {
|
||||||
|
number: "01",
|
||||||
|
href: "https://batch.w-make.com",
|
||||||
|
images: [
|
||||||
|
{ src: "/portfolio/batch-start.jpg", de: "Dashboard: Schicht, Alarme und der Weg in Satzzettel und Protokoll.", en: "Dashboard: shift, alerts, and the path into tickets and the daily log." },
|
||||||
|
{ src: "/portfolio/batch-satzzettel.jpg", de: "Satzzettel mit Rezept, Zielsatz und sichtbarer Redox-Lage.", en: "Batch ticket with recipe, target weight, and visible redox state." },
|
||||||
|
{ src: "/portfolio/batch-rezepte.jpg", de: "Rezeptarbeitsplatz: Versionen, Temperatur und Kosten als Stammdaten.", en: "Recipe workspace: versions, temperature, and cost as master data." },
|
||||||
|
{ src: "/portfolio/batch-tagesprotokoll.jpg", de: "Tagesprotokoll mit Schichtübergabe und Tagesabschluss.", en: "Daily log with shift handover and day close." },
|
||||||
|
],
|
||||||
|
de: {
|
||||||
|
status: "Öffentliche Produkt-Demo",
|
||||||
|
label: "Industrie-Software · Produktentwicklung",
|
||||||
|
title: "W-Make Batch",
|
||||||
|
summary: "Batchmanagement für die Behälterglas-Produktion: vom Satzzettel über den Siloabzug bis zum Tagesprotokoll.",
|
||||||
|
stance: "Das Betriebsprodukt. Die öffentliche Demo zeigt den Ablauf, nicht den Kundenstand.",
|
||||||
|
stack: "Node.js · Express · SQLite · Web Components · native Tests · Playwright",
|
||||||
|
role: "Domänenmodell, Full-Stack-Entwicklung und technische Qualität.",
|
||||||
|
situation: [
|
||||||
|
"In der Behälterglas-Produktion ist der Tag eine Kette. Eine Rezeptversion gilt, ein Satzzettel geht an die Waage, der Verbrauch geht vom Silo ab, Oxid und Redox müssen zur Wanne passen, die Schicht übergibt, der Tag schließt. Bricht eines dieser Glieder, stehen am nächsten Morgen drei Zahlen im Raum, die nicht zusammenpassen.",
|
||||||
|
"Wird das als lose Sammlung von Masken gebaut, kopiert jemand die Werte von einem Blatt auf das nächste. Der Satzzettel sagt etwas anderes als der Silostand, das Protokoll etwas anderes als beides. Die schnellste Korrektur in Software ist dann das Löschen oder Überschreiben einer Zeile. Nach dem Wiegen ist das bereits eine Lüge gegenüber Bestand und Schicht.",
|
||||||
|
"Dazu kommt der Druck der Schichtübergabe. Was mündlich nachgereicht wird, steht nirgendwo. Wer morgens übernimmt, erbt eine Halle und eine Tabelle, die sich widersprechen.",
|
||||||
|
],
|
||||||
|
decisions: [
|
||||||
|
"Fachliche Buchungen sind Zustände. Eine Korrektur nach dem Siloabzug ist eine Gegenbuchung, ein Storno oder ein Abgleich, kein DELETE auf der Zeile.",
|
||||||
|
"Satzzettel, Gemengebuch und Tagesprotokoll gehören in dasselbe Modell. Zwei Tools an dieser Naht erzeugen denselben Papierbruch, den die Software ersetzen sollte.",
|
||||||
|
"Oxid, Redox, Satzgröße und Kosten bleiben an der Rezeptversion hängen und liegen in eigenen Modulen. Die Oberfläche zeigt die Zahl, sie erfindet sie nicht.",
|
||||||
|
"Schichtbeginn ist eine gemeinsame Uhr, kein voreingestelltes Feld in einem Formular. Ohne diese Uhr driftet der Tag zwischen Stationen.",
|
||||||
|
"Migrationen, Sicherung und ein Abschluss, den man wiederholen kann, gehören zum Produkt. Ein System, das nur in der Demo schließt, schließt in der Halle nicht.",
|
||||||
|
"Die öffentliche Demo zeigt das Modell. Kundendaten, Mengen und interne Kennzahlen bleiben außen vor.",
|
||||||
|
],
|
||||||
|
outcome: [
|
||||||
|
"Ein betreibbares System mit öffentlicher Demo unter batch.w-make.com. Man kann den Weg von der Rezeptversion über den Satzzettel in das Tagesprotokoll nachgehen, ohne einen Kundenstand zu sehen.",
|
||||||
|
"Vertrauliche Hallendaten, Rezepte und Mengen stehen nicht auf dieser Site. Es gibt keine veröffentlichten Durchsatzzahlen, keine Verfügbarkeitsversprechen und keine Kundennamen.",
|
||||||
|
],
|
||||||
|
proof: "Öffentliche Produkt-Demo unter batch.w-make.com. Die Case Study beschreibt das Modell, nicht den Kundenstand.",
|
||||||
|
lesson: "Ein Produktionssystem wird besser, wenn die fachliche Unumkehrbarkeit im Datenmodell steckt. Eine Validierung im Formular kommt zu spät, sobald Silo und Protokoll schon geschrieben haben.",
|
||||||
|
stages: [
|
||||||
|
{ title: "Rezept", text: "Zusammensetzung, Version, Temperatur und Kosten bleiben mit dem späteren Verbrauch verbunden." },
|
||||||
|
{ title: "Satzzettel", text: "Der Zettel ist der Auftrag an die Waage, kein Ausdruck einer Tabelle." },
|
||||||
|
{ title: "Silo", text: "Der Verbrauch geht vom Bestand ab. Danach ist die Buchung Teil der Halle." },
|
||||||
|
{ title: "Gemengebuch", text: "Was eingewogen wurde, bleibt an Satz und Rezeptversion gebunden." },
|
||||||
|
{ title: "Protokoll", text: "Der Tag schließt als Zustand, den die nächste Schicht vorfindet, nicht als exportierte Liste." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
status: "Public product demo",
|
||||||
|
label: "Industrial software · product development",
|
||||||
|
title: "W-Make Batch",
|
||||||
|
summary: "Batch management for container-glass production: from the batch ticket through silo deduction to the daily log.",
|
||||||
|
stance: "The operations product. The public demo shows the flow, not a client installation.",
|
||||||
|
stack: "Node.js · Express · SQLite · Web Components · native tests · Playwright",
|
||||||
|
role: "Domain model, full-stack development, and technical quality.",
|
||||||
|
situation: [
|
||||||
|
"In container-glass production the day is a chain. A recipe version holds, a batch ticket goes to the scale, consumption leaves the silo, oxide and redox have to match the furnace, the shift hands over, the day closes. If one link breaks, three figures that do not agree are waiting the next morning.",
|
||||||
|
"Built as a loose set of screens, someone copies values from one sheet to the next. The ticket says one thing, the silo another, the log a third. The fastest correction in software is then to delete or overwrite a row. After weighing, that is already a lie to stock and to the shift.",
|
||||||
|
"Shift handover adds pressure. What is passed on verbally is written nowhere. Whoever takes over in the morning inherits a hall and a table that contradict each other.",
|
||||||
|
],
|
||||||
|
decisions: [
|
||||||
|
"Domain bookings are states. A correction after silo deduction is a reversing entry, a cancellation or a reconcile, not a DELETE on the row.",
|
||||||
|
"Batch ticket, batch book and daily log belong in one model. Two tools at that seam recreate the paper fracture the software was meant to replace.",
|
||||||
|
"Oxide, redox, batch size and cost stay bound to the recipe version and live in their own modules. The interface shows the figure. It does not invent it.",
|
||||||
|
"Shift start is a shared clock, not a default in a form field. Without that clock the day drifts between stations.",
|
||||||
|
"Migrations, backup and a close you can repeat belong to the product. A system that only closes in the demo will not close on the floor.",
|
||||||
|
"The public demo shows the model. Client data, volumes and internal metrics stay off the site.",
|
||||||
|
],
|
||||||
|
outcome: [
|
||||||
|
"An operable system with a public demo at batch.w-make.com. You can follow the path from recipe version through the batch ticket into the daily log without seeing a client installation.",
|
||||||
|
"Confidential plant data, recipes and volumes are not on this site. There are no published throughput figures, no uptime claims and no client names.",
|
||||||
|
],
|
||||||
|
proof: "Public product demo at batch.w-make.com. This case study describes the model, not a client installation.",
|
||||||
|
lesson: "A production system improves when domain irreversibility lives in the data model. A form validation comes too late once silo and log have already been written.",
|
||||||
|
stages: [
|
||||||
|
{ title: "Recipe", text: "Composition, version, temperature and cost stay tied to later consumption." },
|
||||||
|
{ title: "Ticket", text: "The ticket is the order to the scale, not a printout of a table." },
|
||||||
|
{ title: "Silo", text: "Consumption leaves the stock. After that the booking is part of the hall." },
|
||||||
|
{ title: "Batch book", text: "What was weighed stays bound to the batch and the recipe version." },
|
||||||
|
{ title: "Log", text: "The day closes as a state the next shift will find, not as an exported list." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"batchmaker-studio": {
|
||||||
|
number: "02",
|
||||||
|
images: [
|
||||||
|
{ src: "/portfolio/batchmaker-recipes.jpg", de: "Rezeptarbeitsplatz von Batchmaker Studio.", en: "Batchmaker Studio recipe workspace." },
|
||||||
|
{ src: "/portfolio/batchmaker-validation.jpg", de: "Validierung als eigener Schritt.", en: "Validation as a dedicated step." },
|
||||||
|
{ src: "/portfolio/batchmaker-settings.jpg", de: "Konfiguration für Redox, Kalibrierung und Oxide.", en: "Configuration for redox, calibration, and oxides." },
|
||||||
|
],
|
||||||
|
de: {
|
||||||
|
status: "Eigenständig betreibbares Produkt",
|
||||||
|
label: "Standalone · Recipe Engineering",
|
||||||
|
title: "Batchmaker Studio",
|
||||||
|
summary: "Schlankes Recipe-Studio für Rezepte, Rohstoffe, Satzzettel und Glaschemie, ohne Silos und ohne laufende Schichten.",
|
||||||
|
stance: "Die Werkbank am Schreibtisch. Derselbe Fachkern wie W-Make Batch, ohne den Betrieb der Halle.",
|
||||||
|
stack: "Node.js · Express · node:sqlite · Vanilla JS · Vite · native Tests",
|
||||||
|
role: "Produktarchitektur, Full-Stack-Entwicklung und Calculation Engine.",
|
||||||
|
situation: [
|
||||||
|
"Rezeptarbeit braucht denselben fachlichen Kern wie die Produktion: Rohstoffe, Oxidrechnung, Redox, Satzgröße, Kosten, Versionen. Sie braucht nicht Silos, Schichten und laufende Buchungen. Ein volles Betriebssystem ist für die Entwicklung am Schreibtisch zu schwer. Man will eine Rezeptversion ändern, die Chemie prüfen und einen Satzzettel drucken, ohne eine Schicht zu eröffnen.",
|
||||||
|
"Wer beides in ein Produkt zwingt, belastet die Werkbank mit Hallenregeln und die Halle mit Entwurfszuständen. Entwürfe geraten in den Bestand, oder der Bestand wird am Schreibtisch nachgebaut. Beides ist teuer.",
|
||||||
|
],
|
||||||
|
decisions: [
|
||||||
|
"Derselbe Fachkern für Rezept, Rohstoff und Glaschemie, kleinerer Zuschnitt: keine Silos, keine Schichten, keine laufenden Buchungen.",
|
||||||
|
"Eigener Server, eigene SQLite-Datei, eigene schlanke Oberfläche. Studio hängt nicht am Betriebsprodukt.",
|
||||||
|
"Validierung ist ein sichtbarer Schritt. Eine Rechnung, die still im Speichern mitläuft, wird später niemand mehr finden.",
|
||||||
|
"Satzzettel entstehen hier als Entwurf und Druckvorlage. Gebucht wird erst in W-Make Batch.",
|
||||||
|
"Keine unbelegten Effizienzversprechen. Das Produkt ist die Werkbank, nicht ein Ersatz für den Betrieb.",
|
||||||
|
],
|
||||||
|
outcome: [
|
||||||
|
"Ein eigenständig betreibbares Werkzeug für Rezeptentwicklung, Glaschemie und druckfertige Satzzettel. Man kann am Schreibtisch arbeiten, ohne eine Schicht zu simulieren.",
|
||||||
|
"Die Grenze zu W-Make Batch bleibt sichtbar. Was in Studio entworfen wird, wird in Batch zum Auftrag. Die Case Study belegt den Zuschnitt, nicht einen Kundenstand.",
|
||||||
|
],
|
||||||
|
proof: "Öffentliche Case Study und der getrennte Produktzuschnitt gegenüber W-Make Batch.",
|
||||||
|
lesson: "Produktgrenzen sind Architektur. Ein gutes Werkzeug muss den Nachbarprozess nicht vollständig abbilden. Wer Studio und Betrieb vermischt, trägt Entwürfe in den Bestand oder den Bestand an den Schreibtisch.",
|
||||||
|
stages: [
|
||||||
|
{ title: "Rezepte", text: "Versionieren, vergleichen, importieren und exportieren, gebunden an Rohstoffe und Kosten." },
|
||||||
|
{ title: "Rohstoffe", text: "Stammdaten, aus denen Oxid und Satz später gerechnet werden." },
|
||||||
|
{ title: "Glaschemie", text: "Oxide, Redox, Physik und Rechnungen, die man prüfen kann." },
|
||||||
|
{ title: "Satzzettel", text: "Entwurf, Skalierung, Vorschau, druckfertige Ausgabe." },
|
||||||
|
{ title: "Validierung", text: "Ein eigener Schritt, bevor eine Version als brauchbar gilt." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
status: "Independently operable product",
|
||||||
|
label: "Standalone · recipe engineering",
|
||||||
|
title: "Batchmaker Studio",
|
||||||
|
summary: "A lean recipe studio for recipes, raw materials, batch tickets and glass chemistry, without silos and without live shifts.",
|
||||||
|
stance: "The desk-side workbench. The same domain core as W-Make Batch, without the hall’s operations.",
|
||||||
|
stack: "Node.js · Express · node:sqlite · Vanilla JS · Vite · native tests",
|
||||||
|
role: "Product architecture, full-stack development, and calculation engine.",
|
||||||
|
situation: [
|
||||||
|
"Recipe work needs the same domain core as production: raw materials, oxide calculation, redox, batch size, cost, versions. It does not need silos, shifts and live bookings. A full operations system is too heavy for desk-side development. You want to change a recipe version, check the chemistry and print a ticket without opening a shift.",
|
||||||
|
"Force both into one product and the workbench inherits hall rules while the hall inherits draft states. Drafts leak into stock, or stock is rebuilt at the desk. Both are expensive.",
|
||||||
|
],
|
||||||
|
decisions: [
|
||||||
|
"The same domain core for recipe, raw material and glass chemistry, smaller scope: no silos, no shifts, no live bookings.",
|
||||||
|
"Its own server, SQLite file and lean browser interface. Studio does not hang off the operations product.",
|
||||||
|
"Validation is a visible step. A calculation that runs quietly on save will not be found later.",
|
||||||
|
"Batch tickets originate here as draft and print-ready output. Booking happens in W-Make Batch.",
|
||||||
|
"No unverifiable efficiency claims. The product is the workbench, not a substitute for operations.",
|
||||||
|
],
|
||||||
|
outcome: [
|
||||||
|
"An independently operable tool for recipe work, glass chemistry and print-ready batch tickets. You can work at the desk without simulating a shift.",
|
||||||
|
"The boundary with W-Make Batch stays visible. What is drafted in Studio becomes an order in Batch. This case study documents the cut, not a client installation.",
|
||||||
|
],
|
||||||
|
proof: "This public case study and the separate product boundary against W-Make Batch.",
|
||||||
|
lesson: "Product boundaries are architecture. A good tool does not have to model the neighbouring process in full. Mix studio and operations and you carry drafts into stock, or stock onto the desk.",
|
||||||
|
stages: [
|
||||||
|
{ title: "Recipes", text: "Version, compare, import and export, bound to raw materials and cost." },
|
||||||
|
{ title: "Materials", text: "Master data from which oxide and batch size are later calculated." },
|
||||||
|
{ title: "Chemistry", text: "Oxides, redox, physics, and calculations you can inspect." },
|
||||||
|
{ title: "Tickets", text: "Draft, scale, preview, print-ready output." },
|
||||||
|
{ title: "Validation", text: "A dedicated step before a version counts as usable." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const projectSlugs = Object.keys(projects) as ProjectSlug[];
|
||||||
|
|
||||||
|
export function getProject(slug: string, locale: Locale): LocalizedProject | null {
|
||||||
|
if (!(slug in projects)) return null;
|
||||||
|
const entry = projects[slug as ProjectSlug];
|
||||||
|
return {
|
||||||
|
slug: slug as ProjectSlug,
|
||||||
|
number: entry.number,
|
||||||
|
href: entry.href,
|
||||||
|
images: entry.images,
|
||||||
|
...entry[locale],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProjects(locale: Locale): LocalizedProject[] {
|
||||||
|
return projectSlugs.map((slug) => getProject(slug, locale)!);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export const site = {
|
||||||
|
name: "W-MAKE",
|
||||||
|
person: "Jan Wagner",
|
||||||
|
email: "eldov@w-make.de",
|
||||||
|
domain: "w-make.com",
|
||||||
|
url: process.env.NEXT_PUBLIC_SITE_URL || "https://w-make.com",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function siteUrl(path = "/"): string {
|
||||||
|
const base = site.url.replace(/\/$/, "");
|
||||||
|
if (path === "/") return base;
|
||||||
|
return `${base}${path.startsWith("/") ? path : `/${path}`}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import type { Locale } from "./routes";
|
||||||
|
|
||||||
|
export const dictionaries = {
|
||||||
|
de: {
|
||||||
|
nav: {
|
||||||
|
work: "Arbeit",
|
||||||
|
about: "Über mich",
|
||||||
|
notes: "Notizen",
|
||||||
|
contact: "Kontakt",
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
viewWork: "Arbeit ansehen",
|
||||||
|
startConversation: "Gespräch beginnen",
|
||||||
|
openCase: "Fall lesen",
|
||||||
|
openNote: "Notiz lesen",
|
||||||
|
send: "Anfrage senden",
|
||||||
|
sending: "Wird gesendet…",
|
||||||
|
backToWork: "Zur Übersicht",
|
||||||
|
allNotes: "Alle Notizen",
|
||||||
|
},
|
||||||
|
home: {
|
||||||
|
kicker: "Jan Wagner · Behälterglas und Software",
|
||||||
|
title: "Wenn Zahlen zwischen Schichten verschwinden, liegt es meist an der Software.",
|
||||||
|
lede: "Ich baue Systeme für industrielle Prozesse, in denen Rezept, Material und Schichtübergabe lückenlos zusammengehören müssen. Der Ausgangspunkt ist die Behälterglas-Produktion. Derselbe Anspruch gilt überall dort, wo Buchungen Geld, Material und die nächste Schicht berühren.",
|
||||||
|
asideLabel: "Warum Prozess-Software scheitert",
|
||||||
|
asideTitle: "Die Software kennt die Reihenfolge nicht.",
|
||||||
|
asideBody: "Rohstoffe, Redox, Schichtbeginn, Gemengebuch. Wer das als Formularstapel behandelt, verliert die Folge, in der die Halle tatsächlich arbeitet. Das Ergebnis: stille Korrekturen und Zahlen, die morgens nicht mehr stimmen.",
|
||||||
|
workKicker: "Veröffentlichbare Arbeit",
|
||||||
|
workTitle: "W-Make Batch und Batchmaker Studio.",
|
||||||
|
workLede: "W-Make Batch ist das Betriebsprodukt mit öffentlicher Demo unter batch.w-make.com. Batchmaker Studio ist die Werkbank für Rezept und Glaschemie, ohne laufende Schichten und ohne Silos.",
|
||||||
|
methodKicker: "Arbeitsweise",
|
||||||
|
methodTitle: "Zuerst der Schichttag, dann die Maske.",
|
||||||
|
methodLede: "Ich kläre, was um Schichtbeginn gelten muss und was am Abend noch stimmen darf. Erst dann folgt die Technik. Wenn Silo, Rezept und Protokoll nachts auseinanderlaufen, hilft keine neue Oberfläche.",
|
||||||
|
offerKicker: "Zusammenarbeit",
|
||||||
|
offerTitle: "Drei Formen der Zusammenarbeit.",
|
||||||
|
notesKicker: "Notizen",
|
||||||
|
notesTitle: "Entscheidungen aus dem Betrieb.",
|
||||||
|
linksKicker: "Nützliche Links",
|
||||||
|
linksTitle: "Ressourcen und Werkzeuge.",
|
||||||
|
ctaKicker: "30-Min. Erstanalyse · Kostenlos",
|
||||||
|
ctaTitle: "Beschreiben Sie den Prozess, der heute bricht.",
|
||||||
|
ctaBody: "Schildern Sie den Ablauf, der in Kopien, Nebenrechnungen oder stillen Korrekturen endet. Ich antworte innerhalb von zwei Werktagen und sage ehrlich, ob ich den Fall führen kann.",
|
||||||
|
ctaBenefits: [
|
||||||
|
"Antwort innerhalb von 2 Werktagen",
|
||||||
|
"Ehrliche Einschätzung, ob ich den Fall führen kann",
|
||||||
|
"Keine Folien nötig, nur Verständnis des Ablaufs",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
about: {
|
||||||
|
kicker: "Über mich",
|
||||||
|
title: "Aus der Behälterglas-Produktion.",
|
||||||
|
lede: "Ich bin Jan Wagner. Ich komme aus der Schmelze und entwickle Software für Abläufe, die man am nächsten Morgen noch erklären können muss.",
|
||||||
|
close:
|
||||||
|
"Wenn Sie einen Ablauf haben, der in Kopien oder stillen Korrekturen endet, schreiben Sie den Prozess auf. Ich sage Ihnen, ob ich den Fall führen kann.",
|
||||||
|
},
|
||||||
|
work: {
|
||||||
|
kicker: "Arbeit",
|
||||||
|
title: "W-Make Batch und Batchmaker Studio.",
|
||||||
|
lede: "Keine Kundennamen, keine internen Mengen, keine Verfügbarkeitsversprechen. Die Demo unter batch.w-make.com zeigt das Modell. Ein Kundenstand bleibt beim Kunden.",
|
||||||
|
},
|
||||||
|
notes: {
|
||||||
|
kicker: "Notizen",
|
||||||
|
title: "Texte für Leute, die den Betrieb verantworten.",
|
||||||
|
lede: "Keine Ankündigungen und keine Tool-Listen. Es geht um Buchungen, Schichtübergabe, Produktgrenzen und das, was nach dem Go-Live übrig bleibt.",
|
||||||
|
},
|
||||||
|
contact: {
|
||||||
|
kicker: "Kontakt",
|
||||||
|
title: "Beschreiben Sie den Ablauf, der heute reibt.",
|
||||||
|
lede: "Nützlich sind der Prozess im Alltag, wer ihn trägt, und ob es um eine bestehende Anwendung oder um einen Neubau geht. Eine Folienlage brauche ich nicht.",
|
||||||
|
needTitle: "Was in der ersten Nachricht helfen sollte",
|
||||||
|
need: [
|
||||||
|
"Welcher Ablauf heute in Kopien, Nebenrechnungen oder stillen Korrekturen endet.",
|
||||||
|
"Wer ihn im Alltag trägt: Schmelzer, Mischer, Schichtführer, Labor, IT.",
|
||||||
|
"Ob es um eine bestehende Anwendung geht oder um einen Neubau.",
|
||||||
|
],
|
||||||
|
name: "Name",
|
||||||
|
email: "E-Mail",
|
||||||
|
company: "Unternehmen (optional)",
|
||||||
|
message: "Worum geht es?",
|
||||||
|
topic: "Einstieg (optional)",
|
||||||
|
topicAny: "Anderes / noch unklar",
|
||||||
|
honeypot: "Website",
|
||||||
|
success: "Danke. Die Anfrage ist eingegangen.",
|
||||||
|
error: "Bitte prüfen Sie die markierten Felder.",
|
||||||
|
network: "Senden gerade nicht möglich. Bitte später erneut versuchen oder direkt schreiben.",
|
||||||
|
delivery: "Die Anfrage wurde gespeichert, die E-Mail ist fehlgeschlagen. Bitte schreiben Sie direkt an",
|
||||||
|
rate: "Zu viele Anfragen in kurzer Zeit. Bitte schreiben Sie direkt per E-Mail.",
|
||||||
|
reply: "Ich antworte in der Regel innerhalb von zwei Werktagen, und nur wenn ich den Fall wirklich führen kann.",
|
||||||
|
emailDirect: "Direkt per E-Mail",
|
||||||
|
},
|
||||||
|
legal: {
|
||||||
|
imprintKicker: "Impressum",
|
||||||
|
imprintTitle: "Rechtliche Angaben",
|
||||||
|
privacyKicker: "Datenschutz",
|
||||||
|
privacyTitle: "Umgang mit Daten",
|
||||||
|
},
|
||||||
|
case: {
|
||||||
|
situation: "Ausgangslage",
|
||||||
|
role: "Verantwortung",
|
||||||
|
decisions: "Entscheidungen",
|
||||||
|
outcome: "Ergebnis",
|
||||||
|
proof: "Was man prüfen kann",
|
||||||
|
lesson: "Was bleibt",
|
||||||
|
modelTitle: "Der Ablauf im Modell.",
|
||||||
|
galleryTitle: "Blick in die Anwendung.",
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
line: "Software für industrielle Prozesse",
|
||||||
|
imprint: "Impressum",
|
||||||
|
privacy: "Datenschutz",
|
||||||
|
},
|
||||||
|
a11y: {
|
||||||
|
skip: "Zum Inhalt",
|
||||||
|
nav: "Hauptnavigation",
|
||||||
|
},
|
||||||
|
language: {
|
||||||
|
de: "Deutsch",
|
||||||
|
en: "English",
|
||||||
|
switch: "Sprache",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
nav: {
|
||||||
|
work: "Work",
|
||||||
|
about: "About",
|
||||||
|
notes: "Notes",
|
||||||
|
contact: "Contact",
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
viewWork: "See the work",
|
||||||
|
startConversation: "Start a conversation",
|
||||||
|
openCase: "Read the case",
|
||||||
|
openNote: "Read the note",
|
||||||
|
send: "Send inquiry",
|
||||||
|
sending: "Sending…",
|
||||||
|
backToWork: "All work",
|
||||||
|
allNotes: "All notes",
|
||||||
|
},
|
||||||
|
home: {
|
||||||
|
kicker: "Jan Wagner · container glass and software",
|
||||||
|
title: "When numbers vanish between shifts, it's usually the software.",
|
||||||
|
lede: "I build systems for industrial processes where recipe, materials and shift handover must fit together seamlessly. The starting point is container-glass production. The same standard applies wherever bookings touch money, material and the next shift.",
|
||||||
|
asideLabel: "Why process software fails",
|
||||||
|
asideTitle: "The software doesn't know the order.",
|
||||||
|
asideBody: "Raw materials, redox, shift start, batch book. Treat that as a pile of forms and you lose the sequence in which the hall actually works. The result: quiet corrections and figures that no longer match in the morning.",
|
||||||
|
workKicker: "Work I can publish",
|
||||||
|
workTitle: "W-Make Batch and Batchmaker Studio.",
|
||||||
|
workLede: "W-Make Batch is the operations product with a public demo at batch.w-make.com. Batchmaker Studio is the workbench for recipes and glass chemistry, without live shifts and without silos.",
|
||||||
|
methodKicker: "How I work",
|
||||||
|
methodTitle: "The shift day first, then the screen.",
|
||||||
|
methodLede: "I settle what must hold at shift start and what may still be true in the evening. The stack comes after that. If silo, recipe and log drift apart overnight, a new interface will not help.",
|
||||||
|
offerKicker: "Working together",
|
||||||
|
offerTitle: "Three ways of working together.",
|
||||||
|
notesKicker: "Notes",
|
||||||
|
notesTitle: "Decisions from operations.",
|
||||||
|
linksKicker: "Useful Links",
|
||||||
|
linksTitle: "Resources and Tools.",
|
||||||
|
ctaKicker: "30-Min. Analysis · Free",
|
||||||
|
ctaTitle: "Describe the process that's breaking today.",
|
||||||
|
ctaBody: "Describe the flow that currently ends in copies, side calculations or quiet corrections. I reply within two working days and will honestly tell you whether I can take the case.",
|
||||||
|
ctaBenefits: [
|
||||||
|
"Reply within 2 working days",
|
||||||
|
"Honest assessment of whether I can take the case",
|
||||||
|
"No slide deck needed, just understanding of the flow",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
about: {
|
||||||
|
kicker: "About",
|
||||||
|
title: "From container-glass production.",
|
||||||
|
lede: "I am Jan Wagner. I come from the melt and build software for flows that still have to make sense the next morning.",
|
||||||
|
close:
|
||||||
|
"If you have a flow that currently ends in copies or quiet corrections, write the process down. I will tell you whether I can take the case.",
|
||||||
|
},
|
||||||
|
work: {
|
||||||
|
kicker: "Work",
|
||||||
|
title: "W-Make Batch and Batchmaker Studio.",
|
||||||
|
lede: "No client names, no internal volumes, no uptime claims. The demo at batch.w-make.com shows the model. A client installation stays with the client.",
|
||||||
|
},
|
||||||
|
notes: {
|
||||||
|
kicker: "Notes",
|
||||||
|
title: "Writing for people who own the plant day.",
|
||||||
|
lede: "No announcements and no tool lists. The subject is bookings, shift handover, product boundaries, and what remains after go-live.",
|
||||||
|
},
|
||||||
|
contact: {
|
||||||
|
kicker: "Contact",
|
||||||
|
title: "Describe the flow that currently rubs.",
|
||||||
|
lede: "What helps: the process in daily use, who carries it, and whether this is an existing system or a new build. I do not need a slide deck.",
|
||||||
|
needTitle: "What helps in the first message",
|
||||||
|
need: [
|
||||||
|
"Which flow currently ends in copies, side calculations or quiet corrections.",
|
||||||
|
"Who carries it day to day: melter, mixer, shift lead, lab, IT.",
|
||||||
|
"Whether this is an existing application or a new build.",
|
||||||
|
],
|
||||||
|
name: "Name",
|
||||||
|
email: "Email",
|
||||||
|
company: "Company (optional)",
|
||||||
|
message: "What is the process?",
|
||||||
|
topic: "Starting point (optional)",
|
||||||
|
topicAny: "Something else / not sure yet",
|
||||||
|
honeypot: "Website",
|
||||||
|
success: "Thank you. The inquiry has been received.",
|
||||||
|
error: "Please check the highlighted fields.",
|
||||||
|
network: "Sending is unavailable right now. Please try again later or write directly.",
|
||||||
|
delivery: "The inquiry was stored, but email delivery failed. Please write directly to",
|
||||||
|
rate: "Too many inquiries in a short time. Please write directly by email.",
|
||||||
|
reply: "I usually reply within two working days, and only if I can actually take the case.",
|
||||||
|
emailDirect: "Email directly",
|
||||||
|
},
|
||||||
|
legal: {
|
||||||
|
imprintKicker: "Legal notice",
|
||||||
|
imprintTitle: "Legal information",
|
||||||
|
privacyKicker: "Privacy",
|
||||||
|
privacyTitle: "How data is handled",
|
||||||
|
},
|
||||||
|
case: {
|
||||||
|
situation: "Situation",
|
||||||
|
role: "Responsibility",
|
||||||
|
decisions: "Decisions",
|
||||||
|
outcome: "Result",
|
||||||
|
proof: "What you can inspect",
|
||||||
|
lesson: "What remains",
|
||||||
|
modelTitle: "The flow as a model.",
|
||||||
|
galleryTitle: "A look at the application.",
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
line: "Software for industrial processes",
|
||||||
|
imprint: "Legal notice",
|
||||||
|
privacy: "Privacy",
|
||||||
|
},
|
||||||
|
a11y: {
|
||||||
|
skip: "Skip to content",
|
||||||
|
nav: "Main navigation",
|
||||||
|
},
|
||||||
|
language: {
|
||||||
|
de: "Deutsch",
|
||||||
|
en: "English",
|
||||||
|
switch: "Language",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type Dictionary = (typeof dictionaries)[Locale];
|
||||||
|
|
||||||
|
export function getDictionary(locale: Locale): Dictionary {
|
||||||
|
return dictionaries[locale];
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { resolvePublicPath, switchLocale, toInternalPath } from "./routes.ts";
|
||||||
|
|
||||||
|
describe("resolvePublicPath", () => {
|
||||||
|
it("maps German home and localized slugs", () => {
|
||||||
|
assert.deepEqual(resolvePublicPath("/"), { locale: "de", route: "home" });
|
||||||
|
assert.deepEqual(resolvePublicPath("/ueber-mich"), { locale: "de", route: "about" });
|
||||||
|
assert.deepEqual(resolvePublicPath("/arbeit/w-make-batch"), {
|
||||||
|
locale: "de",
|
||||||
|
route: "workItem",
|
||||||
|
slug: "w-make-batch",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps English prefixed routes", () => {
|
||||||
|
assert.deepEqual(resolvePublicPath("/en"), { locale: "en", route: "home" });
|
||||||
|
assert.deepEqual(resolvePublicPath("/en/contact"), { locale: "en", route: "contact" });
|
||||||
|
assert.deepEqual(resolvePublicPath("/en/notes/bookings"), {
|
||||||
|
locale: "en",
|
||||||
|
route: "noteItem",
|
||||||
|
slug: "bookings",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unknown paths", () => {
|
||||||
|
assert.equal(resolvePublicPath("/about"), null);
|
||||||
|
assert.equal(resolvePublicPath("/de"), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toInternalPath", () => {
|
||||||
|
it("rewrites unprefixed German URLs into [lang] segments", () => {
|
||||||
|
assert.equal(toInternalPath("/"), "/de");
|
||||||
|
assert.equal(toInternalPath("/kontakt"), "/de/contact");
|
||||||
|
assert.equal(toInternalPath("/arbeit/batchmaker-studio"), "/de/work/batchmaker-studio");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps English URLs aligned with the file tree", () => {
|
||||||
|
assert.equal(toInternalPath("/en"), "/en");
|
||||||
|
assert.equal(toInternalPath("/en/about"), "/en/about");
|
||||||
|
assert.equal(toInternalPath("/en/work/w-make-batch"), "/en/work/w-make-batch");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("switchLocale", () => {
|
||||||
|
it("switches a case study between public locales", () => {
|
||||||
|
assert.equal(switchLocale("/arbeit/w-make-batch", "en"), "/en/work/w-make-batch");
|
||||||
|
assert.equal(switchLocale("/en/work/w-make-batch", "de"), "/arbeit/w-make-batch");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("switches home without leaking a /de prefix", () => {
|
||||||
|
assert.equal(switchLocale("/", "en"), "/en");
|
||||||
|
assert.equal(switchLocale("/en", "de"), "/");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
export const locales = ["de", "en"] as const;
|
||||||
|
export type Locale = (typeof locales)[number];
|
||||||
|
export const defaultLocale: Locale = "de";
|
||||||
|
|
||||||
|
export type RouteId =
|
||||||
|
| "home"
|
||||||
|
| "about"
|
||||||
|
| "work"
|
||||||
|
| "workItem"
|
||||||
|
| "notes"
|
||||||
|
| "noteItem"
|
||||||
|
| "contact"
|
||||||
|
| "legal"
|
||||||
|
| "privacy";
|
||||||
|
|
||||||
|
export type ResolvedPath =
|
||||||
|
| { locale: Locale; route: Exclude<RouteId, "workItem" | "noteItem"> }
|
||||||
|
| { locale: Locale; route: "workItem" | "noteItem"; slug: string };
|
||||||
|
|
||||||
|
const staticPublic: Record<Locale, Record<Exclude<RouteId, "workItem" | "noteItem">, string>> = {
|
||||||
|
de: {
|
||||||
|
home: "/",
|
||||||
|
about: "/ueber-mich",
|
||||||
|
work: "/arbeit",
|
||||||
|
notes: "/notizen",
|
||||||
|
contact: "/kontakt",
|
||||||
|
legal: "/impressum",
|
||||||
|
privacy: "/datenschutz",
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
home: "/en",
|
||||||
|
about: "/en/about",
|
||||||
|
work: "/en/work",
|
||||||
|
notes: "/en/notes",
|
||||||
|
contact: "/en/contact",
|
||||||
|
legal: "/en/legal",
|
||||||
|
privacy: "/en/privacy",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const prefixes: Record<Locale, { work: string; notes: string }> = {
|
||||||
|
de: { work: "/arbeit", notes: "/notizen" },
|
||||||
|
en: { work: "/en/work", notes: "/en/notes" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const internalSegment: Record<Exclude<RouteId, "home" | "workItem" | "noteItem">, string> = {
|
||||||
|
about: "about",
|
||||||
|
work: "work",
|
||||||
|
notes: "notes",
|
||||||
|
contact: "contact",
|
||||||
|
legal: "legal",
|
||||||
|
privacy: "privacy",
|
||||||
|
};
|
||||||
|
|
||||||
|
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: RouteId, slug?: string): string {
|
||||||
|
if (route === "workItem") return `${prefixes[locale].work}/${slug ?? ""}`;
|
||||||
|
if (route === "noteItem") return `${prefixes[locale].notes}/${slug ?? ""}`;
|
||||||
|
return staticPublic[locale][route];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePublicPath(pathname: string): ResolvedPath | null {
|
||||||
|
const path = normalizePath(pathname);
|
||||||
|
|
||||||
|
for (const locale of locales) {
|
||||||
|
for (const [route, url] of Object.entries(staticPublic[locale])) {
|
||||||
|
if (path === url) {
|
||||||
|
return { locale, route: route as Exclude<RouteId, "workItem" | "noteItem"> };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const workPrefix = prefixes[locale].work;
|
||||||
|
if (path.startsWith(`${workPrefix}/`)) {
|
||||||
|
const slug = path.slice(workPrefix.length + 1);
|
||||||
|
if (slug && !slug.includes("/")) return { locale, route: "workItem", slug };
|
||||||
|
}
|
||||||
|
|
||||||
|
const notesPrefix = prefixes[locale].notes;
|
||||||
|
if (path.startsWith(`${notesPrefix}/`)) {
|
||||||
|
const slug = path.slice(notesPrefix.length + 1);
|
||||||
|
if (slug && !slug.includes("/")) return { locale, route: "noteItem", 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 === "workItem") return `/${resolved.locale}/work/${resolved.slug}`;
|
||||||
|
if (resolved.route === "noteItem") return `/${resolved.locale}/notes/${resolved.slug}`;
|
||||||
|
return `/${resolved.locale}/${internalSegment[resolved.route]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function switchLocale(pathname: string, target: Locale): string | null {
|
||||||
|
const resolved = resolvePublicPath(pathname);
|
||||||
|
if (!resolved) return null;
|
||||||
|
if (resolved.route === "workItem" || resolved.route === "noteItem") {
|
||||||
|
return publicPath(target, resolved.route, resolved.slug);
|
||||||
|
}
|
||||||
|
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 === "work") return publicPath(locale, "workItem", slug);
|
||||||
|
if (slug && segment === "notes") return publicPath(locale, "noteItem", slug);
|
||||||
|
if (slug) return null;
|
||||||
|
|
||||||
|
const route = (Object.keys(internalSegment) as Array<keyof typeof internalSegment>)
|
||||||
|
.find((key) => internalSegment[key] === segment);
|
||||||
|
return route ? publicPath(locale, route) : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// API client for updates-api service
|
||||||
|
const UPDATES_API_URL = process.env.UPDATES_API_URL || 'http://localhost:8080';
|
||||||
|
|
||||||
|
export type Link = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
category: 'external' | 'internal' | 'resource' | 'tool';
|
||||||
|
description: string | null;
|
||||||
|
icon: string | null;
|
||||||
|
sort_order: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Update = {
|
||||||
|
id: number;
|
||||||
|
slug: string;
|
||||||
|
product: 'batchmaker' | 'standalone';
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
body_markdown: string;
|
||||||
|
published_at: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
link_url: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch visible links from the updates-api
|
||||||
|
* Used for displaying links on the homepage
|
||||||
|
*/
|
||||||
|
export async function fetchLinks(): Promise<Link[]> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${UPDATES_API_URL}/v1/links`, {
|
||||||
|
next: { revalidate: 60 }, // ISR: Cache 60s
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error('Failed to fetch links:', res.status, res.statusText);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
return data.data || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching links:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch recent product updates
|
||||||
|
* @param product Filter by product (optional)
|
||||||
|
* @param limit Max number of updates (default: 5)
|
||||||
|
*/
|
||||||
|
export async function fetchUpdates(
|
||||||
|
product?: 'batchmaker' | 'standalone',
|
||||||
|
limit: number = 5
|
||||||
|
): Promise<Update[]> {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (product) params.set('product', product);
|
||||||
|
params.set('limit', String(limit));
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${UPDATES_API_URL}/v1/updates?${params}`,
|
||||||
|
{ next: { revalidate: 300 } } // ISR: Cache 5min
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error('Failed to fetch updates:', res.status, res.statusText);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
return data.data || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching updates:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a single update by slug
|
||||||
|
*/
|
||||||
|
export async function fetchUpdateBySlug(slug: string): Promise<Update | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${UPDATES_API_URL}/v1/updates/${slug}`, {
|
||||||
|
next: { revalidate: 300 },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
return data.data || null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching update:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { deliverInquiry, type InquiryActionResult } from "./deliver";
|
||||||
|
import { allowInquiry } from "./rate-limit";
|
||||||
|
import { sendInquiryMail } from "./mail";
|
||||||
|
import { validateInquiry } from "./schema";
|
||||||
|
import { saveInquiry } from "./store";
|
||||||
|
|
||||||
|
export type { InquiryActionResult };
|
||||||
|
|
||||||
|
export async function submitInquiry(formData: FormData): Promise<InquiryActionResult> {
|
||||||
|
const parsed = validateInquiry({
|
||||||
|
name: formData.get("name"),
|
||||||
|
email: formData.get("email"),
|
||||||
|
company: formData.get("company"),
|
||||||
|
message: formData.get("message"),
|
||||||
|
locale: formData.get("locale"),
|
||||||
|
website: formData.get("website"),
|
||||||
|
topic: formData.get("topic"),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!parsed.ok && parsed.code === "spam") return { ok: true };
|
||||||
|
if (!parsed.ok) return { ok: false, code: "invalid", field: parsed.field };
|
||||||
|
|
||||||
|
const headerList = await headers();
|
||||||
|
const forwarded = headerList.get("x-forwarded-for")?.split(",")[0]?.trim();
|
||||||
|
const ip = forwarded || headerList.get("x-real-ip") || "unknown";
|
||||||
|
if (!allowInquiry(ip)) return { ok: false, code: "rate" };
|
||||||
|
|
||||||
|
return deliverInquiry(parsed.data, {
|
||||||
|
save: saveInquiry,
|
||||||
|
send: sendInquiryMail,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { deliverInquiry } from "./deliver.ts";
|
||||||
|
import type { Inquiry } from "./schema.ts";
|
||||||
|
|
||||||
|
const inquiry: Inquiry = {
|
||||||
|
name: "Anna Müller",
|
||||||
|
email: "anna@example.com",
|
||||||
|
message: "Wir brauchen eine belastbare Abbildung unseres Satzprozesses.",
|
||||||
|
locale: "de",
|
||||||
|
topic: "process",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("deliverInquiry", () => {
|
||||||
|
it("saves and succeeds when mail is sent", async () => {
|
||||||
|
const saved: Inquiry[] = [];
|
||||||
|
const result = await deliverInquiry(inquiry, {
|
||||||
|
save: (item) => {
|
||||||
|
saved.push(item);
|
||||||
|
return "id-1";
|
||||||
|
},
|
||||||
|
send: async () => true,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(result, { ok: true });
|
||||||
|
assert.equal(saved.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not report success when mail fails after save", async () => {
|
||||||
|
let saved = false;
|
||||||
|
const result = await deliverInquiry(inquiry, {
|
||||||
|
save: () => {
|
||||||
|
saved = true;
|
||||||
|
return "id-2";
|
||||||
|
},
|
||||||
|
send: async () => false,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(saved, true);
|
||||||
|
assert.deepEqual(result, { ok: false, code: "delivery" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns error when save throws", async () => {
|
||||||
|
const result = await deliverInquiry(inquiry, {
|
||||||
|
save: () => {
|
||||||
|
throw new Error("disk");
|
||||||
|
},
|
||||||
|
send: async () => true,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(result, { ok: false, code: "error" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { Inquiry } from "./schema";
|
||||||
|
|
||||||
|
export type InquiryActionResult =
|
||||||
|
| { ok: true }
|
||||||
|
| { ok: false; code: "invalid" | "rate" | "error" | "delivery"; field?: string };
|
||||||
|
|
||||||
|
export async function deliverInquiry(
|
||||||
|
inquiry: Inquiry,
|
||||||
|
deps: {
|
||||||
|
save: (inquiry: Inquiry) => string;
|
||||||
|
send: (inquiry: Inquiry) => Promise<boolean>;
|
||||||
|
},
|
||||||
|
): Promise<InquiryActionResult> {
|
||||||
|
try {
|
||||||
|
deps.save(inquiry);
|
||||||
|
} catch {
|
||||||
|
return { ok: false, code: "error" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const mailed = await deps.send(inquiry);
|
||||||
|
if (!mailed) return { ok: false, code: "delivery" };
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { createConnection } from "node:net";
|
||||||
|
import { connect as tlsConnect } from "node:tls";
|
||||||
|
import type { Inquiry } from "./schema";
|
||||||
|
|
||||||
|
type SmtpConfig = {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
user: string;
|
||||||
|
pass: string;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function smtpConfig(): SmtpConfig | null {
|
||||||
|
const to = process.env.CONTACT_TO;
|
||||||
|
const host = process.env.SMTP_HOST;
|
||||||
|
const user = process.env.SMTP_USER;
|
||||||
|
const pass = process.env.SMTP_PASS;
|
||||||
|
if (!to || !host || !user || !pass) return null;
|
||||||
|
return {
|
||||||
|
host,
|
||||||
|
port: Number(process.env.SMTP_PORT || 465),
|
||||||
|
user,
|
||||||
|
pass,
|
||||||
|
from: process.env.CONTACT_FROM || user,
|
||||||
|
to,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeAuth(value: string): string {
|
||||||
|
return Buffer.from(value, "utf8").toString("base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function smtpSend(config: SmtpConfig, inquiry: Inquiry): Promise<void> {
|
||||||
|
const body = [
|
||||||
|
`Name: ${inquiry.name}`,
|
||||||
|
`E-Mail: ${inquiry.email}`,
|
||||||
|
`Unternehmen: ${inquiry.company || "-"}`,
|
||||||
|
`Thema: ${inquiry.topic || "-"}`,
|
||||||
|
`Sprache: ${inquiry.locale}`,
|
||||||
|
"",
|
||||||
|
inquiry.message,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const subject = `W-MAKE Anfrage · ${inquiry.name}`.replace(/[\r\n]/g, " ");
|
||||||
|
const message = [
|
||||||
|
`From: ${config.from}`,
|
||||||
|
`To: ${config.to}`,
|
||||||
|
`Reply-To: ${inquiry.email}`,
|
||||||
|
`Subject: =?UTF-8?B?${Buffer.from(subject, "utf8").toString("base64")}?=`,
|
||||||
|
"MIME-Version: 1.0",
|
||||||
|
"Content-Type: text/plain; charset=utf-8",
|
||||||
|
"Content-Transfer-Encoding: 8bit",
|
||||||
|
"",
|
||||||
|
body,
|
||||||
|
"",
|
||||||
|
].join("\r\n");
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const socket =
|
||||||
|
config.port === 465
|
||||||
|
? tlsConnect({ host: config.host, port: config.port, servername: config.host })
|
||||||
|
: createConnection({ host: config.host, port: config.port });
|
||||||
|
|
||||||
|
let buffer = "";
|
||||||
|
let step = 0;
|
||||||
|
const commands = [
|
||||||
|
`EHLO w-make.com`,
|
||||||
|
`AUTH LOGIN`,
|
||||||
|
encodeAuth(config.user),
|
||||||
|
encodeAuth(config.pass),
|
||||||
|
`MAIL FROM:<${config.user}>`,
|
||||||
|
`RCPT TO:<${config.to}>`,
|
||||||
|
`DATA`,
|
||||||
|
];
|
||||||
|
|
||||||
|
const write = (line: string) => socket.write(`${line}\r\n`);
|
||||||
|
const fail = (error: Error) => {
|
||||||
|
socket.destroy();
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onLine = (line: string) => {
|
||||||
|
const code = Number(line.slice(0, 3));
|
||||||
|
if (code >= 400) {
|
||||||
|
fail(new Error(`SMTP ${line}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (step < commands.length) {
|
||||||
|
write(commands[step]);
|
||||||
|
step += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (step === commands.length) {
|
||||||
|
write(`${message}\r\n.`);
|
||||||
|
step += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
write("QUIT");
|
||||||
|
socket.end();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.setEncoding("utf8");
|
||||||
|
socket.on("data", (chunk: string) => {
|
||||||
|
buffer += chunk;
|
||||||
|
const parts = buffer.split("\r\n");
|
||||||
|
buffer = parts.pop() || "";
|
||||||
|
for (const line of parts) {
|
||||||
|
if (line && (line[3] === " " || line.length === 3)) onLine(line);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on("error", fail);
|
||||||
|
socket.setTimeout(20000, () => fail(new Error("SMTP timeout")));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendInquiryMail(inquiry: Inquiry): Promise<boolean> {
|
||||||
|
const config = smtpConfig();
|
||||||
|
if (!config) return false;
|
||||||
|
try {
|
||||||
|
await smtpSend(config, inquiry);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
const WINDOW_MS = 60 * 60 * 1000;
|
||||||
|
const MAX_PER_WINDOW = 3;
|
||||||
|
const hits = new Map<string, number[]>();
|
||||||
|
|
||||||
|
export function allowInquiry(key: string): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const recent = (hits.get(key) || []).filter((time) => now - time < WINDOW_MS);
|
||||||
|
if (recent.length >= MAX_PER_WINDOW) {
|
||||||
|
hits.set(key, recent);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
recent.push(now);
|
||||||
|
hits.set(key, recent);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { validateInquiry } from "./schema.ts";
|
||||||
|
import { offerIds } from "../../content/offer-ids.ts";
|
||||||
|
|
||||||
|
describe("validateInquiry", () => {
|
||||||
|
const valid = {
|
||||||
|
name: "Anna Müller",
|
||||||
|
email: "anna@example.com",
|
||||||
|
company: "Glaswerk",
|
||||||
|
message: "Wir brauchen eine belastbare Abbildung unseres Satzprozesses.",
|
||||||
|
locale: "de",
|
||||||
|
website: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("accepts a complete inquiry", () => {
|
||||||
|
const result = validateInquiry(valid);
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
if (result.ok) {
|
||||||
|
assert.equal(result.data.email, "anna@example.com");
|
||||||
|
assert.equal(result.data.company, "Glaswerk");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a filled honeypot", () => {
|
||||||
|
const result = validateInquiry({ ...valid, website: "https://spam.test" });
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
if (!result.ok) assert.equal(result.code, "spam");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects short messages and invalid email", () => {
|
||||||
|
assert.equal(validateInquiry({ ...valid, message: "Hallo" }).ok, false);
|
||||||
|
assert.equal(validateInquiry({ ...valid, email: "not-an-email" }).ok, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows an empty company field", () => {
|
||||||
|
const result = validateInquiry({ ...valid, company: " " });
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
if (result.ok) assert.equal(result.data.company, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts every published offer id", () => {
|
||||||
|
for (const topic of offerIds) {
|
||||||
|
const result = validateInquiry({ ...valid, topic });
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
if (result.ok) assert.equal(result.data.topic, topic);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unknown topic", () => {
|
||||||
|
const result = validateInquiry({ ...valid, topic: "casino" });
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
if (!result.ok) assert.equal(result.field, "topic");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a blank topic as omitted", () => {
|
||||||
|
const result = validateInquiry({ ...valid, topic: " " });
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
if (result.ok) assert.equal(result.data.topic, undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
export type InquiryInput = {
|
||||||
|
name: unknown;
|
||||||
|
email: unknown;
|
||||||
|
company?: unknown;
|
||||||
|
message: unknown;
|
||||||
|
locale: unknown;
|
||||||
|
website?: unknown;
|
||||||
|
topic?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
const OFFER_TOPICS = ["process", "product", "collaboration"] as const;
|
||||||
|
export type InquiryTopic = (typeof OFFER_TOPICS)[number];
|
||||||
|
|
||||||
|
function isInquiryTopic(value: string): value is InquiryTopic {
|
||||||
|
return (OFFER_TOPICS as readonly string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Inquiry = {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
company?: string;
|
||||||
|
message: string;
|
||||||
|
locale: "de" | "en";
|
||||||
|
topic?: InquiryTopic;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ValidationResult =
|
||||||
|
| { ok: true; data: Inquiry }
|
||||||
|
| { ok: false; code: "spam" | "invalid"; field?: string };
|
||||||
|
|
||||||
|
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
|
export function validateInquiry(input: InquiryInput): ValidationResult {
|
||||||
|
if (typeof input.website === "string" && input.website.trim() !== "") {
|
||||||
|
return { ok: false, code: "spam" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = typeof input.name === "string" ? input.name.trim() : "";
|
||||||
|
const email = typeof input.email === "string" ? input.email.trim() : "";
|
||||||
|
const message = typeof input.message === "string" ? input.message.trim() : "";
|
||||||
|
const companyRaw = typeof input.company === "string" ? input.company.trim() : "";
|
||||||
|
const topicRaw = typeof input.topic === "string" ? input.topic.trim() : "";
|
||||||
|
const locale = input.locale === "en" || input.locale === "de" ? input.locale : null;
|
||||||
|
|
||||||
|
if (name.length < 2 || name.length > 80) return { ok: false, code: "invalid", field: "name" };
|
||||||
|
if (!EMAIL.test(email) || email.length > 160) return { ok: false, code: "invalid", field: "email" };
|
||||||
|
if (message.length < 20 || message.length > 4000) return { ok: false, code: "invalid", field: "message" };
|
||||||
|
if (!locale) return { ok: false, code: "invalid", field: "locale" };
|
||||||
|
if (companyRaw.length > 120) return { ok: false, code: "invalid", field: "company" };
|
||||||
|
if (topicRaw && !isInquiryTopic(topicRaw)) return { ok: false, code: "invalid", field: "topic" };
|
||||||
|
const topic: InquiryTopic | undefined = isInquiryTopic(topicRaw) ? topicRaw : undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
message,
|
||||||
|
locale,
|
||||||
|
...(companyRaw ? { company: companyRaw } : {}),
|
||||||
|
...(topic ? { topic } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { mkdirSync } from "node:fs";
|
||||||
|
import { dirname } from "node:path";
|
||||||
|
import { DatabaseSync } from "node:sqlite";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import type { Inquiry } from "./schema";
|
||||||
|
|
||||||
|
let db: DatabaseSync | null = null;
|
||||||
|
|
||||||
|
function getDb(): DatabaseSync {
|
||||||
|
if (db) return db;
|
||||||
|
const dbPath = process.env.INQUIRIES_DB_PATH || "./data/inquiries.sqlite";
|
||||||
|
mkdirSync(dirname(dbPath), { recursive: true });
|
||||||
|
db = new DatabaseSync(dbPath);
|
||||||
|
db.exec("PRAGMA journal_mode = WAL;");
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS inquiries (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
company TEXT,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
locale TEXT NOT NULL,
|
||||||
|
topic TEXT,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
)`);
|
||||||
|
try {
|
||||||
|
db.exec("ALTER TABLE inquiries ADD COLUMN topic TEXT");
|
||||||
|
} catch {
|
||||||
|
// Existing databases already have the column after the first migrate.
|
||||||
|
}
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveInquiry(inquiry: Inquiry): string {
|
||||||
|
const id = randomUUID();
|
||||||
|
getDb()
|
||||||
|
.prepare(
|
||||||
|
"INSERT INTO inquiries (id, name, email, company, message, locale, topic, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
id,
|
||||||
|
inquiry.name,
|
||||||
|
inquiry.email,
|
||||||
|
inquiry.company ?? null,
|
||||||
|
inquiry.message,
|
||||||
|
inquiry.locale,
|
||||||
|
inquiry.topic ?? null,
|
||||||
|
new Date().toISOString(),
|
||||||
|
);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { isLocale, type Locale } from "@/i18n/routes";
|
||||||
|
|
||||||
|
export function requireLocale(value: string): Locale {
|
||||||
|
if (!isLocale(value)) notFound();
|
||||||
|
return value;
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { siteUrl } from "@/content/site";
|
||||||
|
import { publicPath, type Locale, type RouteId } from "@/i18n/routes";
|
||||||
|
|
||||||
|
export function pageMetadata(
|
||||||
|
locale: Locale,
|
||||||
|
route: RouteId,
|
||||||
|
title: string,
|
||||||
|
description: string,
|
||||||
|
slug?: string,
|
||||||
|
ogImage?: string,
|
||||||
|
): Metadata {
|
||||||
|
const path = publicPath(locale, route, slug);
|
||||||
|
const image = ogImage || siteUrl(`/${locale}/opengraph-image`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
alternates: {
|
||||||
|
canonical: siteUrl(path),
|
||||||
|
languages: {
|
||||||
|
de: siteUrl(publicPath("de", route, slug)),
|
||||||
|
en: siteUrl(publicPath("en", route, slug)),
|
||||||
|
"x-default": siteUrl(publicPath("de", route, slug)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
url: siteUrl(path),
|
||||||
|
siteName: "W-MAKE",
|
||||||
|
locale: locale === "de" ? "de_DE" : "en_GB",
|
||||||
|
type: "website",
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: image,
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
alt: title,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: "summary_large_image",
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
images: [image],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export type Update = {
|
||||||
|
slug: string;
|
||||||
|
product: "batchmaker" | "standalone";
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
published_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getUpdates(): Promise<Update[]> {
|
||||||
|
const baseUrl = process.env.UPDATES_API_URL || "http://updates-api:8080";
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${baseUrl}/v1/updates?limit=3`, {
|
||||||
|
next: { revalidate: 60 },
|
||||||
|
signal: AbortSignal.timeout(1500),
|
||||||
|
});
|
||||||
|
if (!response.ok) return [];
|
||||||
|
const payload = (await response.json()) as { data?: Update[] };
|
||||||
|
return payload.data || [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { resolvePublicPath, toInternalPath, toPublicFromInternal, type Locale } from "./i18n/routes";
|
||||||
|
|
||||||
|
function localeFromPath(pathname: string): Locale {
|
||||||
|
if (pathname === "/en" || pathname.startsWith("/en/")) return "en";
|
||||||
|
const resolved = resolvePublicPath(pathname);
|
||||||
|
if (resolved) return resolved.locale;
|
||||||
|
if (pathname === "/de" || pathname.startsWith("/de/")) return "de";
|
||||||
|
return "de";
|
||||||
|
}
|
||||||
|
|
||||||
|
function withLocale(response: NextResponse, pathname: string) {
|
||||||
|
response.headers.set("x-locale", localeFromPath(pathname));
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function proxy(request: NextRequest) {
|
||||||
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
|
if (pathname === "/de" || pathname.startsWith("/de/")) {
|
||||||
|
const publicPath = toPublicFromInternal(pathname);
|
||||||
|
if (publicPath && publicPath !== pathname) {
|
||||||
|
const url = request.nextUrl.clone();
|
||||||
|
url.pathname = publicPath;
|
||||||
|
return withLocale(NextResponse.redirect(url), publicPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const internal = toInternalPath(pathname);
|
||||||
|
if (internal && internal !== pathname) {
|
||||||
|
const url = request.nextUrl.clone();
|
||||||
|
url.pathname = internal;
|
||||||
|
return withLocale(NextResponse.rewrite(url), pathname);
|
||||||
|
}
|
||||||
|
|
||||||
|
return withLocale(NextResponse.next(), pathname);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|txt|xml)$).*)",
|
||||||
|
],
|
||||||
|
};
|
||||||
Vendored
+9
@@ -0,0 +1,9 @@
|
|||||||
|
declare module "node:sqlite" {
|
||||||
|
export class DatabaseSync {
|
||||||
|
constructor(path: string);
|
||||||
|
exec(sql: string): void;
|
||||||
|
prepare(sql: string): {
|
||||||
|
run(...params: unknown[]): unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -30,5 +30,5 @@
|
|||||||
".next/dev/types/**/*.ts",
|
".next/dev/types/**/*.ts",
|
||||||
"**/*.mts"
|
"**/*.mts"
|
||||||
],
|
],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules", "**/*.test.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user