Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Failing after 9m26s
158 lines
7.1 KiB
TypeScript
158 lines
7.1 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 { useTranslations } from "next-intl";
|
|
import Image from "next/image";
|
|
import { Skeleton } from "../components/ui/Skeleton";
|
|
|
|
export type ProjectListItem = {
|
|
id: number | string; // Allow both for Directus (string) and Prisma (number) compatibility
|
|
slug: string;
|
|
title: string;
|
|
description: string;
|
|
tags: string[];
|
|
category: string;
|
|
date?: string;
|
|
createdAt?: 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>
|
|
);
|
|
}
|