diff --git a/app/api/book-reviews/route.ts b/app/api/book-reviews/route.ts
new file mode 100644
index 0000000..36b9dd1
--- /dev/null
+++ b/app/api/book-reviews/route.ts
@@ -0,0 +1,45 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { getBookReviews } from '@/lib/directus';
+
+export const runtime = 'nodejs';
+export const dynamic = 'force-dynamic';
+
+/**
+ * GET /api/book-reviews
+ *
+ * Loads Book Reviews from Directus CMS
+ *
+ * Query params:
+ * - locale: en or de (default: en)
+ */
+export async function GET(request: NextRequest) {
+ try {
+ const { searchParams } = new URL(request.url);
+ const locale = searchParams.get('locale') || 'en';
+
+ const reviews = await getBookReviews(locale);
+
+ if (reviews && reviews.length > 0) {
+ return NextResponse.json({
+ bookReviews: reviews,
+ source: 'directus'
+ });
+ }
+
+ return NextResponse.json({
+ bookReviews: null,
+ source: 'fallback'
+ });
+
+ } catch (error) {
+ console.error('Error loading book reviews:', error);
+ return NextResponse.json(
+ {
+ bookReviews: null,
+ error: 'Failed to load book reviews',
+ source: 'error'
+ },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/components/About.tsx b/app/components/About.tsx
index 40823e9..ff0e368 100644
--- a/app/components/About.tsx
+++ b/app/components/About.tsx
@@ -7,6 +7,7 @@ import { useLocale, useTranslations } from "next-intl";
import type { JSONContent } from "@tiptap/react";
import RichTextClient from "./RichTextClient";
import CurrentlyReading from "./CurrentlyReading";
+import ReadBooks from "./ReadBooks";
// Type definitions for CMS data
interface TechStackItem {
@@ -389,6 +390,14 @@ const About = () => {
>
+
+ {/* Read Books with Ratings */}
+
+
+
diff --git a/app/components/ReadBooks.tsx b/app/components/ReadBooks.tsx
new file mode 100644
index 0000000..d99c0d6
--- /dev/null
+++ b/app/components/ReadBooks.tsx
@@ -0,0 +1,212 @@
+"use client";
+
+import { motion } from "framer-motion";
+import { BookCheck, Star, ChevronDown, ChevronUp } from "lucide-react";
+import { useEffect, useState } from "react";
+import { useLocale, useTranslations } from "next-intl";
+
+interface BookReview {
+ id: string;
+ hardcover_id?: string;
+ book_title: string;
+ book_author: string;
+ book_image?: string;
+ rating: number;
+ review?: string;
+ finished_at?: string;
+}
+
+const StarRating = ({ rating }: { rating: number }) => {
+ return (
+
+ {[1, 2, 3, 4, 5].map((star) => (
+
+ ))}
+
+ );
+};
+
+const ReadBooks = () => {
+ const locale = useLocale();
+ const t = useTranslations("home.about.readBooks");
+ const [reviews, setReviews] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [expanded, setExpanded] = useState(false);
+
+ const INITIAL_SHOW = 3;
+
+ useEffect(() => {
+ const fetchReviews = async () => {
+ try {
+ const res = await fetch(
+ `/api/book-reviews?locale=${encodeURIComponent(locale)}`,
+ { cache: "default" }
+ );
+
+ if (!res.ok) {
+ throw new Error("Failed to fetch");
+ }
+
+ const data = await res.json();
+ if (data.bookReviews) {
+ setReviews(data.bookReviews);
+ } else {
+ setReviews([]);
+ }
+ } catch (error) {
+ if (process.env.NODE_ENV === "development") {
+ console.error("Error fetching book reviews:", error);
+ }
+ setReviews([]);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchReviews();
+ }, [locale]);
+
+ if (loading || reviews.length === 0) {
+ return null;
+ }
+
+ const visibleReviews = expanded ? reviews : reviews.slice(0, INITIAL_SHOW);
+ const hasMore = reviews.length > INITIAL_SHOW;
+
+ return (
+
+ {/* Header */}
+
+
+
+ {t("title")} ({reviews.length})
+
+
+
+ {/* Book Reviews */}
+ {visibleReviews.map((review, index) => (
+
+ {/* Background Blob */}
+
+
+
+ {/* Book Cover */}
+ {review.book_image && (
+
+
+

+
+
+
+ )}
+
+ {/* Book Info */}
+
+
+ {review.book_title}
+
+
+ {review.book_author}
+
+
+ {/* Rating */}
+
+
+
+ {review.rating}/5
+
+
+
+ {/* Review Text */}
+ {review.review && (
+
+ “{review.review}”
+
+ )}
+
+ {/* Finished Date */}
+ {review.finished_at && (
+
+ {t("finishedAt")}{" "}
+ {new Date(review.finished_at).toLocaleDateString(
+ locale === "de" ? "de-DE" : "en-US",
+ { year: "numeric", month: "short" }
+ )}
+
+ )}
+
+
+
+ ))}
+
+ {/* Show More / Show Less */}
+ {hasMore && (
+
setExpanded(!expanded)}
+ className="w-full flex items-center justify-center gap-1.5 py-2.5 text-sm font-medium text-stone-600 hover:text-stone-800 rounded-lg border-2 border-dashed border-stone-200 hover:border-stone-300 transition-colors duration-300"
+ >
+ {expanded ? (
+ <>
+ {t("showLess")}
+ >
+ ) : (
+ <>
+ {t("showMore", { count: reviews.length - INITIAL_SHOW })}{" "}
+
+ >
+ )}
+
+ )}
+
+ );
+};
+
+export default ReadBooks;
diff --git a/lib/directus.ts b/lib/directus.ts
index 7abe337..b4e0956 100644
--- a/lib/directus.ts
+++ b/lib/directus.ts
@@ -422,6 +422,71 @@ export async function getHobbies(locale: string): Promise {
}
}
+// Book Review Types
+export interface BookReview {
+ id: string;
+ hardcover_id?: string;
+ book_title: string;
+ book_author: string;
+ book_image?: string;
+ rating: number; // 1-5
+ review?: string; // Translated review text
+ finished_at?: string;
+}
+
+/**
+ * Get Book Reviews from Directus with translations
+ */
+export async function getBookReviews(locale: string): Promise {
+ const directusLocale = toDirectusLocale(locale);
+
+ const query = `
+ query {
+ book_reviews(
+ filter: { status: { _eq: "published" } }
+ sort: ["-finished_at", "-date_created"]
+ ) {
+ id
+ hardcover_id
+ book_title
+ book_author
+ book_image
+ rating
+ finished_at
+ translations(filter: { languages_code: { code: { _eq: "${directusLocale}" } } }) {
+ review
+ }
+ }
+ }
+ `;
+
+ try {
+ const result = await directusRequest(
+ '',
+ { body: { query } }
+ );
+
+ const reviews = (result as any)?.book_reviews;
+ if (!reviews || reviews.length === 0) {
+ return null;
+ }
+
+ return reviews.map((item: any) => ({
+ id: item.id,
+ hardcover_id: item.hardcover_id || undefined,
+ book_title: item.book_title,
+ book_author: item.book_author,
+ book_image: item.book_image || undefined,
+ rating: typeof item.rating === 'number' ? item.rating : parseInt(item.rating) || 0,
+ review: item.translations?.[0]?.review || undefined,
+ finished_at: item.finished_at || undefined,
+ }));
+ } catch (error) {
+ console.error(`Failed to fetch book reviews (${locale}):`, error);
+ return null;
+ }
+}
+
// Projects Types
export interface Project {
id: string;
diff --git a/messages/de.json b/messages/de.json
index 838aa4e..1c7cc05 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -63,6 +63,12 @@
"currentlyReading": {
"title": "Aktuell am Lesen",
"progress": "Fortschritt"
+ },
+ "readBooks": {
+ "title": "Gelesen",
+ "finishedAt": "Beendet",
+ "showMore": "{count} weitere",
+ "showLess": "Weniger anzeigen"
}
},
"projects": {
diff --git a/messages/en.json b/messages/en.json
index 22aa7a9..2923000 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -64,6 +64,12 @@
"currentlyReading": {
"title": "Currently Reading",
"progress": "Progress"
+ },
+ "readBooks": {
+ "title": "Read",
+ "finishedAt": "Finished",
+ "showMore": "{count} more",
+ "showLess": "Show less"
}
},
"projects": {