31560a712f
- Fix ClientWrappers missing 'about' namespace (MISSING_MESSAGE error) - Add system/light/dark theme toggle with prefers-color-scheme detection - Rewrite 404 page with i18n, accessibility, and proper navigation - Rewrite books page with Header/Footer, i18n, and semantic HTML - Add i18n keys to About, Footer, and both locale files - Fix dark mode contrast: text-stone-300/600 -> text-stone-400 - Replace raw hex bg-[#fdfcf8] with bg-stone-50 across all components - Guard console.error in ChatWidget and manage/page behind NODE_ENV - Add aria-label to admin login form - Remove emoji from manage page password toggle - Update stale dates in privacy-policy and legal-notice - Fix ScrollFadeIn index->delay prop type error in books page - Fix privacy-policy and legal-notice landmark structure - Add pre-push-check.test.ts: 13-category static analysis (i18n parity, namespace coverage, key resolution, accessibility, email validation, hex colors, emojis, console guards, env docs, types) - Add explicit i18n check step to CI workflow
141 lines
5.5 KiB
TypeScript
141 lines
5.5 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import { motion } from "framer-motion";
|
|
import { ArrowUpRight } from "lucide-react";
|
|
import Link from "next/link";
|
|
import Image from "next/image";
|
|
import { useLocale, useTranslations } from "next-intl";
|
|
import { Skeleton } from "./ui/Skeleton";
|
|
import ProjectThumbnail from "./ProjectThumbnail";
|
|
|
|
interface Project {
|
|
id: number;
|
|
slug: string;
|
|
title: string;
|
|
description: string;
|
|
content: string;
|
|
tags: string[];
|
|
featured: boolean;
|
|
category: string;
|
|
date: string;
|
|
github?: string;
|
|
live?: string;
|
|
imageUrl?: string;
|
|
}
|
|
|
|
const Projects = () => {
|
|
const [projects, setProjects] = useState<Project[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const locale = useLocale();
|
|
const t = useTranslations("home.projects");
|
|
|
|
useEffect(() => {
|
|
const loadProjects = async () => {
|
|
try {
|
|
const response = await fetch("/api/projects?featured=true&published=true&limit=6");
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setProjects(data.projects || []);
|
|
}
|
|
} catch (error) {
|
|
if (process.env.NODE_ENV === "development") console.error("Featured projects fetch failed:", error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
loadProjects();
|
|
}, []);
|
|
|
|
return (
|
|
<section id="projects" className="py-16 sm:py-24 md:py-32 px-4 sm:px-6 bg-stone-50 dark:bg-stone-950">
|
|
<div className="max-w-7xl mx-auto">
|
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-end mb-8 sm:mb-12 md:mb-16 gap-4 sm:gap-6">
|
|
<div>
|
|
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-black text-stone-900 dark:text-stone-50 tracking-tighter mb-2 sm:mb-4 uppercase">
|
|
{t("title")}<span className="text-emerald-600 dark:text-emerald-400">.</span>
|
|
</h2>
|
|
<p className="text-base sm:text-lg md:text-xl text-stone-500 max-w-xl font-light">
|
|
{t("subtitle")}
|
|
</p>
|
|
</div>
|
|
<Link href={`/${locale}/projects`} className="group flex items-center gap-2 text-stone-900 dark:text-stone-100 font-black border-b-2 border-stone-900 dark:border-stone-100 pb-1 hover:opacity-70 transition-all text-xs uppercase tracking-widest">
|
|
{t("viewAll")} <ArrowUpRight className="group-hover:-translate-y-1 group-hover:translate-x-1 transition-transform" size={14} />
|
|
</Link>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-12">
|
|
{Array.from({ length: 4 }).map((_, i) => (
|
|
<div key={i} className="space-y-4">
|
|
<Skeleton className="aspect-[16/10] sm:aspect-[4/3] rounded-2xl sm:rounded-3xl" />
|
|
<Skeleton className="h-6 w-3/4" />
|
|
<Skeleton className="h-4 w-1/2" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-12">
|
|
{projects.length === 0 ? (
|
|
<div className="col-span-2 py-12 text-center text-stone-400 dark:text-stone-600 text-sm">
|
|
{t("noProjects")}
|
|
</div>
|
|
) : (
|
|
projects.map((project) => (
|
|
<motion.div
|
|
key={project.id}
|
|
className="group relative"
|
|
>
|
|
<Link href={`/${locale}/projects/${project.slug}`} className="block">
|
|
{/* Image Card */}
|
|
<div className="relative aspect-[16/10] sm:aspect-[4/3] rounded-2xl sm:rounded-3xl overflow-hidden bg-stone-200 dark:bg-stone-900 mb-4 sm:mb-6">
|
|
{project.imageUrl ? (
|
|
<Image
|
|
src={project.imageUrl}
|
|
alt={project.title}
|
|
fill
|
|
className="object-cover transition-transform duration-700 group-hover:scale-105"
|
|
/>
|
|
) : (
|
|
<ProjectThumbnail
|
|
title={project.title}
|
|
category={project.category}
|
|
tags={project.tags}
|
|
slug={project.slug}
|
|
size="card"
|
|
/>
|
|
)}
|
|
{/* Overlay on Hover */}
|
|
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors duration-500" />
|
|
</div>
|
|
|
|
{/* Text Content */}
|
|
<div className="flex justify-between items-start">
|
|
<div>
|
|
<h3 className="text-lg sm:text-xl md:text-2xl font-bold text-stone-900 dark:text-stone-100 mb-1 sm:mb-2 group-hover:underline decoration-2 underline-offset-4">
|
|
{project.title}
|
|
</h3>
|
|
<p className="text-sm sm:text-base text-stone-500 dark:text-stone-400 line-clamp-2 max-w-md">
|
|
{project.description}
|
|
</p>
|
|
</div>
|
|
<div className="hidden sm:flex gap-2">
|
|
{project.tags.slice(0, 2).map(tag => (
|
|
<span key={tag} className="px-3 py-1 rounded-full border border-stone-200 dark:border-stone-800 text-xs font-medium text-stone-600 dark:text-stone-400">
|
|
{tag}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
</motion.div>
|
|
)))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
};
|
|
|
|
export default Projects;
|