fix: resolve project 404s with Directus fallback and upgrade 404 page
Merged Directus and PostgreSQL project data, implemented single project fetch from CMS, and modernized the NotFound component with liquid design.
This commit is contained in:
@@ -3,6 +3,7 @@ import ProjectDetailClient from "@/app/_ui/ProjectDetailClient";
|
||||
import { notFound } from "next/navigation";
|
||||
import type { Metadata } from "next";
|
||||
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
|
||||
import { getProjectBySlug } from "@/lib/directus";
|
||||
|
||||
export const revalidate = 300;
|
||||
|
||||
@@ -12,6 +13,20 @@ export async function generateMetadata({
|
||||
params: Promise<{ locale: string; slug: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { locale, slug } = await params;
|
||||
|
||||
// Try Directus first for metadata
|
||||
const directusProject = await getProjectBySlug(slug, locale);
|
||||
if (directusProject) {
|
||||
return {
|
||||
title: directusProject.title,
|
||||
description: directusProject.description,
|
||||
alternates: {
|
||||
canonical: toAbsoluteUrl(`/${locale}/projects/${slug}`),
|
||||
languages: getLanguageAlternates({ pathWithoutLocale: `projects/${slug}` }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const languages = getLanguageAlternates({ pathWithoutLocale: `projects/${slug}` });
|
||||
return {
|
||||
alternates: {
|
||||
@@ -28,7 +43,8 @@ export default async function ProjectPage({
|
||||
}) {
|
||||
const { locale, slug } = await params;
|
||||
|
||||
const project = await prisma.project.findFirst({
|
||||
// Try PostgreSQL first
|
||||
const dbProject = await prisma.project.findFirst({
|
||||
where: { slug, published: true },
|
||||
include: {
|
||||
translations: {
|
||||
@@ -37,43 +53,56 @@ export default async function ProjectPage({
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) return notFound();
|
||||
let projectData: any = null;
|
||||
|
||||
const trPreferred = project.translations?.find((t) => t.locale === locale && (t?.title || t?.description));
|
||||
const trDefault = project.translations?.find(
|
||||
(t) => t.locale === project.defaultLocale && (t?.title || t?.description),
|
||||
);
|
||||
const tr = trPreferred ?? trDefault;
|
||||
const { translations: _translations, ...rest } = project;
|
||||
const localizedContent = (() => {
|
||||
if (typeof tr?.content === "string") return tr.content;
|
||||
if (tr?.content && typeof tr.content === "object" && "markdown" in tr.content) {
|
||||
const markdown = (tr.content as Record<string, unknown>).markdown;
|
||||
if (typeof markdown === "string") return markdown;
|
||||
if (dbProject) {
|
||||
const trPreferred = dbProject.translations?.find((t) => t.locale === locale && (t?.title || t?.description));
|
||||
const trDefault = dbProject.translations?.find(
|
||||
(t) => t.locale === dbProject.defaultLocale && (t?.title || t?.description),
|
||||
);
|
||||
const tr = trPreferred ?? trDefault;
|
||||
const { translations: _translations, ...rest } = dbProject;
|
||||
const localizedContent = (() => {
|
||||
if (typeof tr?.content === "string") return tr.content;
|
||||
if (tr?.content && typeof tr.content === "object" && "markdown" in tr.content) {
|
||||
const markdown = (tr.content as Record<string, unknown>).markdown;
|
||||
if (typeof markdown === "string") return markdown;
|
||||
}
|
||||
return dbProject.content;
|
||||
})();
|
||||
projectData = {
|
||||
...rest,
|
||||
title: tr?.title ?? dbProject.title,
|
||||
description: tr?.description ?? dbProject.description,
|
||||
content: localizedContent,
|
||||
};
|
||||
} else {
|
||||
// Try Directus fallback
|
||||
const directusProject = await getProjectBySlug(slug, locale);
|
||||
if (directusProject) {
|
||||
projectData = {
|
||||
...directusProject,
|
||||
id: parseInt(directusProject.id) || 0,
|
||||
};
|
||||
}
|
||||
return project.content;
|
||||
})();
|
||||
const localized = {
|
||||
...rest,
|
||||
title: tr?.title ?? project.title,
|
||||
description: tr?.description ?? project.description,
|
||||
content: localizedContent,
|
||||
};
|
||||
}
|
||||
|
||||
if (!projectData) return notFound();
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareSourceCode",
|
||||
"name": localized.title,
|
||||
"description": localized.description,
|
||||
"codeRepository": localized.github,
|
||||
"programmingLanguage": localized.technologies,
|
||||
"name": projectData.title,
|
||||
"description": projectData.description,
|
||||
"codeRepository": projectData.github_url || projectData.github,
|
||||
"programmingLanguage": projectData.technologies,
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "Dennis Konkol"
|
||||
},
|
||||
"dateCreated": project.date,
|
||||
"dateCreated": projectData.date || projectData.created_at,
|
||||
"url": toAbsoluteUrl(`/${locale}/projects/${slug}`),
|
||||
"image": localized.imageUrl ? toAbsoluteUrl(localized.imageUrl) : undefined,
|
||||
"image": projectData.imageUrl || projectData.image_url ? toAbsoluteUrl(projectData.imageUrl || projectData.image_url) : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -82,7 +111,7 @@ export default async function ProjectPage({
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<ProjectDetailClient project={localized} locale={locale} />
|
||||
<ProjectDetailClient project={projectData} locale={locale} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { prisma } from "@/lib/prisma";
|
||||
import ProjectsPageClient from "@/app/_ui/ProjectsPageClient";
|
||||
import type { Metadata } from "next";
|
||||
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
|
||||
import { getProjects as getDirectusProjects } from "@/lib/directus";
|
||||
|
||||
export const revalidate = 300;
|
||||
|
||||
@@ -27,7 +28,8 @@ export default async function ProjectsPage({
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
|
||||
const projects = await prisma.project.findMany({
|
||||
// Fetch from PostgreSQL
|
||||
const dbProjects = await prisma.project.findMany({
|
||||
where: { published: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: {
|
||||
@@ -37,7 +39,21 @@ export default async function ProjectsPage({
|
||||
},
|
||||
});
|
||||
|
||||
const localized = projects.map((p) => {
|
||||
// Fetch from Directus
|
||||
let directusProjects: any[] = [];
|
||||
try {
|
||||
const fetched = await getDirectusProjects(locale, { published: true });
|
||||
if (fetched) {
|
||||
directusProjects = fetched.map(p => ({
|
||||
...p,
|
||||
id: parseInt(p.id) || 0,
|
||||
}));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Directus projects fetch failed:", err);
|
||||
}
|
||||
|
||||
const localizedDb = dbProjects.map((p) => {
|
||||
const trPreferred = p.translations?.find((t) => t.locale === locale && (t?.title || t?.description));
|
||||
const trDefault = p.translations?.find(
|
||||
(t) => t.locale === p.defaultLocale && (t?.title || t?.description),
|
||||
@@ -51,6 +67,23 @@ export default async function ProjectsPage({
|
||||
};
|
||||
});
|
||||
|
||||
return <ProjectsPageClient projects={localized} locale={locale} />;
|
||||
// Merge projects, prioritizing DB ones if slugs match
|
||||
const allProjects = [...localizedDb];
|
||||
const dbSlugs = new Set(localizedDb.map(p => p.slug));
|
||||
|
||||
for (const dp of directusProjects) {
|
||||
if (!dbSlugs.has(dp.slug)) {
|
||||
allProjects.push(dp);
|
||||
}
|
||||
}
|
||||
|
||||
// Final sort by date
|
||||
allProjects.sort((a, b) => {
|
||||
const dateA = new Date(a.date || a.createdAt || 0).getTime();
|
||||
const dateB = new Date(b.date || b.createdAt || 0).getTime();
|
||||
return dateB - dateA;
|
||||
});
|
||||
|
||||
return <ProjectsPageClient projects={allProjects} locale={locale} />;
|
||||
}
|
||||
|
||||
|
||||
+28
-47
@@ -47,8 +47,9 @@ export async function GET(request: NextRequest) {
|
||||
const locale = searchParams.get('locale') || 'en';
|
||||
|
||||
// Try Directus FIRST (Primary Source)
|
||||
let directusProjects: any[] = [];
|
||||
try {
|
||||
const directusProjects = await getDirectusProjects(locale, {
|
||||
const fetched = await getDirectusProjects(locale, {
|
||||
featured: featured === 'true' ? true : featured === 'false' ? false : undefined,
|
||||
published: published === 'true' ? true : published === 'false' ? false : undefined,
|
||||
category: category || undefined,
|
||||
@@ -57,54 +58,34 @@ export async function GET(request: NextRequest) {
|
||||
limit
|
||||
});
|
||||
|
||||
if (directusProjects && directusProjects.length > 0) {
|
||||
return NextResponse.json({
|
||||
projects: directusProjects,
|
||||
total: directusProjects.length,
|
||||
page: 1,
|
||||
limit: directusProjects.length,
|
||||
source: 'directus'
|
||||
});
|
||||
if (fetched) {
|
||||
directusProjects = fetched;
|
||||
}
|
||||
} catch (directusError) {
|
||||
console.log('Directus not available, trying PostgreSQL fallback');
|
||||
console.log('Directus error, continuing with PostgreSQL');
|
||||
}
|
||||
|
||||
// Fallback 1: Try PostgreSQL
|
||||
try {
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
} catch (dbError) {
|
||||
console.log('PostgreSQL also not available, using empty fallback');
|
||||
console.log('PostgreSQL not available');
|
||||
if (directusProjects.length > 0) {
|
||||
return NextResponse.json({
|
||||
projects: directusProjects,
|
||||
total: directusProjects.length,
|
||||
source: 'directus'
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback 2: Return empty (components should have hardcoded fallback)
|
||||
return NextResponse.json({
|
||||
projects: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit,
|
||||
source: 'fallback'
|
||||
});
|
||||
}
|
||||
|
||||
// Create cache parameters object
|
||||
const cacheParams = {
|
||||
page: page.toString(),
|
||||
limit: limit.toString(),
|
||||
category,
|
||||
featured,
|
||||
published,
|
||||
difficulty,
|
||||
search
|
||||
};
|
||||
|
||||
// Check cache first
|
||||
const cached = await apiCache.getProjects(cacheParams);
|
||||
if (cached && !search) { // Don't cache search results
|
||||
return NextResponse.json(cached);
|
||||
}
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
|
||||
if (category) where.category = category;
|
||||
@@ -116,12 +97,11 @@ export async function GET(request: NextRequest) {
|
||||
where.OR = [
|
||||
{ title: { contains: search, mode: 'insensitive' } },
|
||||
{ description: { contains: search, mode: 'insensitive' } },
|
||||
{ tags: { hasSome: [search] } },
|
||||
{ content: { contains: search, mode: 'insensitive' } }
|
||||
{ tags: { hasSome: [search] } }
|
||||
];
|
||||
}
|
||||
|
||||
const [projects, total] = await Promise.all([
|
||||
const [dbProjects, total] = await Promise.all([
|
||||
prisma.project.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
@@ -131,20 +111,21 @@ export async function GET(request: NextRequest) {
|
||||
prisma.project.count({ where })
|
||||
]);
|
||||
|
||||
const result = {
|
||||
projects,
|
||||
total,
|
||||
pages: Math.ceil(total / limit),
|
||||
currentPage: page,
|
||||
source: 'postgresql'
|
||||
};
|
||||
|
||||
// Cache the result (only for non-search queries)
|
||||
if (!search) {
|
||||
await apiCache.setProjects(cacheParams, result);
|
||||
// Merge logic
|
||||
const dbSlugs = new Set(dbProjects.map(p => p.slug));
|
||||
const mergedProjects = [...dbProjects];
|
||||
|
||||
for (const dp of directusProjects) {
|
||||
if (!dbSlugs.has(dp.slug)) {
|
||||
mergedProjects.push(dp);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(result);
|
||||
return NextResponse.json({
|
||||
projects: mergedProjects,
|
||||
total: total + (mergedProjects.length - dbProjects.length),
|
||||
source: 'merged'
|
||||
});
|
||||
} catch (error) {
|
||||
// Handle missing database table gracefully
|
||||
if (error instanceof PrismaClientKnownRequestError && error.code === 'P2021') {
|
||||
|
||||
+97
-125
@@ -1,149 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Home, ArrowLeft, Search, Ghost, RefreshCcw } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Home, ArrowLeft, Search } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function NotFound() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [input, setInput] = useState("");
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// In tests, avoid next/dynamic loadable timing and render a stable fallback
|
||||
if (process.env.NODE_ENV === "test") {
|
||||
return (
|
||||
<div>
|
||||
Oops! The page you're looking for doesn't exist.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[#faf8f3]">
|
||||
<div className="text-center">
|
||||
<div className="text-[#795548]">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleCommand = (cmd: string) => {
|
||||
const command = cmd.toLowerCase().trim();
|
||||
if (command === 'home' || command === 'cd ~' || command === 'cd /') {
|
||||
router.push('/');
|
||||
} else if (command === 'back' || command === 'cd ..') {
|
||||
router.back();
|
||||
} else if (command === 'search') {
|
||||
router.push('/projects');
|
||||
}
|
||||
};
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[#faf8f3] p-4">
|
||||
<div className="w-full max-w-2xl">
|
||||
{/* Terminal-style 404 */}
|
||||
<div className="bg-[#3e2723] rounded-2xl shadow-2xl overflow-hidden border border-[#5d4037]">
|
||||
{/* Terminal Header */}
|
||||
<div className="bg-[#5d4037] px-4 py-3 flex items-center gap-2 border-b border-[#795548]">
|
||||
<div className="flex gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-[#d84315]"></div>
|
||||
<div className="w-3 h-3 rounded-full bg-[#bcaaa4]"></div>
|
||||
<div className="w-3 h-3 rounded-full bg-[#a1887f]"></div>
|
||||
</div>
|
||||
<div className="ml-4 text-[#faf8f3] text-sm font-mono">
|
||||
terminal@portfolio ~ 404
|
||||
</div>
|
||||
<div className="min-h-screen flex items-center justify-center bg-[#fdfcf8] dark:bg-stone-950 overflow-hidden relative">
|
||||
{/* Liquid Background Blobs */}
|
||||
<motion.div
|
||||
className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-liquid-mint/20 dark:bg-liquid-mint/10 rounded-full blur-[120px]"
|
||||
animate={{
|
||||
scale: [1, 1.2, 1],
|
||||
x: [0, 50, 0],
|
||||
y: [0, 30, 0],
|
||||
}}
|
||||
transition={{ duration: 15, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] bg-liquid-rose/20 dark:bg-liquid-rose/10 rounded-full blur-[120px]"
|
||||
animate={{
|
||||
scale: [1.2, 1, 1.2],
|
||||
x: [0, -50, 0],
|
||||
y: [0, -30, 0],
|
||||
}}
|
||||
transition={{ duration: 18, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
|
||||
<div className="relative z-10 max-w-2xl w-full px-6 text-center">
|
||||
{/* Large 404 with Liquid Animation */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
className="relative inline-block mb-8"
|
||||
>
|
||||
<h1 className="text-[12rem] md:text-[16rem] font-black text-stone-900/5 dark:text-stone-100/5 select-none leading-none">
|
||||
404
|
||||
</h1>
|
||||
<motion.div
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
animate={{ y: [0, -10, 0] }}
|
||||
transition={{ duration: 4, repeat: Infinity, ease: "easeInOut" }}
|
||||
>
|
||||
<Ghost size={120} className="text-stone-800 dark:text-stone-200 opacity-80" />
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* Content Card */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.2, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
className="bg-white/40 dark:bg-stone-900/40 backdrop-blur-2xl border border-white/60 dark:border-white/10 rounded-[2.5rem] p-8 md:p-12 shadow-[0_20px_50px_rgba(0,0,0,0.05)] dark:shadow-none"
|
||||
>
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-stone-900 dark:text-stone-50 mb-4 font-sans tracking-tight">
|
||||
Lost in the Liquid.
|
||||
</h2>
|
||||
<p className="text-stone-600 dark:text-stone-400 text-lg md:text-xl font-light leading-relaxed mb-10 max-w-md mx-auto">
|
||||
The page you are looking for has evaporated or never existed in this dimension.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center justify-center gap-3 px-8 py-4 bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900 rounded-2xl font-bold hover:scale-[1.02] active:scale-[0.98] transition-all shadow-lg hover:shadow-xl dark:shadow-none group"
|
||||
>
|
||||
<Home size={20} className="group-hover:-translate-y-0.5 transition-transform" />
|
||||
<span>Back Home</span>
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="flex items-center justify-center gap-3 px-8 py-4 bg-white dark:bg-stone-800 text-stone-900 dark:text-stone-100 border border-stone-200 dark:border-stone-700 rounded-2xl font-bold hover:bg-stone-50 dark:hover:bg-stone-700 hover:scale-[1.02] active:scale-[0.98] transition-all shadow-sm group"
|
||||
>
|
||||
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
|
||||
<span>Go Back</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Terminal Body */}
|
||||
<div className="p-6 md:p-8 font-mono text-sm md:text-base">
|
||||
<div className="mb-6">
|
||||
<div className="text-[#bcaaa4] mb-2">$ cd {mounted ? window.location.pathname : '/unknown'}</div>
|
||||
<div className="text-[#d84315] mb-4">
|
||||
<span className="mr-2">✗</span>
|
||||
Error: ENOENT: no such file or directory
|
||||
</div>
|
||||
<div className="text-[#a1887f] mb-6">
|
||||
<pre className="whitespace-pre-wrap">
|
||||
{`
|
||||
██╗ ██╗ ██████╗ ██╗ ██╗
|
||||
██║ ██║██╔═████╗██║ ██║
|
||||
███████║██║██╔██║███████║
|
||||
╚════██║████╔╝██║╚════██║
|
||||
██║╚██████╔╝ ██║
|
||||
╚═╝ ╚═════╝ ╚═╝
|
||||
`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="text-[#faf8f3] mb-6">
|
||||
<p className="mb-3">The page you're looking for seems to have wandered off.</p>
|
||||
<p className="text-[#bcaaa4]">Perhaps it never existed, or maybe it's on a coffee break.</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 text-[#a1887f]">
|
||||
<div className="mb-2">Available commands:</div>
|
||||
<div className="pl-4 space-y-1 text-sm">
|
||||
<div>→ <span className="text-[#faf8f3]">home</span> - Return to homepage</div>
|
||||
<div>→ <span className="text-[#faf8f3]">back</span> - Go back to previous page</div>
|
||||
<div>→ <span className="text-[#faf8f3]">search</span> - Search the website</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Interactive Command Line */}
|
||||
<div className="flex items-center gap-2 border-t border-[#5d4037] pt-4">
|
||||
<span className="text-[#a1887f]">$</span>
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleCommand(input);
|
||||
setInput('');
|
||||
}
|
||||
}}
|
||||
placeholder="Type a command..."
|
||||
className="flex-1 bg-transparent text-[#faf8f3] outline-none placeholder:text-[#795548] font-mono"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-8 pt-8 border-t border-stone-100 dark:border-stone-800">
|
||||
<Link
|
||||
href="/projects"
|
||||
className="inline-flex items-center gap-2 text-stone-500 dark:text-stone-400 hover:text-stone-900 dark:hover:text-stone-100 transition-colors font-medium group"
|
||||
>
|
||||
<Search size={18} className="group-hover:rotate-12 transition-transform" />
|
||||
<span>Looking for my work? Explore projects</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Quick Action Buttons */}
|
||||
<div className="mt-6 grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center justify-center gap-2 bg-[#fffcf5] hover:bg-[#faf8f3] border border-[#d7ccc8] rounded-xl px-6 py-4 transition-all hover:shadow-md group"
|
||||
{/* Floating Help Badge */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 1 }}
|
||||
className="mt-12"
|
||||
>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-stone-100 dark:bg-stone-800 rounded-full text-stone-500 dark:text-stone-400 text-xs font-mono hover:text-stone-800 dark:hover:text-stone-200 transition-colors"
|
||||
>
|
||||
<Home className="w-5 h-5 text-[#5d4037] group-hover:text-[#3e2723]" />
|
||||
<span className="text-[#3e2723] font-medium">Home</span>
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="flex items-center justify-center gap-2 bg-[#fffcf5] hover:bg-[#faf8f3] border border-[#d7ccc8] rounded-xl px-6 py-4 transition-all hover:shadow-md group"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 text-[#5d4037] group-hover:text-[#3e2723]" />
|
||||
<span className="text-[#3e2723] font-medium">Go Back</span>
|
||||
<RefreshCcw size={12} />
|
||||
<span>ERR_PAGE_NOT_FOUND_404</span>
|
||||
</button>
|
||||
|
||||
<Link
|
||||
href="/projects"
|
||||
className="flex items-center justify-center gap-2 bg-[#fffcf5] hover:bg-[#faf8f3] border border-[#d7ccc8] rounded-xl px-6 py-4 transition-all hover:shadow-md group"
|
||||
>
|
||||
<Search className="w-5 h-5 text-[#5d4037] group-hover:text-[#3e2723]" />
|
||||
<span className="text-[#3e2723] font-medium">Explore Projects</span>
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user