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 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>
|
||||
|
||||
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",
|
||||
"gameServers": "Game-Server einrichten",
|
||||
"jogging": "Joggen zum Kopf freibekommen und aktiv bleiben"
|
||||
},
|
||||
"currentlyReading": {
|
||||
"title": "Aktuell am Lesen",
|
||||
"progress": "Fortschritt"
|
||||
}
|
||||
},
|
||||
"projects": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user