From 38a98a9ea23da834fa22529b5a566795f8475e57 Mon Sep 17 00:00:00 2001 From: denshooter Date: Thu, 15 Jan 2026 14:58:34 +0100 Subject: [PATCH] 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 --- .../n8n/hardcover/currently-reading/route.ts | 131 +++++ app/components/About.tsx | 9 + app/components/CurrentlyReading.tsx | 157 ++++++ docs/HARDCOVER_INTEGRATION.md | 459 ++++++++++++++++++ messages/de.json | 4 + messages/en.json | 4 + 6 files changed, 764 insertions(+) create mode 100644 app/api/n8n/hardcover/currently-reading/route.ts create mode 100644 app/components/CurrentlyReading.tsx create mode 100644 docs/HARDCOVER_INTEGRATION.md diff --git a/app/api/n8n/hardcover/currently-reading/route.ts b/app/api/n8n/hardcover/currently-reading/route.ts new file mode 100644 index 0000000..0ec007c --- /dev/null +++ b/app/api/n8n/hardcover/currently-reading/route.ts @@ -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, + }); + } +} diff --git a/app/components/About.tsx b/app/components/About.tsx index 93eee7d..5f08fb8 100644 --- a/app/components/About.tsx +++ b/app/components/About.tsx @@ -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 = () => { ))} + + {/* Currently Reading */} + + + diff --git a/app/components/CurrentlyReading.tsx b/app/components/CurrentlyReading.tsx new file mode 100644 index 0000000..2c18486 --- /dev/null +++ b/app/components/CurrentlyReading.tsx @@ -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([]); + 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 ( +
+ {/* Header */} +
+ +

+ {t("title")} {books.length > 1 && `(${books.length})`} +

+
+ + {/* Books List */} + {books.map((book, index) => ( + + {/* Background Blob Animation */} + + +
+ {/* Book Cover */} + {book.image && ( + +
+ {book.title} + {/* Glossy Overlay */} +
+
+ + )} + + {/* Book Info */} +
+ {/* Title */} +

+ {book.title} +

+ + {/* Authors */} +

+ {book.authors.join(", ")} +

+ + {/* Progress Bar */} +
+
+ {t("progress")} + {book.progress}% +
+
+ +
+
+
+
+
+ ))} +
+ ); +}; + +export default CurrentlyReading; diff --git a/docs/HARDCOVER_INTEGRATION.md b/docs/HARDCOVER_INTEGRATION.md new file mode 100644 index 0000000..1c3b3a3 --- /dev/null +++ b/docs/HARDCOVER_INTEGRATION.md @@ -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(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
Loading...
; + } + + if (!book) { + return null; // Kein Buch = nichts anzeigen + } + + return ( +
+ {book.title} +
+

{book.title}

+

{book.authors.join(", ")}

+
+
+
+

{book.progress}% gelesen

+
+
+ ); +} +``` + +--- + +## 🔍 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/) diff --git a/messages/de.json b/messages/de.json index c64c1f2..fad6009 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index 8a5b079..d5a625a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": {