Compare commits
7
Commits
042ea88d94
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
820a2f8241 | ||
|
|
78b857a80c | ||
|
|
97944875ca | ||
|
|
1f37edfa46 | ||
|
|
4717404cc3 | ||
|
|
7228fee77a | ||
|
|
c822a110ce |
@@ -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 }
|
||||||
@@ -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.
|
||||||
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 ""
|
||||||
+14
-16
@@ -57,23 +57,21 @@ 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.priority=10"
|
- "traefik.http.routers.w-make-api.rule=Host(`w-make.com`) && (PathPrefix(`/v1`) || PathPrefix(`/feed.xml`))"
|
||||||
- "traefik.http.routers.w-make-updates.entrypoints=websecure"
|
- "traefik.http.routers.w-make-api.priority=150"
|
||||||
- "traefik.http.routers.w-make-updates.tls=true"
|
- "traefik.http.routers.w-make-api.entrypoints=websecure"
|
||||||
- "traefik.http.routers.w-make-updates.tls.certresolver=http_resolver"
|
- "traefik.http.routers.w-make-api.tls=true"
|
||||||
- "traefik.http.routers.w-make-updates.middlewares=default@file,crowdsec-bouncer-plugin@file"
|
- "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"
|
||||||
- "traefik.http.middlewares.wmake-updates-authelia.forwardauth.address=http://authelia:9091/api/verify?rd=https://auth.w-make.com"
|
# Admin-UI (höchste Priorität, Authelia-Schutz)
|
||||||
- "traefik.http.middlewares.wmake-updates-authelia.forwardauth.trustForwardHeader=true"
|
- "traefik.http.routers.w-make-admin.rule=Host(`w-make.com`) && PathPrefix(`/admin`)"
|
||||||
- "traefik.http.middlewares.wmake-updates-authelia.forwardauth.authResponseHeaders=Remote-User,Remote-Groups"
|
- "traefik.http.routers.w-make-admin.priority=200"
|
||||||
- "traefik.http.routers.w-make-updates-admin.rule=Host(`updates.w-make.com`) && (PathPrefix(`/admin`) || PathPrefix(`/v1/admin`))"
|
- "traefik.http.routers.w-make-admin.entrypoints=websecure"
|
||||||
- "traefik.http.routers.w-make-updates-admin.priority=200"
|
- "traefik.http.routers.w-make-admin.tls=true"
|
||||||
- "traefik.http.routers.w-make-updates-admin.entrypoints=websecure"
|
- "traefik.http.routers.w-make-admin.tls.certresolver=http_resolver"
|
||||||
- "traefik.http.routers.w-make-updates-admin.tls=true"
|
- "traefik.http.routers.w-make-admin.middlewares=default@file,crowdsec-bouncer-plugin@file"
|
||||||
- "traefik.http.routers.w-make-updates-admin.tls.certresolver=http_resolver"
|
|
||||||
- "traefik.http.routers.w-make-updates-admin.middlewares=default@file,crowdsec-bouncer-plugin@file,wmake-updates-authelia@docker"
|
|
||||||
- "traefik.http.routers.w-make-updates-admin.service=w-make-updates"
|
|
||||||
networks:
|
networks:
|
||||||
- proxy
|
- proxy
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
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>
|
||||||
@@ -23,6 +23,9 @@
|
|||||||
header, main { max-width: 1200px; margin: 0 auto; padding: 1.25rem 1.5rem; }
|
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); }
|
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; }
|
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; }
|
.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; }
|
.layout { display: grid; grid-template-columns: 280px 1fr; gap: 1.5rem; }
|
||||||
@media (max-width: 860px) { .layout { grid-template-columns: 1fr; } }
|
@media (max-width: 860px) { .layout { grid-template-columns: 1fr; } }
|
||||||
@@ -59,11 +62,17 @@
|
|||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<div>
|
<div>
|
||||||
<p class="kicker">Authelia · updates.w-make.com</p>
|
<p class="kicker">Authelia · w-make.com</p>
|
||||||
<h1>Produkt-Updates</h1>
|
<h1>Produkt-Updates</h1>
|
||||||
</div>
|
</div>
|
||||||
<button class="ghost" id="new-btn" type="button">Neues Update</button>
|
<button class="ghost" id="new-btn" type="button">Neues Update</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<nav>
|
||||||
|
<a href="/admin" class="active">Updates</a>
|
||||||
|
<a href="/admin/links">Links</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<main class="layout">
|
<main class="layout">
|
||||||
<aside class="list">
|
<aside class="list">
|
||||||
<p class="kicker">Bestand</p>
|
<p class="kicker">Bestand</p>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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_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']);
|
||||||
@@ -82,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()] };
|
||||||
@@ -137,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;
|
||||||
@@ -165,12 +266,18 @@ export function createApp({ dbPath = process.env.UPDATES_DB_PATH || './data/upda
|
|||||||
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');
|
||||||
}
|
}
|
||||||
const isAdminUi = path === '/admin' || path === '/admin/';
|
const isAdminUi = path === '/admin' || path === '/admin/';
|
||||||
if ((isAdminUi || path.startsWith('/v1/admin/')) && !auth(req, adminToken)) return error(res, 401, 'UNAUTHORIZED', 'admin authentication required');
|
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) {
|
if (req.method === 'GET' && isAdminUi) {
|
||||||
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
||||||
res.end(ADMIN_HTML);
|
res.end(ADMIN_HTML);
|
||||||
return;
|
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') {
|
if (req.method === 'GET' && path === '/v1/admin/updates') {
|
||||||
const product = url.searchParams.get('product') || null;
|
const product = url.searchParams.get('product') || null;
|
||||||
const status = url.searchParams.get('status') || null;
|
const status = url.searchParams.get('status') || null;
|
||||||
@@ -188,6 +295,31 @@ export function createApp({ dbPath = process.env.UPDATES_DB_PATH || './data/upda
|
|||||||
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -8,8 +8,8 @@ export default async function OpenGraphImage({ params }: { params: Promise<{ lan
|
|||||||
const locale = requireLocale((await params).lang);
|
const locale = requireLocale((await params).lang);
|
||||||
const title =
|
const title =
|
||||||
locale === "de"
|
locale === "de"
|
||||||
? "Software für Prozesse, auf die man sich verlassen muss."
|
? "Wenn Zahlen zwischen Schichten verschwinden, liegt es meist an der Software."
|
||||||
: "Software for processes that have to hold.";
|
: "When numbers vanish between shifts, it's usually the software.";
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
(
|
(
|
||||||
|
|||||||
+45
-5
@@ -1,6 +1,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { FeaturedWork, WorkCard } from "@/components/work-preview";
|
import { FeaturedWork, WorkCard } from "@/components/work-preview";
|
||||||
import { HomeJsonLd } from "@/components/json-ld";
|
import { HomeJsonLd } from "@/components/json-ld";
|
||||||
|
import { LinksSection } from "@/components/LinksSection";
|
||||||
import { getOffers } from "@/content/offers";
|
import { getOffers } from "@/content/offers";
|
||||||
import { getNotes } from "@/content/notes";
|
import { getNotes } from "@/content/notes";
|
||||||
import { getPerson } from "@/content/person";
|
import { getPerson } from "@/content/person";
|
||||||
@@ -9,17 +10,22 @@ import { getDictionary } from "@/i18n/dictionaries";
|
|||||||
import { publicPath } from "@/i18n/routes";
|
import { publicPath } from "@/i18n/routes";
|
||||||
import { pageMetadata } from "@/lib/metadata";
|
import { pageMetadata } from "@/lib/metadata";
|
||||||
import { requireLocale } from "@/lib/locale";
|
import { requireLocale } from "@/lib/locale";
|
||||||
|
import { fetchLinks } from "@/lib/api-client";
|
||||||
|
|
||||||
export async function generateMetadata({ params }: PageProps<"/[lang]">) {
|
export async function generateMetadata({ params }: PageProps<"/[lang]">) {
|
||||||
const locale = requireLocale((await params).lang);
|
const locale = requireLocale((await params).lang);
|
||||||
const dict = getDictionary(locale);
|
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(
|
return pageMetadata(
|
||||||
locale,
|
locale,
|
||||||
"home",
|
"home",
|
||||||
locale === "de"
|
locale === "de"
|
||||||
? "Jan Wagner · Software für Satzzettel, Silo und Schichtabschluss"
|
? "Jan Wagner · Software für industrielle Prozesse"
|
||||||
: "Jan Wagner · software for batch tickets, silos and the shift close",
|
: "Jan Wagner · software for industrial processes",
|
||||||
dict.home.lede,
|
description,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,6 +36,7 @@ export default async function HomePage({ params }: PageProps<"/[lang]">) {
|
|||||||
const [featured, ...rest] = getProjects(locale);
|
const [featured, ...rest] = getProjects(locale);
|
||||||
const offers = getOffers(locale);
|
const offers = getOffers(locale);
|
||||||
const notes = getNotes(locale).slice(0, 4);
|
const notes = getNotes(locale).slice(0, 4);
|
||||||
|
const links = await fetchLinks();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
@@ -55,6 +62,23 @@ export default async function HomePage({ params }: PageProps<"/[lang]">) {
|
|||||||
</aside>
|
</aside>
|
||||||
</section>
|
</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">
|
<section className="border-t border-line">
|
||||||
<div className="mx-auto w-full max-w-6xl px-6 py-20 md:px-10">
|
<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 className="mb-12 flex flex-wrap items-end justify-between gap-6">
|
||||||
@@ -149,15 +173,31 @@ export default async function HomePage({ params }: PageProps<"/[lang]">) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<LinksSection
|
||||||
|
links={links}
|
||||||
|
dict={{
|
||||||
|
kicker: dict.home.linksKicker,
|
||||||
|
title: dict.home.linksTitle,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<section className="border-t border-line">
|
<section className="border-t border-line">
|
||||||
<div className="mx-auto w-full max-w-6xl px-6 py-20 md:px-10">
|
<div className="mx-auto w-full max-w-6xl px-6 py-20 md:px-10">
|
||||||
<div className="border border-copper/30 bg-copper/5 px-8 py-12 md:px-12">
|
<div className="border-2 border-copper/30 bg-copper/5 px-8 py-12 md:px-12">
|
||||||
<p className="kicker">{dict.home.ctaKicker}</p>
|
<p className="kicker">{dict.home.ctaKicker}</p>
|
||||||
<h2 className="display mt-4 max-w-3xl text-4xl md:text-6xl">{dict.home.ctaTitle}</h2>
|
<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>
|
<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
|
<Link
|
||||||
href={publicPath(locale, "contact")}
|
href={publicPath(locale, "contact")}
|
||||||
className="mt-8 inline-block border border-copper bg-copper px-6 py-3 text-sm font-medium text-ink"
|
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}
|
{dict.actions.startConversation}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -127,3 +127,21 @@ body {
|
|||||||
height: 0;
|
height: 0;
|
||||||
overflow: hidden;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+30
-16
@@ -20,11 +20,11 @@ export const dictionaries = {
|
|||||||
},
|
},
|
||||||
home: {
|
home: {
|
||||||
kicker: "Jan Wagner · Behälterglas und Software",
|
kicker: "Jan Wagner · Behälterglas und Software",
|
||||||
title: "Software für Satzzettel, Silo und Schichtabschluss.",
|
title: "Wenn Zahlen zwischen Schichten verschwinden, liegt es meist an der Software.",
|
||||||
lede: "Ich baue Systeme, in denen Rezept, Satzzettel, Siloabzug und Tagesabschluss zusammengehören. Der Ausgangspunkt ist die Behälterglas-Produktion. Derselbe Anspruch gilt überall dort, wo Buchungen Geld, Material und die nächste Schicht berühren.",
|
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: "Herkunft",
|
asideLabel: "Warum Prozess-Software scheitert",
|
||||||
asideTitle: "Die Reihenfolge kommt aus der Halle.",
|
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.",
|
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",
|
workKicker: "Veröffentlichbare Arbeit",
|
||||||
workTitle: "W-Make Batch und Batchmaker Studio.",
|
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.",
|
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.",
|
||||||
@@ -35,9 +35,16 @@ export const dictionaries = {
|
|||||||
offerTitle: "Drei Formen der Zusammenarbeit.",
|
offerTitle: "Drei Formen der Zusammenarbeit.",
|
||||||
notesKicker: "Notizen",
|
notesKicker: "Notizen",
|
||||||
notesTitle: "Entscheidungen aus dem Betrieb.",
|
notesTitle: "Entscheidungen aus dem Betrieb.",
|
||||||
ctaKicker: "Kontakt",
|
linksKicker: "Nützliche Links",
|
||||||
ctaTitle: "Schreiben Sie, wenn der Ablauf in der Software bricht.",
|
linksTitle: "Ressourcen und Werkzeuge.",
|
||||||
ctaBody: "Schildern Sie den Ablauf, der heute in Kopien, Nebenrechnungen oder stillen Korrekturen endet. Ich antworte in der Regel innerhalb von zwei Werktagen, sofern ich den Fall führen kann.",
|
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: {
|
about: {
|
||||||
kicker: "Über mich",
|
kicker: "Über mich",
|
||||||
@@ -131,11 +138,11 @@ export const dictionaries = {
|
|||||||
},
|
},
|
||||||
home: {
|
home: {
|
||||||
kicker: "Jan Wagner · container glass and software",
|
kicker: "Jan Wagner · container glass and software",
|
||||||
title: "Software for batch tickets, silos and the shift close.",
|
title: "When numbers vanish between shifts, it's usually the software.",
|
||||||
lede: "I build systems in which recipe, batch ticket, silo deduction and day close belong together. The starting point is container-glass production. The same standard applies wherever bookings touch money, material and the next shift.",
|
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: "Background",
|
asideLabel: "Why process software fails",
|
||||||
asideTitle: "The order comes from the hall.",
|
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.",
|
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",
|
workKicker: "Work I can publish",
|
||||||
workTitle: "W-Make Batch and Batchmaker Studio.",
|
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.",
|
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.",
|
||||||
@@ -146,9 +153,16 @@ export const dictionaries = {
|
|||||||
offerTitle: "Three ways of working together.",
|
offerTitle: "Three ways of working together.",
|
||||||
notesKicker: "Notes",
|
notesKicker: "Notes",
|
||||||
notesTitle: "Decisions from operations.",
|
notesTitle: "Decisions from operations.",
|
||||||
ctaKicker: "Contact",
|
linksKicker: "Useful Links",
|
||||||
ctaTitle: "Write if the process is breaking in the software.",
|
linksTitle: "Resources and Tools.",
|
||||||
ctaBody: "Describe the flow that currently ends in copies, side calculations or quiet corrections. I usually reply within two working days, if I can take the case.",
|
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: {
|
about: {
|
||||||
kicker: "About",
|
kicker: "About",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,8 +8,11 @@ export function pageMetadata(
|
|||||||
title: string,
|
title: string,
|
||||||
description: string,
|
description: string,
|
||||||
slug?: string,
|
slug?: string,
|
||||||
|
ogImage?: string,
|
||||||
): Metadata {
|
): Metadata {
|
||||||
const path = publicPath(locale, route, slug);
|
const path = publicPath(locale, route, slug);
|
||||||
|
const image = ogImage || siteUrl(`/${locale}/opengraph-image`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@@ -28,6 +31,20 @@ export function pageMetadata(
|
|||||||
siteName: "W-MAKE",
|
siteName: "W-MAKE",
|
||||||
locale: locale === "de" ? "de_DE" : "en_GB",
|
locale: locale === "de" ? "de_DE" : "en_GB",
|
||||||
type: "website",
|
type: "website",
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: image,
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
alt: title,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: "summary_large_image",
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
images: [image],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user