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:
131
app/api/n8n/hardcover/currently-reading/route.ts
Normal file
131
app/api/n8n/hardcover/currently-reading/route.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { useLocale, useTranslations } from "next-intl";
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
import type { JSONContent } from "@tiptap/react";
|
import type { JSONContent } from "@tiptap/react";
|
||||||
import RichTextClient from "./RichTextClient";
|
import RichTextClient from "./RichTextClient";
|
||||||
|
import CurrentlyReading from "./CurrentlyReading";
|
||||||
|
|
||||||
const staggerContainer: Variants = {
|
const staggerContainer: Variants = {
|
||||||
hidden: { opacity: 0 },
|
hidden: { opacity: 0 },
|
||||||
@@ -239,6 +240,14 @@ const About = () => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Currently Reading */}
|
||||||
|
<motion.div
|
||||||
|
variants={fadeInUp}
|
||||||
|
className="mt-8"
|
||||||
|
>
|
||||||
|
<CurrentlyReading />
|
||||||
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
157
app/components/CurrentlyReading.tsx
Normal file
157
app/components/CurrentlyReading.tsx
Normal 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;
|
||||||
459
docs/HARDCOVER_INTEGRATION.md
Normal file
459
docs/HARDCOVER_INTEGRATION.md
Normal 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/)
|
||||||
@@ -60,6 +60,10 @@
|
|||||||
"gaming": "Gaming",
|
"gaming": "Gaming",
|
||||||
"gameServers": "Game-Server einrichten",
|
"gameServers": "Game-Server einrichten",
|
||||||
"jogging": "Joggen zum Kopf freibekommen und aktiv bleiben"
|
"jogging": "Joggen zum Kopf freibekommen und aktiv bleiben"
|
||||||
|
},
|
||||||
|
"currentlyReading": {
|
||||||
|
"title": "Aktuell am Lesen",
|
||||||
|
"progress": "Fortschritt"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"projects": {
|
"projects": {
|
||||||
|
|||||||
@@ -60,6 +60,10 @@
|
|||||||
"gaming": "Gaming",
|
"gaming": "Gaming",
|
||||||
"gameServers": "Setting up game servers",
|
"gameServers": "Setting up game servers",
|
||||||
"jogging": "Jogging to clear my mind and stay active"
|
"jogging": "Jogging to clear my mind and stay active"
|
||||||
|
},
|
||||||
|
"currentlyReading": {
|
||||||
|
"title": "Currently Reading",
|
||||||
|
"progress": "Progress"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"projects": {
|
"projects": {
|
||||||
|
|||||||
Reference in New Issue
Block a user