From c442aa447bfd7fee99e6c04579a79c2b65408540 Mon Sep 17 00:00:00 2001 From: denshooter Date: Thu, 16 Apr 2026 13:46:10 +0200 Subject: [PATCH] feat: add ProjectThumbnail component with category-themed visuals for projects without images --- app/_ui/ProjectDetailClient.tsx | 11 +- app/_ui/ProjectsPageClient.tsx | 13 +- app/components/ProjectThumbnail.tsx | 241 ++++++++++++++++++++++++++++ app/components/Projects.tsx | 11 +- 4 files changed, 269 insertions(+), 7 deletions(-) create mode 100644 app/components/ProjectThumbnail.tsx diff --git a/app/_ui/ProjectDetailClient.tsx b/app/_ui/ProjectDetailClient.tsx index d642379..ba35c97 100644 --- a/app/_ui/ProjectDetailClient.tsx +++ b/app/_ui/ProjectDetailClient.tsx @@ -6,6 +6,7 @@ import ReactMarkdown from "react-markdown"; import { useTranslations } from "next-intl"; import Image from "next/image"; import { useRouter } from "next/navigation"; +import ProjectThumbnail from "@/app/components/ProjectThumbnail"; export type ProjectDetailData = { id: number; @@ -90,9 +91,13 @@ export default function ProjectDetailClient({ {project.imageUrl ? ( {project.title} ) : ( -
- {project.title.charAt(0)} -
+ )} diff --git a/app/_ui/ProjectsPageClient.tsx b/app/_ui/ProjectsPageClient.tsx index 227bdc0..319b308 100644 --- a/app/_ui/ProjectsPageClient.tsx +++ b/app/_ui/ProjectsPageClient.tsx @@ -7,6 +7,7 @@ import Link from "next/link"; import { useTranslations } from "next-intl"; import Image from "next/image"; import { Skeleton } from "../components/ui/Skeleton"; +import ProjectThumbnail from "@/app/components/ProjectThumbnail"; export type ProjectListItem = { id: number | string; // Allow both for Directus (string) and Prisma (number) compatibility @@ -127,10 +128,20 @@ export default function ProjectsPageClient({
- {project.imageUrl && ( + {project.imageUrl ? (
{project.title}
+ ) : ( +
+ +
)}
diff --git a/app/components/ProjectThumbnail.tsx b/app/components/ProjectThumbnail.tsx new file mode 100644 index 0000000..d81f92c --- /dev/null +++ b/app/components/ProjectThumbnail.tsx @@ -0,0 +1,241 @@ +"use client"; + +import { useMemo } from "react"; +import { + Terminal, + Smartphone, + Globe, + Code, + LayoutDashboard, + MessageSquare, + Cloud, + Wrench, + Cpu, + Shield, + Boxes, + type LucideIcon, +} from "lucide-react"; + +interface ProjectThumbnailProps { + title: string; + category?: string; + tags?: string[]; + slug?: string; + size?: "card" | "hero"; +} + +const categoryThemes: Record< + string, + { + icon: LucideIcon; + gradient: string; + darkGradient: string; + iconColor: string; + darkIconColor: string; + pattern: "dots" | "grid" | "diagonal" | "circuit" | "waves" | "terminal"; + } +> = { + "Web Development": { + icon: Code, + gradient: "from-liquid-sky/20 via-liquid-blue/10 to-liquid-lavender/20", + darkGradient: "dark:from-liquid-sky/10 dark:via-liquid-blue/5 dark:to-liquid-lavender/10", + iconColor: "text-blue-500", + darkIconColor: "dark:text-blue-400", + pattern: "circuit", + }, + "Mobile Development": { + icon: Smartphone, + gradient: "from-liquid-mint/20 via-liquid-teal/10 to-liquid-sky/20", + darkGradient: "dark:from-liquid-mint/10 dark:via-liquid-teal/5 dark:to-liquid-sky/10", + iconColor: "text-emerald-500", + darkIconColor: "dark:text-emerald-400", + pattern: "waves", + }, + "Web Application": { + icon: Globe, + gradient: "from-liquid-lavender/20 via-liquid-purple/10 to-liquid-pink/20", + darkGradient: "dark:from-liquid-lavender/10 dark:via-liquid-purple/5 dark:to-liquid-pink/10", + iconColor: "text-violet-500", + darkIconColor: "dark:text-violet-400", + pattern: "dots", + }, + "Backend Development": { + icon: Cpu, + gradient: "from-liquid-amber/20 via-liquid-yellow/10 to-liquid-peach/20", + darkGradient: "dark:from-liquid-amber/10 dark:via-liquid-yellow/5 dark:to-liquid-peach/10", + iconColor: "text-amber-500", + darkIconColor: "dark:text-amber-400", + pattern: "grid", + }, + "Full-Stack Development": { + icon: Boxes, + gradient: "from-liquid-teal/20 via-liquid-mint/10 to-liquid-lavender/20", + darkGradient: "dark:from-liquid-teal/10 dark:via-liquid-mint/5 dark:to-liquid-lavender/10", + iconColor: "text-teal-500", + darkIconColor: "dark:text-teal-400", + pattern: "grid", + }, + DevOps: { + icon: Shield, + gradient: "from-liquid-coral/20 via-liquid-rose/10 to-liquid-peach/20", + darkGradient: "dark:from-liquid-coral/10 dark:via-liquid-rose/5 dark:to-liquid-peach/10", + iconColor: "text-red-500", + darkIconColor: "dark:text-red-400", + pattern: "diagonal", + }, + default: { + icon: Wrench, + gradient: "from-liquid-peach/20 via-liquid-rose/10 to-liquid-lavender/20", + darkGradient: "dark:from-liquid-peach/10 dark:via-liquid-rose/5 dark:to-liquid-lavender/10", + iconColor: "text-stone-400", + darkIconColor: "dark:text-stone-500", + pattern: "dots", + }, +}; + +const slugIcons: Record = { + "kernel-panic-404-interactive-terminal": Terminal, + "portfolio-website": LayoutDashboard, + "real-time-chat-application": MessageSquare, + "weather-forecast-app": Cloud, + "clarity": Smartphone, + "e-commerce-platform-api": Boxes, + "task-management-dashboard": LayoutDashboard, +}; + +function PatternOverlay({ pattern }: { pattern: string }) { + const patternMap: Record = { + dots: ( + + + + + + + + + ), + grid: ( + + + + + + + + + ), + diagonal: ( + + + + + + + + + ), + circuit: ( + + + + + + + + + + + + + + ), + waves: ( + + + + + + + + + ), + terminal: ( + + + + $_ + + 404 + + ERR + + + + + ), + }; + + return patternMap[pattern] || patternMap.dots; +} + +export default function ProjectThumbnail({ + title, + category, + tags, + slug, + size = "card", +}: ProjectThumbnailProps) { + const theme = useMemo(() => { + if (slug && slugIcons[slug]) { + const matchedTheme = categoryThemes[category || ""] || categoryThemes.default; + return { ...matchedTheme, icon: slugIcons[slug] }; + } + return categoryThemes[category || ""] || categoryThemes.default; + }, [category, slug]); + + const Icon = theme.icon; + + const isHero = size === "hero"; + + const displayTags = tags?.slice(0, 3) ?? []; + + const sizeClasses = isHero + ? "" + : ""; + + return ( +
+ + +
+
+ +
+ + + {title} + + + {displayTags.length > 0 && ( +
+ {displayTags.map((tag) => ( + + {tag} + + ))} +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/app/components/Projects.tsx b/app/components/Projects.tsx index d6de5bc..daf0179 100644 --- a/app/components/Projects.tsx +++ b/app/components/Projects.tsx @@ -7,6 +7,7 @@ import Link from "next/link"; import Image from "next/image"; import { useLocale, useTranslations } from "next-intl"; import { Skeleton } from "boneyard-js/react"; +import ProjectThumbnail from "./ProjectThumbnail"; interface Project { id: number; @@ -86,9 +87,13 @@ const Projects = () => { className="object-cover transition-transform duration-700 group-hover:scale-105" /> ) : ( -
- {project.title.charAt(0)} -
+ )} {/* Overlay on Hover */}