feat: Hardcover→Directus book sync + fix empty states for projects/books
All checks were successful
CI / CD / test-build (push) Successful in 11m5s
CI / CD / deploy-dev (push) Successful in 1m18s
CI / CD / deploy-production (push) Has been skipped

- Add POST /api/n8n/hardcover/sync-books — n8n calls this after detecting
  finished books in Hardcover. Authenticates via N8N_SECRET_TOKEN/N8N_API_KEY,
  deduplicates by hardcover_id, creates new book_reviews entries in Directus.

- Add getBookReviewByHardcoverId() + createBookReview() to lib/directus.ts.
  Check uses GraphQL filter; create uses Directus REST POST /items/book_reviews.

- ReadBooks: replace silent return null with a visible empty state so the
  section stays visible with a hint until the n8n sync populates it.

- Projects: add "No projects yet." placeholder instead of blank grid when
  both Directus and PostgreSQL return no data.

- Add home.about.readBooks.empty i18n key (EN + DE).

n8n workflow setup:
  Schedule → HTTP Hardcover GraphQL (books_read) → Code (transform) →
  POST /api/n8n/hardcover/sync-books with array of { hardcover_id, title,
  author, image, rating, finished_at }

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 00:02:52 +01:00
parent 7f9d39c275
commit d7958b3841
6 changed files with 219 additions and 3 deletions

View File

@@ -0,0 +1,125 @@
/**
* POST /api/n8n/hardcover/sync-books
*
* Called by an n8n workflow whenever books are finished in Hardcover.
* Creates new entries in the Directus book_reviews collection.
* Deduplicates by hardcover_id — safe to call repeatedly.
*
* n8n Workflow setup:
* 1. Schedule Trigger (every hour)
* 2. HTTP Request → Hardcover GraphQL (query: me { books_read(limit: 20) { ... } })
* 3. Code Node → transform to array of HardcoverBook objects
* 4. HTTP Request → POST https://dk0.dev/api/n8n/hardcover/sync-books
* Headers: Authorization: Bearer <N8N_SECRET_TOKEN>
* Body: [{ hardcover_id, title, author, image, rating, finished_at }, ...]
*
* Expected body shape (array or single object):
* {
* hardcover_id: string | number // Hardcover book ID, used for deduplication
* title: string
* author: string
* image?: string // Cover image URL
* rating?: number // 15
* finished_at?: string // ISO date string
* }
*/
import { NextRequest, NextResponse } from 'next/server';
import { getBookReviewByHardcoverId, createBookReview } from '@/lib/directus';
import { checkRateLimit, getClientIp } from '@/lib/auth';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
interface HardcoverBook {
hardcover_id: string | number;
title: string;
author: string;
image?: string;
rating?: number;
finished_at?: string;
}
export async function POST(request: NextRequest) {
// Auth: require N8N_SECRET_TOKEN or N8N_API_KEY
const authHeader = request.headers.get('Authorization');
const apiKeyHeader = request.headers.get('X-API-Key');
const validToken = process.env.N8N_SECRET_TOKEN;
const validApiKey = process.env.N8N_API_KEY;
const isAuthenticated =
(validToken && authHeader === `Bearer ${validToken}`) ||
(validApiKey && apiKeyHeader === validApiKey);
if (!isAuthenticated) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Rate limit: max 10 sync requests per minute
const ip = getClientIp(request);
if (!checkRateLimit(ip, 10, 60000, 'hardcover-sync')) {
return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 });
}
let books: HardcoverBook[];
try {
const body = await request.json();
books = Array.isArray(body) ? body : [body];
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
if (books.length === 0) {
return NextResponse.json({ success: true, created: 0, skipped: 0, errors: 0 });
}
const results = {
created: 0,
skipped: 0,
errors: 0,
details: [] as string[],
};
for (const book of books) {
if (!book.title || !book.author) {
results.errors++;
results.details.push(`Skipped (missing title/author): ${JSON.stringify(book).slice(0, 80)}`);
continue;
}
const hardcoverId = String(book.hardcover_id);
// Deduplication: skip if already in Directus
const existing = await getBookReviewByHardcoverId(hardcoverId);
if (existing) {
results.skipped++;
results.details.push(`Skipped (exists): "${book.title}"`);
continue;
}
// Create new entry in Directus
const created = await createBookReview({
hardcover_id: hardcoverId,
book_title: book.title,
book_author: book.author,
book_image: book.image,
rating: book.rating,
finished_at: book.finished_at,
status: 'published',
});
if (created) {
results.created++;
results.details.push(`Created: "${book.title}" → id=${created.id}`);
} else {
results.errors++;
results.details.push(`Error creating: "${book.title}" (Directus unavailable or token missing)`);
}
}
if (process.env.NODE_ENV === 'development') {
console.log('[sync-books]', results);
}
return NextResponse.json({ success: true, source: 'directus', ...results });
}

