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 { 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 { translations: _translations, ...rest } = project;
|
const tr = trPreferred ?? trDefault;
|
||||||
const localizedContent = (() => {
|
const { translations: _translations, ...rest } = dbProject;
|
||||||
if (typeof tr?.content === "string") return tr.content;
|
const localizedContent = (() => {
|
||||||
if (tr?.content && typeof tr.content === "object" && "markdown" in tr.content) {
|
if (typeof tr?.content === "string") return tr.content;
|
||||||
const markdown = (tr.content as Record<string, unknown>).markdown;
|
if (tr?.content && typeof tr.content === "object" && "markdown" in tr.content) {
|
||||||
if (typeof markdown === "string") return markdown;
|
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 = {
|
if (!projectData) return notFound();
|
||||||
...rest,
|
|
||||||
title: tr?.title ?? project.title,
|
|
||||||
description: tr?.description ?? project.description,
|
|
||||||
content: localizedContent,
|
|
||||||
};
|
|
||||||
|
|
||||||
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} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+27
-46
@@ -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') {
|
||||||
|
|||||||
+97
-125
@@ -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>
|
transition={{ duration: 15, repeat: Infinity, ease: "easeInOut" }}
|
||||||
</div>
|
/>
|
||||||
<div className="ml-4 text-[#faf8f3] text-sm font-mono">
|
<motion.div
|
||||||
terminal@portfolio ~ 404
|
className="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] bg-liquid-rose/20 dark:bg-liquid-rose/10 rounded-full blur-[120px]"
|
||||||
</div>
|
animate={{
|
||||||
</div>
|
scale: [1.2, 1, 1.2],
|
||||||
|
x: [0, -50, 0],
|
||||||
|
y: [0, -30, 0],
|
||||||
|
}}
|
||||||
|
transition={{ duration: 18, repeat: Infinity, ease: "easeInOut" }}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Terminal Body */}
|
<div className="relative z-10 max-w-2xl w-full px-6 text-center">
|
||||||
<div className="p-6 md:p-8 font-mono text-sm md:text-base">
|
{/* Large 404 with Liquid Animation */}
|
||||||
<div className="mb-6">
|
<motion.div
|
||||||
<div className="text-[#bcaaa4] mb-2">$ cd {mounted ? window.location.pathname : '/unknown'}</div>
|
initial={{ opacity: 0, y: 20 }}
|
||||||
<div className="text-[#d84315] mb-4">
|
animate={{ opacity: 1, y: 0 }}
|
||||||
<span className="mr-2">✗</span>
|
transition={{ duration: 0.8, ease: [0.25, 0.1, 0.25, 1] }}
|
||||||
Error: ENOENT: no such file or directory
|
className="relative inline-block mb-8"
|
||||||
</div>
|
>
|
||||||
<div className="text-[#a1887f] mb-6">
|
<h1 className="text-[12rem] md:text-[16rem] font-black text-stone-900/5 dark:text-stone-100/5 select-none leading-none">
|
||||||
<pre className="whitespace-pre-wrap">
|
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" }}
|
||||||
██║╚██████╔╝ ██║
|
|
||||||
╚═╝ ╚═════╝ ╚═╝
|
|
||||||
`}
|
|
||||||
</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>
|
|
||||||
</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"
|
|
||||||
>
|
>
|
||||||
<Home className="w-5 h-5 text-[#5d4037] group-hover:text-[#3e2723]" />
|
<Ghost size={120} className="text-stone-800 dark:text-stone-200 opacity-80" />
|
||||||
<span className="text-[#3e2723] font-medium">Home</span>
|
</motion.div>
|
||||||
</Link>
|
</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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Floating Help Badge */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 1 }}
|
||||||
|
className="mt-12"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => router.back()}
|
onClick={() => window.location.reload()}
|
||||||
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 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"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-5 h-5 text-[#5d4037] group-hover:text-[#3e2723]" />
|
<RefreshCcw size={12} />
|
||||||
<span className="text-[#3e2723] font-medium">Go Back</span>
|
<span>ERR_PAGE_NOT_FOUND_404</span>
|
||||||
</button>
|
</button>
|
||||||
|
</motion.div>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+109
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Generated
+41
-32
@@ -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",
|
||||||
|
|||||||
+2
-2
@@ -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