Fixed map parentheses syntax errors, resolved missing ActivityFeedClient imports, and corrected ActivityFeed prop types for idleQuote support. All systems green.
157 lines
7.0 KiB
TypeScript
157 lines
7.0 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { motion } from "framer-motion";
|
|
import { ArrowUpRight, ArrowLeft, Search } from "lucide-react";
|
|
import Link from "next/link";
|
|
import { useLocale, useTranslations } from "next-intl";
|
|
import Image from "next/image";
|
|
import { Skeleton } from "../components/ui/Skeleton";
|
|
|
|
export type ProjectListItem = {
|
|
id: number;
|
|
slug: string;
|
|
title: string;
|
|
description: string;
|
|
tags: string[];
|
|
category: string;
|
|
date: string;
|
|
imageUrl?: string | null;
|
|
};
|
|
|
|
export default function ProjectsPageClient({
|
|
projects,
|
|
locale,
|
|
}: {
|
|
projects: ProjectListItem[];
|
|
locale: string;
|
|
}) {
|
|
const tCommon = useTranslations("common");
|
|
const tList = useTranslations("projects.list");
|
|
|
|
const [selectedCategory, setSelectedCategory] = useState("all");
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
// Simulate initial load for smoother entrance or handle actual fetch if needed
|
|
const timer = setTimeout(() => setLoading(false), 800);
|
|
return () => clearTimeout(timer);
|
|
}, []);
|
|
|
|
const categories = useMemo(() => {
|
|
const unique = Array.from(new Set(projects.map((p) => p.category))).filter(Boolean);
|
|
return ["all", ...unique];
|
|
}, [projects]);
|
|
|
|
const filteredProjects = useMemo(() => {
|
|
let result = projects;
|
|
if (selectedCategory !== "all") {
|
|
result = result.filter((project) => project.category === selectedCategory);
|
|
}
|
|
if (searchQuery) {
|
|
const query = searchQuery.toLowerCase();
|
|
result = result.filter(
|
|
(p) => p.title.toLowerCase().includes(query) || p.description.toLowerCase().includes(query) || p.tags.some(t => t.toLowerCase().includes(query))
|
|
);
|
|
}
|
|
return result;
|
|
}, [projects, selectedCategory, searchQuery]);
|
|
|
|
return (
|
|
<div className="min-h-screen bg-[#fdfcf8] dark:bg-stone-950 pt-40 pb-20 px-6 transition-colors duration-500">
|
|
<div className="max-w-7xl mx-auto">
|
|
|
|
{/* Header */}
|
|
<div className="mb-24">
|
|
<Link
|
|
href={`/${locale}`}
|
|
className="inline-flex items-center gap-2 text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors mb-10 group"
|
|
>
|
|
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
|
|
<span className="font-bold uppercase tracking-widest text-xs">{tCommon("backToHome")}</span>
|
|
</Link>
|
|
|
|
<h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase">
|
|
Archive<span className="text-liquid-mint">.</span>
|
|
</h1>
|
|
<p className="mt-8 text-xl md:text-3xl font-light text-stone-500 dark:text-stone-400 max-w-2xl leading-snug tracking-tight">
|
|
{tList("intro")}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="flex flex-col md:flex-row gap-8 justify-between items-start md:items-center mb-16">
|
|
<div className="flex flex-wrap gap-2">
|
|
{categories.map((cat) => (
|
|
<button
|
|
key={cat}
|
|
onClick={() => setSelectedCategory(cat)}
|
|
className={`px-6 py-2 rounded-full text-[10px] font-black uppercase tracking-widest transition-all ${
|
|
selectedCategory === cat
|
|
? "bg-stone-900 dark:bg-stone-100 text-white dark:text-stone-900"
|
|
: "bg-white dark:bg-stone-900 text-stone-500 border border-stone-200 dark:border-stone-800"
|
|
}`}
|
|
>
|
|
{cat === 'all' ? tList('all') : cat}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="relative w-full md:w-80">
|
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-stone-400" size={18} />
|
|
<input
|
|
type="text"
|
|
placeholder={tList("searchPlaceholder")}
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="w-full bg-white dark:bg-stone-900 border border-stone-200 dark:border-stone-800 rounded-2xl py-4 pl-12 pr-6 focus:outline-none focus:ring-2 focus:ring-liquid-mint/30 transition-all shadow-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Grid */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
|
{loading ? (
|
|
Array.from({ length: 4 }).map((_, i) => (
|
|
<div key={i} className="bg-white dark:bg-stone-900 rounded-[3rem] p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col h-full">
|
|
<Skeleton className="aspect-[16/10] rounded-[2rem] mb-8" />
|
|
<div className="space-y-3">
|
|
<Skeleton className="h-8 w-1/2" />
|
|
<Skeleton className="h-4 w-3/4" />
|
|
</div>
|
|
</div>
|
|
))
|
|
) : (
|
|
filteredProjects.map((project) => (
|
|
<motion.div key={project.id} initial={{ opacity: 0, y: 20 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }}>
|
|
<Link href={`/${locale}/projects/${project.slug}`} className="group block h-full">
|
|
<div className="bg-white dark:bg-stone-900 rounded-[3rem] p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm h-full hover:shadow-xl transition-all flex flex-col">
|
|
{project.imageUrl && (
|
|
<div className="relative aspect-[16/10] rounded-[2rem] overflow-hidden mb-8 border-4 border-stone-50 dark:border-stone-800 shadow-lg">
|
|
<Image src={project.imageUrl} alt={project.title} fill className="object-cover group-hover:scale-105 transition-transform duration-700" />
|
|
</div>
|
|
)}
|
|
<div className="flex-1 flex flex-col">
|
|
<div className="flex justify-between items-start mb-4">
|
|
<h3 className="text-3xl font-black text-stone-900 dark:text-white uppercase tracking-tight">{project.title}</h3>
|
|
<div className="w-12 h-12 rounded-full bg-stone-50 dark:bg-stone-800 flex items-center justify-center group-hover:bg-stone-900 dark:group-hover:bg-stone-50 group-hover:text-white dark:group-hover:text-stone-900 transition-all">
|
|
<ArrowUpRight size={20} />
|
|
</div>
|
|
</div>
|
|
<p className="text-stone-500 dark:text-stone-400 font-light text-lg mb-8 line-clamp-3 leading-relaxed">{project.description}</p>
|
|
<div className="mt-auto flex flex-wrap gap-2">
|
|
{project.tags.slice(0, 3).map(tag => (
|
|
<span key={tag} className="px-3 py-1 bg-stone-50 dark:bg-stone-800 rounded-lg text-[9px] font-black uppercase tracking-widest text-stone-400">{tag}</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
</motion.div>
|
|
)))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|