fix: resolve project 404s with Directus fallback and upgrade 404 page
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Has been cancelled
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Has been cancelled
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} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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