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 && ( + +
+ {review.book_title} +
+
+ + )} + + {/* 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": {