feat: Add Hardcover currently reading integration with i18n support

- Add CurrentlyReading component with beautiful design
- Integrate into About section
- Add German and English translations
- Add n8n API route for Hardcover integration
- Add comprehensive documentation for n8n setup
This commit is contained in:
2026-01-15 14:58:34 +01:00
parent b90a3d589c
commit 38a98a9ea2
6 changed files with 764 additions and 0 deletions

View File

@@ -0,0 +1,131 @@
// app/api/n8n/hardcover/currently-reading/route.ts
import { NextRequest, NextResponse } from "next/server";
// Cache für 5 Minuten, damit wir n8n nicht zuspammen
// Hardcover-Daten ändern sich nicht so häufig
export const revalidate = 300;
export async function GET(request: NextRequest) {
// Rate limiting for n8n hardcover endpoint
const ip =
request.headers.get("x-forwarded-for") ||
request.headers.get("x-real-ip") ||
"unknown";
const ua = request.headers.get("user-agent") || "unknown";
const { checkRateLimit } = await import('@/lib/auth');
// In dev, many requests can share ip=unknown; use UA to avoid a shared bucket.
const rateKey =
process.env.NODE_ENV === "development" && ip === "unknown"
? `ua:${ua.slice(0, 120)}`
: ip;
const maxPerMinute = process.env.NODE_ENV === "development" ? 60 : 10;
if (!checkRateLimit(rateKey, maxPerMinute, 60000)) { // requests per minute
return NextResponse.json(
{ error: 'Rate limit exceeded. Please try again later.' },
{ status: 429 }
);
}
try {
// Check if n8n webhook URL is configured
const n8nWebhookUrl = process.env.N8N_WEBHOOK_URL;
if (!n8nWebhookUrl) {
console.warn("N8N_WEBHOOK_URL not configured for hardcover endpoint");
// Return fallback if n8n is not configured
return NextResponse.json({
currentlyReading: null,
});
}
// Rufe den n8n Webhook auf
// Add timestamp to query to bypass Cloudflare cache
const webhookUrl = `${n8nWebhookUrl}/webhook/hardcover/currently-reading?t=${Date.now()}`;
console.log(`Fetching currently reading from: ${webhookUrl}`);
// Add timeout to prevent hanging requests
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
try {
const res = await fetch(webhookUrl, {
method: "GET",
headers: {
Accept: "application/json",
...(process.env.N8N_SECRET_TOKEN && {
Authorization: `Bearer ${process.env.N8N_SECRET_TOKEN}`,
}),
...(process.env.N8N_API_KEY && {
"X-API-Key": process.env.N8N_API_KEY,
}),
},
next: { revalidate: 300 },
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!res.ok) {
const errorText = await res.text().catch(() => 'Unknown error');
console.error(`n8n hardcover webhook failed: ${res.status}`, errorText);
throw new Error(`n8n error: ${res.status} - ${errorText}`);
}
const raw = await res.text().catch(() => "");
if (!raw || !raw.trim()) {
throw new Error("Empty response body received from n8n");
}
let data: unknown;
try {
data = JSON.parse(raw);
} catch (parseError) {
// Sometimes upstream sends HTML or a partial response; include a snippet for debugging.
const snippet = raw.slice(0, 240);
throw new Error(
`Invalid JSON from n8n (${res.status}): ${snippet}${raw.length > 240 ? "…" : ""}`,
);
}
// n8n gibt oft ein Array zurück: [{...}]. Wir wollen nur das Objekt.
const readingData = Array.isArray(data) ? data[0] : data;
// Safety check: if readingData is still undefined/null (e.g. empty array), use fallback
if (!readingData) {
throw new Error("Empty data received from n8n");
}
// Ensure currentlyReading has proper structure
if (readingData.currentlyReading && typeof readingData.currentlyReading === "object") {
// Already properly formatted from n8n
} else if (readingData.currentlyReading === null || readingData.currentlyReading === undefined) {
// No reading data - keep as null
readingData.currentlyReading = null;
}
return NextResponse.json(readingData);
} catch (fetchError: unknown) {
clearTimeout(timeoutId);
if (fetchError instanceof Error && fetchError.name === 'AbortError') {
console.error("n8n hardcover webhook request timed out");
} else {
console.error("n8n hardcover webhook fetch error:", fetchError);
}
throw fetchError;
}
} catch (error: unknown) {
console.error("Error fetching n8n hardcover data:", error);
console.error("Error details:", {
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
n8nUrl: process.env.N8N_WEBHOOK_URL ? 'configured' : 'missing',
});
// Leeres Fallback-Objekt, damit die Seite nicht abstürzt
return NextResponse.json({
currentlyReading: null,
});
}
}

View File

@@ -6,6 +6,7 @@ import { useEffect, useState } from "react";
import { useLocale, useTranslations } from "next-intl";
import type { JSONContent } from "@tiptap/react";
import RichTextClient from "./RichTextClient";
import CurrentlyReading from "./CurrentlyReading";
const staggerContainer: Variants = {
hidden: { opacity: 0 },
@@ -239,6 +240,14 @@ const About = () => {
))}
</div>
</div>
{/* Currently Reading */}
<motion.div
variants={fadeInUp}
className="mt-8"
>
<CurrentlyReading />
</motion.div>
</motion.div>
</div>
</div>

