fix: resolve project 404s with Directus fallback and upgrade 404 page
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:
2026-02-15 22:47:25 +01:00
parent 6998a0e7a1
commit cc8fff14d2
7 changed files with 370 additions and 237 deletions

View File

@@ -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} />
</> </>
); );
} }

View File

@@ -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} />;
} }

View File

@@ -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') {

View File

@@ -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&apos;re looking for doesn&apos;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&apos;re looking for seems to have wandered off.</p>
<p className="text-[#bcaaa4]">Perhaps it never existed, or maybe it&apos;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>
); );

View File

@@ -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
View File

@@ -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",

View File

@@ -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",