- Remove duplicate app/projects/ route (was causing 5xx and soft 404) - Fix nginx: redirect www.dk0.dev → dk0.dev (non-www canonical) - Fix not-found.tsx: locale-prefixed links, remove framer-motion dependency - Add fetchPriority='high' and will-change to Hero LCP image - Add preconnect hints for hardcover.app and cms.dk0.dev - Reduce background blur from 100px to 80px (LCP rendering delay) - Remove boneyard-js (~20 KiB), replace with custom Skeleton component - Remove react-icons (~10 KiB), replace with inline SVGs - Conditionally render mobile menu (saves ~20 DOM nodes) - Add /books to sitemap - Optimize image config with explicit deviceSizes/imageSizes
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) {
|
|
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;
|