View File

@@ -74,6 +74,10 @@ const Projects = () => {
</div>
</div>
))
) : projects.length === 0 ? (
<div className="col-span-2 py-12 text-center text-stone-400 dark:text-stone-600 text-sm">
No projects yet.
</div>
) : (
projects.map((project) => (
<motion.div

View File

@@ -101,7 +101,12 @@ const ReadBooks = () => {
}
if (reviews.length === 0) {
return null; // Hier kannst du temporär "Keine Bücher gefunden" reinschreiben zum Testen
return (
<div className="flex items-center gap-3 py-4 text-sm text-stone-400 dark:text-stone-500">
<BookCheck size={16} className="shrink-0" />
<span>{t("empty")}</span>
</div>
);
}
const visibleReviews = expanded ? reviews : reviews.slice(0, INITIAL_SHOW);

View File

@@ -993,3 +993,83 @@ export async function getSnippets(limit = 10, featured?: boolean): Promise<Snipp
return null;
}
}
// ─── Hardcover → Directus sync helpers ───────────────────────────────────────
export interface BookReviewCreate {
hardcover_id: string;
book_title: string;
book_author: string;
book_image?: string;
rating?: number;
finished_at?: string;
status: 'published' | 'draft';
}
/**
* Check if a book review already exists in Directus by Hardcover ID.
* Used for deduplication during sync.
*/
export async function getBookReviewByHardcoverId(
hardcoverId: string
): Promise<{ id: string } | null> {
const query = `
query {
book_reviews(
filter: { hardcover_id: { _eq: "${hardcoverId}" } }
limit: 1
) {
id
}
}
`;
try {
const result = await directusRequest<{ book_reviews: Array<{ id: string }> }>(
'',
{ body: { query } }
);
const item = result?.book_reviews?.[0];
return item ?? null;
} catch {
return null;
}
}
/**
* Create a new book review in the Directus book_reviews collection via REST API.
* Returns the created item id, or null on failure.
*/
export async function createBookReview(
data: BookReviewCreate
): Promise<{ id: string } | null> {
if (!DIRECTUS_TOKEN) return null;
try {
const response = await fetch(`${DIRECTUS_URL}/items/book_reviews`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${DIRECTUS_TOKEN}`,
},
body: JSON.stringify(data),
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
const text = await response.text();
if (process.env.NODE_ENV === 'development') {
console.error(`Directus create book_review failed ${response.status}:`, text.slice(0, 200));
}
return null;
}
const result = await response.json();
return result?.data?.id ? { id: result.data.id } : null;
} catch (error) {
if (process.env.NODE_ENV === 'development') {
console.error('createBookReview error:', error);
}
return null;
}
}

View File

@@ -69,7 +69,8 @@
"title": "Gelesene Bücher",
"finishedAt": "Beendet am",
"showMore": "{count} weitere anzeigen",
"showLess": "Weniger anzeigen"
"showLess": "Weniger anzeigen",
"empty": "In Hardcover fertig gelesene Bücher erscheinen hier automatisch."
},
"activity": {
"idleStatus": "System im Leerlauf / Geist aktiv",

View File

@@ -70,7 +70,8 @@
"title": "Read",
"finishedAt": "Finished",
"showMore": "{count} more",
"showLess": "Show less"
"showLess": "Show less",
"empty": "Books finished in Hardcover will appear here automatically."
},
"activity": {
"idleStatus": "System Idle / Mind Active",