Merge branch 'dev' into production
This commit is contained in:
@@ -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 ? (
|
||||
<Image src={project.imageUrl} alt={project.title} fill className="object-cover" priority sizes="100vw" />
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-stone-100 dark:bg-stone-800 flex items-center justify-center">
|
||||
<span className="text-[15rem] font-black text-stone-200 dark:text-stone-700">{project.title.charAt(0)}</span>
|
||||
</div>
|
||||
<ProjectThumbnail
|
||||
title={project.title}
|
||||
category={project.category}
|
||||
tags={project.tags}
|
||||
slug={project.slug}
|
||||
size="hero"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
@@ -74,7 +75,7 @@ export default function ProjectsPageClient({
|
||||
</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>
|
||||
{tList("title")}<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")}
|
||||
@@ -127,10 +128,20 @@ export default function ProjectsPageClient({
|
||||
<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 && (
|
||||
{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="relative aspect-[16/10] rounded-[2rem] overflow-hidden mb-8 border-4 border-stone-50 dark:border-stone-800 shadow-lg">
|
||||
<ProjectThumbnail
|
||||
title={project.title}
|
||||
category={project.category}
|
||||
tags={project.tags}
|
||||
slug={project.slug}
|
||||
size="card"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
|
||||
236
app/components/ProjectThumbnail.tsx
Normal file
236
app/components/ProjectThumbnail.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useId } 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<string, LucideIcon> = {
|
||||
"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, id }: { pattern: string; id: string }) {
|
||||
const patterns: Record<string, React.ReactNode> = {
|
||||
dots: (
|
||||
<svg className="absolute inset-0 w-full h-full opacity-[0.07] dark:opacity-[0.05]" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<pattern id={`pat-dots-${id}`} x="0" y="0" width="20" height="20" patternUnits="userSpaceOnUse">
|
||||
<circle cx="2" cy="2" r="1.5" fill="currentColor" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill={`url(#pat-dots-${id})`} />
|
||||
</svg>
|
||||
),
|
||||
grid: (
|
||||
<svg className="absolute inset-0 w-full h-full opacity-[0.06] dark:opacity-[0.04]" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<pattern id={`pat-grid-${id}`} x="0" y="0" width="40" height="40" patternUnits="userSpaceOnUse">
|
||||
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="currentColor" strokeWidth="1" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill={`url(#pat-grid-${id})`} />
|
||||
</svg>
|
||||
),
|
||||
diagonal: (
|
||||
<svg className="absolute inset-0 w-full h-full opacity-[0.06] dark:opacity-[0.04]" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<pattern id={`pat-diag-${id}`} x="0" y="0" width="24" height="24" patternUnits="userSpaceOnUse">
|
||||
<line x1="0" y1="24" x2="24" y2="0" stroke="currentColor" strokeWidth="1" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill={`url(#pat-diag-${id})`} />
|
||||
</svg>
|
||||
),
|
||||
circuit: (
|
||||
<svg className="absolute inset-0 w-full h-full opacity-[0.07] dark:opacity-[0.05]" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<pattern id={`pat-circ-${id}`} x="0" y="0" width="60" height="60" patternUnits="userSpaceOnUse">
|
||||
<path d="M0 30h20m20 0h20M30 0v20m0 20v20" stroke="currentColor" strokeWidth="0.8" fill="none" />
|
||||
<circle cx="30" cy="30" r="3" fill="currentColor" />
|
||||
<circle cx="10" cy="30" r="2" fill="currentColor" />
|
||||
<circle cx="50" cy="30" r="2" fill="currentColor" />
|
||||
<circle cx="30" cy="10" r="2" fill="currentColor" />
|
||||
<circle cx="30" cy="50" r="2" fill="currentColor" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill={`url(#pat-circ-${id})`} />
|
||||
</svg>
|
||||
),
|
||||
waves: (
|
||||
<svg className="absolute inset-0 w-full h-full opacity-[0.06] dark:opacity-[0.04]" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<pattern id={`pat-wave-${id}`} x="0" y="0" width="100" height="20" patternUnits="userSpaceOnUse">
|
||||
<path d="M0 10 Q25 0 50 10 T100 10" fill="none" stroke="currentColor" strokeWidth="1" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill={`url(#pat-wave-${id})`} />
|
||||
</svg>
|
||||
),
|
||||
terminal: (
|
||||
<svg className="absolute inset-0 w-full h-full opacity-[0.08] dark:opacity-[0.06]" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<pattern id={`pat-term-${id}`} x="0" y="0" width="200" height="30" patternUnits="userSpaceOnUse">
|
||||
<text x="4" y="18" fontFamily="monospace" fontSize="10" fill="currentColor">$_</text>
|
||||
<text x="50" y="18" fontFamily="monospace" fontSize="10" fill="currentColor" opacity="0.4">│</text>
|
||||
<text x="70" y="18" fontFamily="monospace" fontSize="10" fill="currentColor" opacity="0.6">404</text>
|
||||
<text x="110" y="18" fontFamily="monospace" fontSize="10" fill="currentColor" opacity="0.4">│</text>
|
||||
<text x="130" y="18" fontFamily="monospace" fontSize="10" fill="currentColor" opacity="0.3">ERR</text>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill={`url(#pat-term-${id})`} />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
return patterns[pattern] || patterns.dots;
|
||||
}
|
||||
|
||||
export default function ProjectThumbnail({
|
||||
title,
|
||||
category,
|
||||
tags,
|
||||
slug,
|
||||
size = "card",
|
||||
}: ProjectThumbnailProps) {
|
||||
const uniqueId = useId();
|
||||
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) ?? [];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`absolute inset-0 flex items-center justify-center bg-gradient-to-br ${theme.gradient} ${theme.darkGradient}`}
|
||||
>
|
||||
<PatternOverlay pattern={theme.pattern} id={uniqueId} />
|
||||
|
||||
<div className="relative z-10 flex flex-col items-center gap-3 sm:gap-4">
|
||||
<div
|
||||
className={`flex items-center justify-center rounded-2xl bg-white/60 dark:bg-white/10 backdrop-blur-sm border border-white/40 dark:border-white/10 ${theme.iconColor} ${theme.darkIconColor} ${isHero ? "w-20 h-20 sm:w-28 sm:h-28" : "w-14 h-14 sm:w-20 sm:h-20"}`}
|
||||
>
|
||||
<Icon className={isHero ? "w-10 h-10 sm:w-14 sm:h-14" : "w-7 h-7 sm:w-10 sm:h-10"} strokeWidth={1.5} />
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={`font-black tracking-tighter uppercase ${isHero ? "text-2xl sm:text-4xl md:text-5xl" : "text-sm sm:text-lg"} text-stone-400/80 dark:text-stone-500/80`}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
|
||||
{displayTags.length > 0 && (
|
||||
<div className={`flex flex-wrap justify-center gap-1.5 sm:gap-2 ${isHero ? "max-w-md" : "max-w-[200px]"}`}>
|
||||
{displayTags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className={`px-2 py-0.5 rounded-full bg-white/50 dark:bg-white/5 backdrop-blur-sm border border-white/30 dark:border-white/10 text-stone-500 dark:text-stone-400 font-medium ${isHero ? "text-xs sm:text-sm" : "text-[9px] sm:text-[10px]"}`}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -27,7 +28,7 @@ const Projects = () => {
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const locale = useLocale();
|
||||
useTranslations("home.projects");
|
||||
const t = useTranslations("home.projects");
|
||||
|
||||
useEffect(() => {
|
||||
const loadProjects = async () => {
|
||||
@@ -52,22 +53,22 @@ const Projects = () => {
|
||||
<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">
|
||||
Selected Work<span className="text-emerald-600 dark:text-emerald-400">.</span>
|
||||
{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">
|
||||
Projects that pushed my boundaries.
|
||||
{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">
|
||||
View Archive <ArrowUpRight className="group-hover:-translate-y-1 group-hover:translate-x-1 transition-transform" size={14} />
|
||||
</Link>
|
||||
<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>
|
||||
|
||||
<Skeleton name="projects-grid" loading={loading} animate="shimmer" transition>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-12">
|
||||
{projects.length === 0 && !loading ? (
|
||||
<div className="col-span-2 py-12 text-center text-stone-400 dark:text-stone-600 text-sm">
|
||||
No projects yet.
|
||||
{t("noProjects")}
|
||||
</div>
|
||||
) : (
|
||||
projects.map((project) => (
|
||||
@@ -86,9 +87,13 @@ const Projects = () => {
|
||||
className="object-cover transition-transform duration-700 group-hover:scale-105"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-stone-100 to-stone-200 dark:from-stone-800 dark:to-stone-900">
|
||||
<span className="text-4xl font-bold text-stone-300 dark:text-stone-700">{project.title.charAt(0)}</span>
|
||||
</div>
|
||||
<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" />
|
||||
|
||||
@@ -86,10 +86,11 @@
|
||||
}
|
||||
},
|
||||
"projects": {
|
||||
"title": "Ausgewählte Projekte",
|
||||
"subtitle": "Eine Auswahl an Projekten, an denen ich gearbeitet habe – von Web-Apps bis zu Experimenten.",
|
||||
"title": "Ausgewählte Arbeiten",
|
||||
"subtitle": "Projekte, die meine Grenzen erweitert haben.",
|
||||
"featured": "Featured",
|
||||
"viewAll": "Alle Projekte ansehen"
|
||||
"viewAll": "Archiv ansehen",
|
||||
"noProjects": "Noch keine Projekte."
|
||||
},
|
||||
"contact": {
|
||||
"title": "Kontakt",
|
||||
|
||||
@@ -87,10 +87,11 @@
|
||||
}
|
||||
},
|
||||
"projects": {
|
||||
"title": "Selected Works",
|
||||
"subtitle": "A collection of projects I've worked on, ranging from web applications to experiments.",
|
||||
"title": "Selected Work",
|
||||
"subtitle": "Projects that pushed my boundaries.",
|
||||
"featured": "Featured",
|
||||
"viewAll": "View All Projects"
|
||||
"viewAll": "View Archive",
|
||||
"noProjects": "No projects yet."
|
||||
},
|
||||
"contact": {
|
||||
"title": "Contact Me",
|
||||
|
||||
Reference in New Issue
Block a user