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 { notFound } from "next/navigation";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
|
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
|
||||||
|
import { getProjectBySlug } from "@/lib/directus";
|
||||||
|
|
||||||
export const revalidate = 300;
|
export const revalidate = 300;
|
||||||
|
|
||||||
@@ -12,6 +13,20 @@ export async function generateMetadata({
|
|||||||
params: Promise<{ locale: string; slug: string }>;
|
params: Promise<{ locale: string; slug: string }>;
|
||||||
}): Promise<Metadata> {
|
}): Promise<Metadata> {
|
||||||
const { locale, slug } = await params;
|
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}` });
|
const languages = getLanguageAlternates({ pathWithoutLocale: `projects/${slug}` });
|
||||||
return {
|
return {
|
||||||
alternates: {
|
alternates: {
|
||||||
@@ -28,7 +43,8 @@ export default async function ProjectPage({
|
|||||||
}) {
|
}) {
|
||||||
const { locale, slug } = await params;
|
const { locale, slug } = await params;
|
||||||
|
|
||||||
const project = await prisma.project.findFirst({
|
// Try PostgreSQL first
|
||||||
|
const dbProject = await prisma.project.findFirst({
|
||||||
where: { slug, published: true },
|
where: { slug, published: true },
|
||||||
include: {
|
include: {
|
||||||
translations: {
|
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));
|
if (dbProject) {
|
||||||
const trDefault = project.translations?.find(
|
const trPreferred = dbProject.translations?.find((t) => t.locale === locale && (t?.title || t?.description));
|
||||||
(t) => t.locale === project.defaultLocale && (t?.title || t?.description),
|
const trDefault = dbProject.translations?.find(
|
||||||
|
(t) => t.locale === dbProject.defaultLocale && (t?.title || t?.description),
|
||||||
);
|
);
|
||||||
const tr = trPreferred ?? trDefault;
|
const tr = trPreferred ?? trDefault;
|
||||||
const { translations: _translations, ...rest } = project;
|
const { translations: _translations, ...rest } = dbProject;
|
||||||
const localizedContent = (() => {
|
const localizedContent = (() => {
|
||||||
if (typeof tr?.content === "string") return tr.content;
|
if (typeof tr?.content === "string") return tr.content;
|
||||||
if (tr?.content && typeof tr.content === "object" && "markdown" in tr.content) {
|
if (tr?.content && typeof tr.content === "object" && "markdown" in tr.content) {
|
||||||
const markdown = (tr.content as Record<string, unknown>).markdown;
|
const markdown = (tr.content as Record<string, unknown>).markdown;
|
||||||
if (typeof markdown === "string") return markdown;
|
if (typeof markdown === "string") return markdown;
|
||||||
}
|
}
|
||||||
return project.content;
|
return dbProject.content;
|
||||||
})();
|
})();
|
||||||
const localized = {
|
projectData = {
|
||||||
...rest,
|
...rest,
|
||||||
title: tr?.title ?? project.title,
|
title: tr?.title ?? dbProject.title,
|
||||||
description: tr?.description ?? project.description,
|
description: tr?.description ?? dbProject.description,
|
||||||
content: localizedContent,
|
content: localizedContent,
|
||||||
};
|
};
|
||||||
|
} else {
|
||||||
|
// Try Directus fallback
|
||||||
|
const directusProject = await getProjectBySlug(slug, locale);
|
||||||
|
if (directusProject) {
|
||||||
|
projectData = {
|
||||||
|
...directusProject,
|
||||||
|
id: parseInt(directusProject.id) || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!projectData) return notFound();
|
||||||
|
|
||||||
const jsonLd = {
|
const jsonLd = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "SoftwareSourceCode",
|
"@type": "SoftwareSourceCode",
|
||||||
"name": localized.title,
|
"name": projectData.title,
|
||||||
"description": localized.description,
|
"description": projectData.description,
|
||||||
"codeRepository": localized.github,
|
"codeRepository": projectData.github_url || projectData.github,
|
||||||
"programmingLanguage": localized.technologies,
|
"programmingLanguage": projectData.technologies,
|
||||||
"author": {
|
"author": {
|
||||||
"@type": "Person",
|
"@type": "Person",
|
||||||
"name": "Dennis Konkol"
|
"name": "Dennis Konkol"
|
||||||
},
|
},
|
||||||
"dateCreated": project.date,
|
"dateCreated": projectData.date || projectData.created_at,
|
||||||
"url": toAbsoluteUrl(`/${locale}/projects/${slug}`),
|
"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 (
|
return (
|
||||||
@@ -82,7 +111,7 @@ export default async function ProjectPage({
|
|||||||
type="application/ld+json"
|
type="application/ld+json"
|
||||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
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 ProjectsPageClient from "@/app/_ui/ProjectsPageClient";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
|
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
|
||||||
|
import { getProjects as getDirectusProjects } from "@/lib/directus";
|
||||||
|
|
||||||
export const revalidate = 300;
|
export const revalidate = 300;
|
||||||
|
|
||||||
@@ -27,7 +28,8 @@ export default async function ProjectsPage({
|
|||||||
}) {
|
}) {
|
||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
|
|
||||||
const projects = await prisma.project.findMany({
|
// Fetch from PostgreSQL
|
||||||
|
const dbProjects = await prisma.project.findMany({
|
||||||
where: { published: true },
|
where: { published: true },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
include: {
|
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 trPreferred = p.translations?.find((t) => t.locale === locale && (t?.title || t?.description));
|
||||||
const trDefault = p.translations?.find(
|
const trDefault = p.translations?.find(
|
||||||
(t) => t.locale === p.defaultLocale && (t?.title || t?.description),
|
(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';
|
const locale = searchParams.get('locale') || 'en';
|
||||||
|
|
||||||
// Try Directus FIRST (Primary Source)
|
// Try Directus FIRST (Primary Source)
|
||||||
|
let directusProjects: any[] = [];
|
||||||
try {
|
try {
|
||||||
const directusProjects = await getDirectusProjects(locale, {
|
const fetched = await getDirectusProjects(locale, {
|
||||||
featured: featured === 'true' ? true : featured === 'false' ? false : undefined,
|
featured: featured === 'true' ? true : featured === 'false' ? false : undefined,
|
||||||
published: published === 'true' ? true : published === 'false' ? false : undefined,
|
published: published === 'true' ? true : published === 'false' ? false : undefined,
|
||||||
category: category || undefined,
|
category: category || undefined,
|
||||||
@@ -57,54 +58,34 @@ export async function GET(request: NextRequest) {
|
|||||||
limit
|
limit
|
||||||
});
|
});
|
||||||
|
|
||||||
if (directusProjects && directusProjects.length > 0) {
|
if (fetched) {
|
||||||
return NextResponse.json({
|
directusProjects = fetched;
|
||||||
projects: directusProjects,
|
|
||||||
total: directusProjects.length,
|
|
||||||
page: 1,
|
|
||||||
limit: directusProjects.length,
|
|
||||||
source: 'directus'
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (directusError) {
|
} catch (directusError) {
|
||||||
console.log('Directus not available, trying PostgreSQL fallback');
|
console.log('Directus error, continuing with PostgreSQL');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback 1: Try PostgreSQL
|
// Fallback 1: Try PostgreSQL
|
||||||
try {
|
try {
|
||||||
await prisma.$queryRaw`SELECT 1`;
|
await prisma.$queryRaw`SELECT 1`;
|
||||||
} catch (dbError) {
|
} 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({
|
return NextResponse.json({
|
||||||
projects: [],
|
projects: [],
|
||||||
total: 0,
|
total: 0,
|
||||||
page: 1,
|
|
||||||
limit,
|
|
||||||
source: 'fallback'
|
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 skip = (page - 1) * limit;
|
||||||
|
|
||||||
const where: Record<string, unknown> = {};
|
const where: Record<string, unknown> = {};
|
||||||
|
|
||||||
if (category) where.category = category;
|
if (category) where.category = category;
|
||||||
@@ -116,12 +97,11 @@ export async function GET(request: NextRequest) {
|
|||||||
where.OR = [
|
where.OR = [
|
||||||
{ title: { contains: search, mode: 'insensitive' } },
|
{ title: { contains: search, mode: 'insensitive' } },
|
||||||
{ description: { contains: search, mode: 'insensitive' } },
|
{ description: { contains: search, mode: 'insensitive' } },
|
||||||
{ tags: { hasSome: [search] } },
|
{ tags: { hasSome: [search] } }
|
||||||
{ content: { contains: search, mode: 'insensitive' } }
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
const [projects, total] = await Promise.all([
|
const [dbProjects, total] = await Promise.all([
|
||||||
prisma.project.findMany({
|
prisma.project.findMany({
|
||||||
where,
|
where,
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
@@ -131,20 +111,21 @@ export async function GET(request: NextRequest) {
|
|||||||
prisma.project.count({ where })
|
prisma.project.count({ where })
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const result = {
|
// Merge logic
|
||||||
projects,
|
const dbSlugs = new Set(dbProjects.map(p => p.slug));
|
||||||
total,
|
const mergedProjects = [...dbProjects];
|
||||||
pages: Math.ceil(total / limit),
|
|
||||||
currentPage: page,
|
|
||||||
source: 'postgresql'
|
|
||||||
};
|
|
||||||
|
|
||||||
// Cache the result (only for non-search queries)
|
for (const dp of directusProjects) {
|
||||||
if (!search) {
|
if (!dbSlugs.has(dp.slug)) {
|
||||||
await apiCache.setProjects(cacheParams, result);
|
mergedProjects.push(dp);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(result);
|
return NextResponse.json({
|
||||||
|
projects: mergedProjects,
|
||||||
|
total: total + (mergedProjects.length - dbProjects.length),
|
||||||
|
source: 'merged'
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Handle missing database table gracefully
|
// Handle missing database table gracefully
|
||||||
if (error instanceof PrismaClientKnownRequestError && error.code === 'P2021') {
|
if (error instanceof PrismaClientKnownRequestError && error.code === 'P2021') {
|
||||||
|
|||||||
@@ -1,149 +1,121 @@
|
|||||||
"use client";
|
"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 Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Home, ArrowLeft, Search } from "lucide-react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const [input, setInput] = useState("");
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// In tests, avoid next/dynamic loadable timing and render a stable fallback
|
if (!mounted) return null;
|
||||||
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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-[#faf8f3] p-4">
|
<div className="min-h-screen flex items-center justify-center bg-[#fdfcf8] dark:bg-stone-950 overflow-hidden relative">
|
||||||
<div className="w-full max-w-2xl">
|
{/* Liquid Background Blobs */}
|
||||||
{/* Terminal-style 404 */}
|
<motion.div
|
||||||
<div className="bg-[#3e2723] rounded-2xl shadow-2xl overflow-hidden border border-[#5d4037]">
|
className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-liquid-mint/20 dark:bg-liquid-mint/10 rounded-full blur-[120px]"
|
||||||
{/* Terminal Header */}
|
animate={{
|
||||||
<div className="bg-[#5d4037] px-4 py-3 flex items-center gap-2 border-b border-[#795548]">
|
scale: [1, 1.2, 1],
|
||||||
<div className="flex gap-2">
|
x: [0, 50, 0],
|
||||||
<div className="w-3 h-3 rounded-full bg-[#d84315]"></div>
|
y: [0, 30, 0],
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* 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..."
|
transition={{ duration: 15, repeat: Infinity, ease: "easeInOut" }}
|
||||||
className="flex-1 bg-transparent text-[#faf8f3] outline-none placeholder:text-[#795548] font-mono"
|
/>
|
||||||
autoFocus
|
<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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick Action Buttons */}
|
<div className="relative z-10 max-w-2xl w-full px-6 text-center">
|
||||||
<div className="mt-6 grid grid-cols-1 md:grid-cols-3 gap-4">
|
{/* 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
|
<Link
|
||||||
href="/"
|
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"
|
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 className="w-5 h-5 text-[#5d4037] group-hover:text-[#3e2723]" />
|
<Home size={20} className="group-hover:-translate-y-0.5 transition-transform" />
|
||||||
<span className="text-[#3e2723] font-medium">Home</span>
|
<span>Back Home</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => router.back()}
|
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"
|
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 className="w-5 h-5 text-[#5d4037] group-hover:text-[#3e2723]" />
|
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
|
||||||
<span className="text-[#3e2723] font-medium">Go Back</span>
|
<span>Go Back</span>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 pt-8 border-t border-stone-100 dark:border-stone-800">
|
||||||
<Link
|
<Link
|
||||||
href="/projects"
|
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"
|
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 className="w-5 h-5 text-[#5d4037] group-hover:text-[#3e2723]" />
|
<Search size={18} className="group-hover:rotate-12 transition-transform" />
|
||||||
<span className="text-[#3e2723] font-medium">Explore Projects</span>
|
<span>Looking for my work? Explore projects</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* 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"
|
||||||
|
>
|
||||||
|
<RefreshCcw size={12} />
|
||||||
|
<span>ERR_PAGE_NOT_FOUND_404</span>
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
109
lib/directus.ts
109
lib/directus.ts
@@ -659,3 +659,112 @@ export async function getProjects(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single project by slug from Directus
|
||||||
|
*/
|
||||||
|
export async function getProjectBySlug(
|
||||||
|
slug: string,
|
||||||
|
locale: string
|
||||||
|
): Promise<Project | null> {
|
||||||
|
const directusLocale = toDirectusLocale(locale);
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
query {
|
||||||
|
projects(
|
||||||
|
filter: {
|
||||||
|
_and: [
|
||||||
|
{ slug: { _eq: "${slug}" } },
|
||||||
|
{ status: { _eq: "published" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
limit: 1
|
||||||
|
) {
|
||||||
|
id
|
||||||
|
slug
|
||||||
|
category
|
||||||
|
difficulty
|
||||||
|
tags
|
||||||
|
technologies
|
||||||
|
challenges
|
||||||
|
lessons_learned
|
||||||
|
future_improvements
|
||||||
|
github
|
||||||
|
live
|
||||||
|
image_url
|
||||||
|
demo_video
|
||||||
|
date_created
|
||||||
|
date_updated
|
||||||
|
featured
|
||||||
|
status
|
||||||
|
translations {
|
||||||
|
title
|
||||||
|
description
|
||||||
|
content
|
||||||
|
meta_description
|
||||||
|
keywords
|
||||||
|
languages_code { code }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await directusRequest(
|
||||||
|
'',
|
||||||
|
{ body: { query } }
|
||||||
|
);
|
||||||
|
|
||||||
|
const projects = (result as any)?.projects;
|
||||||
|
if (!projects || projects.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const proj = projects[0];
|
||||||
|
const trans =
|
||||||
|
proj.translations?.find((t: any) => t.languages_code?.code === directusLocale) ||
|
||||||
|
proj.translations?.[0] ||
|
||||||
|
{};
|
||||||
|
|
||||||
|
// Parse JSON string fields if needed
|
||||||
|
const parseTags = (tags: any) => {
|
||||||
|
if (!tags) return [];
|
||||||
|
if (Array.isArray(tags)) return tags;
|
||||||
|
if (typeof tags === 'string') {
|
||||||
|
try {
|
||||||
|
return JSON.parse(tags);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: proj.id,
|
||||||
|
slug: proj.slug,
|
||||||
|
title: trans.title || proj.slug,
|
||||||
|
description: trans.description || '',
|
||||||
|
content: trans.content,
|
||||||
|
category: proj.category,
|
||||||
|
difficulty: proj.difficulty,
|
||||||
|
tags: parseTags(proj.tags),
|
||||||
|
technologies: parseTags(proj.technologies),
|
||||||
|
challenges: proj.challenges,
|
||||||
|
lessons_learned: proj.lessons_learned,
|
||||||
|
future_improvements: proj.future_improvements,
|
||||||
|
github_url: proj.github,
|
||||||
|
live_url: proj.live,
|
||||||
|
image_url: proj.image_url,
|
||||||
|
demo_video_url: proj.demo_video,
|
||||||
|
screenshots: parseTags(proj.screenshots),
|
||||||
|
featured: proj.featured === 1 || proj.featured === true,
|
||||||
|
published: proj.status === 'published',
|
||||||
|
created_at: proj.date_created,
|
||||||
|
updated_at: proj.date_updated
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to fetch project by slug ${slug} (${locale}):`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
73
package-lock.json
generated
73
package-lock.json
generated
@@ -61,7 +61,7 @@
|
|||||||
"@types/react-responsive-masonry": "^2.6.0",
|
"@types/react-responsive-masonry": "^2.6.0",
|
||||||
"@types/react-syntax-highlighter": "^15.5.11",
|
"@types/react-syntax-highlighter": "^15.5.11",
|
||||||
"@types/sanitize-html": "^2.16.0",
|
"@types/sanitize-html": "^2.16.0",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.24",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "^15.5.7",
|
"eslint-config-next": "^15.5.7",
|
||||||
@@ -69,7 +69,7 @@
|
|||||||
"jest-environment-jsdom": "^29.7.0",
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
"nodemailer-mock": "^2.0.9",
|
"nodemailer-mock": "^2.0.9",
|
||||||
"playwright": "^1.57.0",
|
"playwright": "^1.57.0",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.5.6",
|
||||||
"prisma": "^5.22.0",
|
"prisma": "^5.22.0",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"ts-jest": "^29.4.6",
|
"ts-jest": "^29.4.6",
|
||||||
@@ -6961,9 +6961,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/arg": {
|
"node_modules/arg": {
|
||||||
"version": "5.0.2",
|
"version": "4.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
|
||||||
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
|
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
@@ -14527,9 +14527,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss-load-config": {
|
"node_modules/postcss-load-config": {
|
||||||
"version": "6.0.1",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz",
|
||||||
"integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
|
"integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -14543,28 +14543,21 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lilconfig": "^3.1.1"
|
"lilconfig": "^3.0.0",
|
||||||
|
"yaml": "^2.3.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 18"
|
"node": ">= 14"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"jiti": ">=1.21.0",
|
|
||||||
"postcss": ">=8.0.9",
|
"postcss": ">=8.0.9",
|
||||||
"tsx": "^4.8.1",
|
"ts-node": ">=9.0.0"
|
||||||
"yaml": "^2.4.2"
|
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"jiti": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"postcss": {
|
"postcss": {
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"tsx": {
|
"ts-node": {
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"yaml": {
|
|
||||||
"optional": true
|
"optional": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -16530,9 +16523,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tailwindcss": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "3.4.19",
|
"version": "3.4.17",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
|
||||||
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
|
"integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -16544,7 +16537,7 @@
|
|||||||
"fast-glob": "^3.3.2",
|
"fast-glob": "^3.3.2",
|
||||||
"glob-parent": "^6.0.2",
|
"glob-parent": "^6.0.2",
|
||||||
"is-glob": "^4.0.3",
|
"is-glob": "^4.0.3",
|
||||||
"jiti": "^1.21.7",
|
"jiti": "^1.21.6",
|
||||||
"lilconfig": "^3.1.3",
|
"lilconfig": "^3.1.3",
|
||||||
"micromatch": "^4.0.8",
|
"micromatch": "^4.0.8",
|
||||||
"normalize-path": "^3.0.0",
|
"normalize-path": "^3.0.0",
|
||||||
@@ -16553,7 +16546,7 @@
|
|||||||
"postcss": "^8.4.47",
|
"postcss": "^8.4.47",
|
||||||
"postcss-import": "^15.1.0",
|
"postcss-import": "^15.1.0",
|
||||||
"postcss-js": "^4.0.1",
|
"postcss-js": "^4.0.1",
|
||||||
"postcss-load-config": "^4.0.2 || ^5.0 || ^6.0",
|
"postcss-load-config": "^4.0.2",
|
||||||
"postcss-nested": "^6.2.0",
|
"postcss-nested": "^6.2.0",
|
||||||
"postcss-selector-parser": "^6.1.2",
|
"postcss-selector-parser": "^6.1.2",
|
||||||
"resolve": "^1.22.8",
|
"resolve": "^1.22.8",
|
||||||
@@ -16567,6 +16560,13 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tailwindcss/node_modules/arg": {
|
||||||
|
"version": "5.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
||||||
|
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tailwindcss/node_modules/fast-glob": {
|
"node_modules/tailwindcss/node_modules/fast-glob": {
|
||||||
"version": "3.3.3",
|
"version": "3.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||||
@@ -17085,13 +17085,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ts-node/node_modules/arg": {
|
|
||||||
"version": "4.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
|
|
||||||
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/tsconfig-paths": {
|
"node_modules/tsconfig-paths": {
|
||||||
"version": "3.15.0",
|
"version": "3.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
|
||||||
@@ -18222,6 +18215,22 @@
|
|||||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/yaml": {
|
||||||
|
"version": "2.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||||
|
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"yaml": "bin.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/eemeli"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yargs": {
|
"node_modules/yargs": {
|
||||||
"version": "17.7.2",
|
"version": "17.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||||
|
|||||||
@@ -105,7 +105,7 @@
|
|||||||
"@types/react-responsive-masonry": "^2.6.0",
|
"@types/react-responsive-masonry": "^2.6.0",
|
||||||
"@types/react-syntax-highlighter": "^15.5.11",
|
"@types/react-syntax-highlighter": "^15.5.11",
|
||||||
"@types/sanitize-html": "^2.16.0",
|
"@types/sanitize-html": "^2.16.0",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.24",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "^15.5.7",
|
"eslint-config-next": "^15.5.7",
|
||||||
@@ -113,7 +113,7 @@
|
|||||||
"jest-environment-jsdom": "^29.7.0",
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
"nodemailer-mock": "^2.0.9",
|
"nodemailer-mock": "^2.0.9",
|
||||||
"playwright": "^1.57.0",
|
"playwright": "^1.57.0",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.5.6",
|
||||||
"prisma": "^5.22.0",
|
"prisma": "^5.22.0",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"ts-jest": "^29.4.6",
|
"ts-jest": "^29.4.6",
|
||||||
|
|||||||
Reference in New Issue
Block a user