View File

@@ -0,0 +1,157 @@
"use client";
import { motion } from "framer-motion";
import { BookOpen } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
interface CurrentlyReading {
title: string;
authors: string[];
image: string | null;
progress: number;
startedAt: string | null;
}
const CurrentlyReading = () => {
const t = useTranslations("home.about.currentlyReading");
const [books, setBooks] = useState<CurrentlyReading[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Nur einmal beim Laden der Seite
const fetchCurrentlyReading = async () => {
try {
const res = await fetch("/api/n8n/hardcover/currently-reading", {
cache: "default",
});
if (!res.ok) {
throw new Error("Failed to fetch");
}
const data = await res.json();
// Handle both single book and array of books
if (data.currentlyReading) {
const booksArray = Array.isArray(data.currentlyReading)
? data.currentlyReading
: [data.currentlyReading];
setBooks(booksArray);
} else {
setBooks([]);
}
} catch (error) {
if (process.env.NODE_ENV === "development") {
console.error("Error fetching currently reading:", error);
}
setBooks([]);
} finally {
setLoading(false);
}
};
fetchCurrentlyReading();
}, []); // Leeres Array = nur einmal beim Mount
// Zeige nichts wenn kein Buch gelesen wird oder noch geladen wird
if (loading || books.length === 0) {
return null;
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center gap-2 mb-4">
<BookOpen size={18} className="text-stone-600 flex-shrink-0" />
<h3 className="text-lg font-bold text-stone-900">
{t("title")} {books.length > 1 && `(${books.length})`}
</h3>
</div>
{/* Books List */}
{books.map((book, index) => (
<motion.div
key={`${book.title}-${index}`}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-50px" }}
transition={{ duration: 0.6, delay: index * 0.1, ease: [0.25, 0.1, 0.25, 1] }}
whileHover={{
scale: 1.02,
transition: { duration: 0.4, ease: "easeOut" },
}}
className="relative overflow-hidden bg-gradient-to-br from-liquid-lavender/15 via-liquid-pink/10 to-liquid-rose/15 border-2 border-liquid-lavender/30 rounded-xl p-6 backdrop-blur-sm hover:border-liquid-lavender/50 hover:from-liquid-lavender/20 hover:via-liquid-pink/15 hover:to-liquid-rose/20 transition-all duration-500 ease-out"
>
{/* Background Blob Animation */}
<motion.div
className="absolute -top-10 -right-10 w-32 h-32 bg-gradient-to-br from-liquid-lavender/20 to-liquid-pink/20 rounded-full blur-2xl"
animate={{
scale: [1, 1.2, 1],
opacity: [0.3, 0.5, 0.3],
}}
transition={{
duration: 8,
repeat: Infinity,
ease: "easeInOut",
delay: index * 0.5,
}}
/>
<div className="relative z-10 flex flex-col sm:flex-row gap-4 items-start">
{/* Book Cover */}
{book.image && (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5, delay: 0.2 + index * 0.1 }}
className="flex-shrink-0"
>
<div className="relative w-24 h-36 sm:w-28 sm:h-40 rounded-lg overflow-hidden shadow-lg border-2 border-white/50">
<img
src={book.image}
alt={book.title}
className="w-full h-full object-cover"
loading="lazy"
/>
{/* Glossy Overlay */}
<div className="absolute inset-0 bg-gradient-to-tr from-white/20 via-transparent to-white/10 pointer-events-none" />
</div>
</motion.div>
)}
{/* Book Info */}
<div className="flex-1 min-w-0">
{/* Title */}
<h4 className="text-lg font-bold text-stone-900 mb-1 line-clamp-2">
{book.title}
</h4>
{/* Authors */}
<p className="text-sm text-stone-600 mb-4 line-clamp-1">
{book.authors.join(", ")}
</p>
{/* Progress Bar */}
<div className="space-y-2">
<div className="flex items-center justify-between text-xs text-stone-600">
<span>{t("progress")}</span>
<span className="font-semibold">{book.progress}%</span>
</div>
<div className="relative h-2 bg-white/50 rounded-full overflow-hidden border border-white/70">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${book.progress}%` }}
transition={{ duration: 1, delay: 0.3 + index * 0.1, ease: "easeOut" }}
className="absolute left-0 top-0 h-full bg-gradient-to-r from-liquid-lavender via-liquid-pink to-liquid-rose rounded-full shadow-sm"
/>
</div>
</div>
</div>
</div>
</motion.div>
))}
</div>
);
};
export default CurrentlyReading;

View File

@@ -0,0 +1,459 @@
# 📚 Hardcover Integration Guide
## Übersicht
Diese Anleitung zeigt dir, wie du die Hardcover API in n8n integrierst, um deine aktuell gelesenen Bücher auf deiner Portfolio-Website anzuzeigen.
---
## 🎯 Was wird angezeigt?
Die Integration zeigt:
- **Titel** des aktuell gelesenen Buches
- **Bild** des Buchcovers
- **Autor(en)** des Buches
- **Lesefortschritt** (Prozent)
---
## 📋 Voraussetzungen
1. **Hardcover Account** mit API-Zugriff
2. **n8n Installation** (lokal oder Cloud)
3. **GraphQL Endpoint** von Hardcover
4. **API Credentials** (Token/Key) für Hardcover
---
## 🔧 n8n Workflow Setup
### Schritt 1: Webhook Node erstellen
1. Öffne n8n und erstelle einen neuen Workflow
2. Füge einen **Webhook** Node hinzu
3. Konfiguriere den Webhook:
- **HTTP Method**: `GET`
- **Path**: `/webhook/hardcover/currently-reading`
- **Response Mode**: `Last Node` (wenn du einen separaten Respond Node verwendest) oder `Respond to Webhook` (wenn der Webhook automatisch antworten soll)
- **Response Code**: `200`
**Wichtig:** Wenn du `Response Mode: Last Node` verwendest, musst du einen separaten "Respond to Webhook" Node am Ende hinzufügen. Wenn du `Response Mode: Respond to Webhook` verwendest, entferne den separaten "Respond to Webhook" Node.
### Schritt 2: HTTP Request Node für Hardcover API
1. Füge einen **HTTP Request** Node nach dem Webhook hinzu
2. Konfiguriere den Node:
**Settings:**
- **Method**: `POST`
- **URL**: `https://api.hardcover.app/graphql` (oder deine Hardcover GraphQL URL)
- **Authentication**: `Header Auth` oder `Generic Credential Type`
- **Name**: `Authorization`
- **Value**: `Bearer YOUR_HARDCOVER_TOKEN`
**Headers:**
```
Content-Type: application/json
```
**Body (JSON):**
```json
{
"query": "query GetCurrentlyReading { me { user_books(where: {status_id: {_eq: 2}}) { user_book_reads(limit: 1, order_by: {started_at: desc}) { progress } edition { title image { url } book { contributions { author { name } } } } } } }"
}
```
### Schritt 3: Daten transformieren
1. Füge einen **Code** Node oder **Set** Node hinzu
2. Transformiere die Hardcover-Antwort in das erwartete Format:
**Beispiel Transformation (Code Node - JavaScript):**
```javascript
// Hardcover API Response kommt als GraphQL Response
// Die Response ist ein Array: [{ data: { me: [{ user_books: [...] }] } }]
const graphqlResponse = $input.all()[0].json;
// Extrahiere die Daten - Response-Struktur: [{ data: { me: [{ user_books: [...] }] } }]
const responseData = Array.isArray(graphqlResponse) ? graphqlResponse[0] : graphqlResponse;
const meData = responseData?.data?.me;
const userBooks = (Array.isArray(meData) && meData[0]?.user_books) || meData?.user_books || [];
if (!userBooks || userBooks.length === 0) {
return {
json: {
currentlyReading: null
}
};
}
// Sortiere nach Fortschritt, falls mehrere Bücher vorhanden sind
const sortedBooks = userBooks.sort((a, b) => {
const progressA = a.user_book_reads?.[0]?.progress || 0;
const progressB = b.user_book_reads?.[0]?.progress || 0;
return progressB - progressA; // Höchster zuerst
});
// Formatiere alle Bücher
const formattedBooks = sortedBooks.map(book => {
const edition = book.edition || {};
const bookData = edition.book || {};
const contributions = bookData.contributions || [];
const authors = contributions
.filter(c => c.author && c.author.name)
.map(c => c.author.name);
const readData = book.user_book_reads?.[0] || {};
const progress = readData.progress || 0;
const image = edition.image?.url || null;
return {
title: edition.title || 'Unknown Title',
authors: authors.length > 0 ? authors : ['Unknown Author'],
image: image,
progress: Math.round(progress) || 0, // Progress ist bereits in Prozent (z.B. 65.75)
startedAt: readData.started_at || null,
};
});
// Gib alle Bücher zurück
return {
json: {
currentlyReading: formattedBooks.length > 0 ? formattedBooks : null
}
};
```
```
### Schritt 4: Response Node
**Option A: Automatische Response (Empfohlen)**
1. Setze den Webhook Node auf **Response Mode**: `Respond to Webhook`
2. **Entferne** den separaten "Respond to Webhook" Node
3. Der Webhook antwortet automatisch mit der Ausgabe des Code Nodes
**Option B: Manueller Respond Node**
1. Setze den Webhook Node auf **Response Mode**: `Last Node`
2. Füge einen **Respond to Webhook** Node nach dem Code Node hinzu
3. Verbinde den Code Node mit dem Respond to Webhook Node
4. Stelle sicher, dass die Antwort als JSON zurückgegeben wird
**Response Format (mit allen Büchern):**
```json
{
"currentlyReading": [
{
"title": "Ready Player Two",
"authors": ["Ernest Cline"],
"image": "https://assets.hardcover.app/...",
"progress": 66,
"startedAt": null
},
{
"title": "Die Mitternachtsbibliothek",
"authors": ["Matt Haig"],
"image": "https://assets.hardcover.app/...",
"progress": 57,
"startedAt": null
}
]
}
```
**Oder wenn kein Buch gelesen wird:**
```json
{
"currentlyReading": null
}
```
---
## 🔐 Environment Variables
Stelle sicher, dass folgende Umgebungsvariablen in deiner `.env` Datei gesetzt sind:
```bash
# n8n Configuration
N8N_WEBHOOK_URL=https://n8n.dk0.dev
N8N_SECRET_TOKEN=your-n8n-secret-token
N8N_API_KEY=your-n8n-api-key
# Hardcover API (optional, falls du es direkt verwenden willst)
HARDCOVER_API_URL=https://api.hardcover.app/graphql
HARDCOVER_API_TOKEN=your-hardcover-token
```
---
## 📡 API Endpoint
Die Portfolio-Website stellt folgenden Endpoint bereit:
**GET** `/api/n8n/hardcover/currently-reading`
### Response Format
**Erfolgreich:**
```json
{
"currentlyReading": {
"title": "Der Herr der Ringe",
"authors": ["J.R.R. Tolkien"],
"image": "https://example.com/book-cover.jpg",
"progress": 45,
"startedAt": "2024-01-15T10:00:00Z"
}
}
```
**Kein Buch:**
```json
{
"currentlyReading": null
}
```
**Fehler:**
```json
{
"error": "Rate limit exceeded. Please try again later."
}
```
### Rate Limiting
- **Development**: 60 Requests pro Minute
- **Production**: 10 Requests pro Minute
- **Cache**: 5 Minuten (300 Sekunden)
---
## 🎨 Frontend Integration
Die API sollte **nur einmal beim initialen Laden der Seite** aufgerufen werden.
**Beispiel React Component:**
```typescript
"use client";
import { useEffect, useState } from "react";
interface CurrentlyReading {
title: string;
authors: string[];
image: string | null;
progress: number;
startedAt: string | null;
}
export default function CurrentlyReadingWidget() {
const [book, setBook] = useState<CurrentlyReading | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Nur einmal beim Laden der Seite
const fetchCurrentlyReading = async () => {
try {
const res = await fetch("/api/n8n/hardcover/currently-reading", {
cache: "default",
});
if (!res.ok) {
throw new Error("Failed to fetch");
}
const data = await res.json();
setBook(data.currentlyReading);
} catch (error) {
console.error("Error fetching currently reading:", error);
setBook(null);
} finally {
setLoading(false);
}
};
fetchCurrentlyReading();
}, []); // Leeres Array = nur einmal beim Mount
if (loading) {
return <div>Loading...</div>;
}
if (!book) {
return null; // Kein Buch = nichts anzeigen
}
return (
<div className="currently-reading-widget">
<img src={book.image || "/placeholder-book.png"} alt={book.title} />
<div>
<h3>{book.title}</h3>
<p>{book.authors.join(", ")}</p>
<div className="progress-bar">
<div style={{ width: `${book.progress}%` }} />
</div>
<p>{book.progress}% gelesen</p>
</div>
</div>
);
}
```
---
## 🔍 Troubleshooting
### Problem: "Unused Respond to Webhook node found in the workflow"
**Fehler:**
```
n8n hardcover webhook failed: 500 {"code":0,"message":"Unused Respond to Webhook node found in the workflow"}
```
**Lösung:**
Dieser Fehler tritt auf, wenn du einen separaten "Respond to Webhook" Node hast, der nicht korrekt mit dem Workflow verbunden ist.
**Option 1: Automatische Response verwenden (Empfohlen)**
1. Öffne den **Webhook** Node
2. Stelle sicher, dass **Response Mode** auf `Respond to Webhook` gesetzt ist
3. Entferne den separaten "Respond to Webhook" Node (falls vorhanden)
4. Der Webhook Node antwortet automatisch mit der letzten Node-Ausgabe
**Option 2: Manueller Respond Node**
1. Falls du einen separaten "Respond to Webhook" Node verwenden möchtest:
- Stelle sicher, dass dieser Node **direkt nach dem Code/Set Node** verbunden ist
- Der Webhook Node sollte auf **Response Mode: `Last Node`** gesetzt sein
- Der "Respond to Webhook" Node muss die Daten vom Code Node erhalten
**Workflow-Struktur (Option 1 - Empfohlen):**
```
Webhook (Response Mode: Respond to Webhook)
HTTP Request (Hardcover API)
Code Node (Transformation)
(Webhook antwortet automatisch mit Code Node Output)
```
**Workflow-Struktur (Option 2):**
```
Webhook (Response Mode: Last Node)
HTTP Request (Hardcover API)
Code Node (Transformation)
Respond to Webhook Node
```
### Problem: n8n Webhook gibt leere Antwort zurück
**Lösung:**
- Prüfe, ob der Hardcover API Token korrekt ist
- Stelle sicher, dass der GraphQL Query korrekt formatiert ist
- Prüfe die n8n Logs für Fehlerdetails
- Stelle sicher, dass der Code Node die Daten korrekt zurückgibt (`return { json: {...} }`)
### Problem: API gibt `null` zurück, obwohl ein Buch gelesen wird
**Lösung:**
- Prüfe, ob `status_id: 2` der korrekte Status für "Currently Reading" ist
- Stelle sicher, dass der GraphQL Query die richtigen Felder abfragt
- Prüfe die Hardcover API direkt mit einem GraphQL Client
- Debug: Füge einen `console.log` im Code Node hinzu, um die rohe Response zu sehen
### Problem: Rate Limit Fehler
**Lösung:**
- Die API cached Daten für 5 Minuten
- Reduziere die Anzahl der API-Aufrufe im Frontend
- Stelle sicher, dass die API nur einmal beim Laden der Seite aufgerufen wird
### Problem: CORS Fehler
**Lösung:**
- n8n sollte die CORS-Header korrekt setzen
- Prüfe die n8n Webhook-Konfiguration
- Stelle sicher, dass die Portfolio-Website-URL in n8n erlaubt ist
---
## 📚 GraphQL Query Details
Der verwendete GraphQL Query:
```graphql
query GetCurrentlyReading {
me {
user_books(where: {status_id: {_eq: 2}}) {
user_book_reads(limit: 1, order_by: {started_at: desc}) {
progress
}
edition {
title
image {
url
}
book {
contributions {
author {
name
}
}
}
}
}
}
}
```
**Erklärung:**
- `status_id: {_eq: 2}` = Filtert nach Büchern mit Status "Currently Reading" (ID 2)
- `user_book_reads(limit: 1, order_by: {started_at: desc})` = Holt den neuesten Lesefortschritt
- `progress` = Lesefortschritt als Dezimalzahl (0.0 - 1.0)
- `edition.title` = Titel des Buches
- `edition.image.url` = URL zum Buchcover
- `book.contributions[].author.name` = Liste der Autorennamen
---
## 🚀 Deployment
1. **n8n Workflow aktivieren**
- Stelle sicher, dass der Workflow aktiviert ist
- Teste den Webhook mit einem GET Request
2. **Environment Variables setzen**
- Füge `N8N_WEBHOOK_URL` zur `.env` hinzu
- Füge `N8N_SECRET_TOKEN` hinzu (optional, für Auth)
3. **Frontend Integration**
- Füge die `CurrentlyReadingWidget` Komponente zur Homepage hinzu
- Stelle sicher, dass die API nur einmal aufgerufen wird
4. **Testen**
- Lade die Homepage neu
- Prüfe die Browser-Konsole für Fehler
- Prüfe die Network-Tab für API-Aufrufe
---
## 📝 Notizen
- Die API cached Daten für **5 Minuten**, um n8n nicht zu überlasten
- Die API sollte **nur einmal beim initialen Laden** der Seite aufgerufen werden
- Falls kein Buch gelesen wird, gibt die API `null` zurück
- Die API verwendet Rate Limiting, um Missbrauch zu verhindern
---
## 🔗 Weitere Ressourcen
- [Hardcover API Dokumentation](https://hardcover.app)
- [n8n Dokumentation](https://docs.n8n.io)
- [GraphQL Best Practices](https://graphql.org/learn/best-practices/)

View File

@@ -60,6 +60,10 @@
"gaming": "Gaming",
"gameServers": "Game-Server einrichten",
"jogging": "Joggen zum Kopf freibekommen und aktiv bleiben"
},
"currentlyReading": {
"title": "Aktuell am Lesen",
"progress": "Fortschritt"
}
},
"projects": {

View File

@@ -60,6 +60,10 @@
"gaming": "Gaming",
"gameServers": "Setting up game servers",
"jogging": "Jogging to clear my mind and stay active"
},
"currentlyReading": {
"title": "Currently Reading",
"progress": "Progress"
}
},
"projects": {