Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Failing after 10m3s
Added rate limiting to APIs, cleaned up docs, implemented fallback logic for reviews without text, and added comprehensive n8n guide.
217 lines
7.3 KiB
TypeScript
217 lines
7.3 KiB
TypeScript
"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";
|
|
import Image from "next/image";
|
|
|
|
interface BookReview {
|
|
id: string;
|
|
hardcover_id?: string;
|
|
book_title: string;
|
|
book_author: string;
|
|
book_image?: string;
|
|
rating?: number | null;
|
|
review?: string | null;
|
|
finished_at?: string;
|
|
}
|
|
|
|
const StarRating = ({ rating }: { rating: number }) => {
|
|
return (
|
|
<div className="flex gap-0.5">
|
|
{[1, 2, 3, 4, 5].map((star) => (
|
|
<Star
|
|
key={star}
|
|
size={14}
|
|
className={
|
|
star <= rating
|
|
? "text-amber-500 fill-amber-500"
|
|
: "text-stone-300 dark:text-stone-600"
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const ReadBooks = () => {
|
|
const locale = useLocale();
|
|
const t = useTranslations("home.about.readBooks");
|
|
const [reviews, setReviews] = useState<BookReview[]>([]);
|
|
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 (
|
|
<div className="space-y-4">
|
|
{/* Header */}
|
|
<div className="flex items-center gap-2 mb-4">
|
|
<BookCheck size={18} className="text-stone-600 dark:text-stone-300 flex-shrink-0" />
|
|
<h3 className="text-lg font-bold text-stone-900 dark:text-stone-100">
|
|
{t("title")} ({reviews.length})
|
|
</h3>
|
|
</div>
|
|
|
|
{/* Book Reviews */}
|
|
{visibleReviews.map((review, index) => (
|
|
<motion.div
|
|
key={review.id}
|
|
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-mint/15 via-liquid-sky/10 to-liquid-teal/15 dark:from-stone-800 dark:via-stone-800 dark:to-stone-700 border-2 border-liquid-mint/30 dark:border-stone-700 rounded-xl p-5 backdrop-blur-sm hover:border-liquid-mint/50 dark:hover:border-stone-600 hover:from-liquid-mint/20 hover:via-liquid-sky/15 hover:to-liquid-teal/20 transition-all duration-500 ease-out"
|
|
>
|
|
{/* Background Blob */}
|
|
<motion.div
|
|
className="absolute -bottom-8 -left-8 w-28 h-28 bg-gradient-to-br from-liquid-mint/20 to-liquid-sky/20 dark:from-stone-700 dark:to-stone-600 rounded-full blur-2xl"
|
|
animate={{
|
|
scale: [1, 1.15, 1],
|
|
opacity: [0.3, 0.45, 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 */}
|
|
{review.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-20 h-[7.5rem] sm:w-24 sm:h-32 rounded-lg overflow-hidden shadow-lg border-2 border-white/50 dark:border-stone-600">
|
|
<Image
|
|
src={review.book_image}
|
|
alt={review.book_title}
|
|
fill
|
|
className="object-cover"
|
|
sizes="(max-width: 640px) 80px, 96px"
|
|
/>
|
|
<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">
|
|
<h4 className="text-base font-bold text-stone-900 dark:text-stone-100 mb-0.5 line-clamp-2">
|
|
{review.book_title}
|
|
</h4>
|
|
<p className="text-sm text-stone-600 dark:text-stone-400 mb-2 line-clamp-1">
|
|
{review.book_author}
|
|
</p>
|
|
|
|
{/* Rating (Optional) */}
|
|
{review.rating && review.rating > 0 && (
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<StarRating rating={review.rating} />
|
|
<span className="text-xs text-stone-500 dark:text-stone-400 font-medium">
|
|
{review.rating}/5
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Review Text (Optional) */}
|
|
{review.review && (
|
|
<p className="text-sm text-stone-700 dark:text-stone-300 leading-relaxed line-clamp-3 italic">
|
|
“{review.review}”
|
|
</p>
|
|
)}
|
|
|
|
{/* Finished Date */}
|
|
{review.finished_at && (
|
|
<p className="text-xs text-stone-400 dark:text-stone-500 mt-2">
|
|
{t("finishedAt")}{" "}
|
|
{new Date(review.finished_at).toLocaleDateString(
|
|
locale === "de" ? "de-DE" : "en-US",
|
|
{ year: "numeric", month: "short" }
|
|
)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
))}
|
|
|
|
{/* Show More / Show Less */}
|
|
{hasMore && (
|
|
<motion.button
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
transition={{ delay: 0.3 }}
|
|
onClick={() => setExpanded(!expanded)}
|
|
className="w-full flex items-center justify-center gap-1.5 py-2.5 text-sm font-medium text-stone-600 dark:text-stone-400 hover:text-stone-800 dark:hover:text-stone-200 rounded-lg border-2 border-dashed border-stone-200 dark:border-stone-700 hover:border-stone-300 dark:hover:border-stone-600 transition-colors duration-300"
|
|
>
|
|
{expanded ? (
|
|
<>
|
|
{t("showLess")} <ChevronUp size={16} />
|
|
</>
|
|
) : (
|
|
<>
|
|
{t("showMore", { count: reviews.length - INITIAL_SHOW })}{" "}
|
|
<ChevronDown size={16} />
|
|
</>
|
|
)}
|
|
</motion.button>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ReadBooks;
|