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 type { Metadata } from "next";
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
import { getProjectBySlug } from "@/lib/directus";
export const revalidate = 300;
@@ -12,6 +13,20 @@ export async function generateMetadata({
params: Promise<{ locale: string; slug: string }>;
}): Promise<Metadata> {
const { locale, slug } = await params;
// Try Directus first for metadata
const directusProject = await getProjectBySlug(slug, locale);
if (directusProject) {
return {
title: directusProject.title,
description: directusProject.description,
alternates: {
canonical: toAbsoluteUrl(`/${locale}/projects/${slug}`),
languages: getLanguageAlternates({ pathWithoutLocale: `projects/${slug}` }),
},
};
}
const languages = getLanguageAlternates({ pathWithoutLocale: `projects/${slug}` });
return {
alternates: {
@@ -28,7 +43,8 @@ export default async function ProjectPage({
}) {
const { locale, slug } = await params;
const project = await prisma.project.findFirst({
// Try PostgreSQL first
const dbProject = await prisma.project.findFirst({
where: { slug, published: true },
include: {
translations: {
@@ -37,43 +53,56 @@ export default async function ProjectPage({
},
});
if (!project) return notFound();
let projectData: any = null;
const trPreferred = project.translations?.find((t) => t.locale === locale && (t?.title || t?.description));
const trDefault = project.translations?.find(
(t) => t.locale === project.defaultLocale && (t?.title || t?.description),
if (dbProject) {
const trPreferred = dbProject.translations?.find((t) => t.locale === locale && (t?.title || t?.description));
const trDefault = dbProject.translations?.find(
(t) => t.locale === dbProject.defaultLocale && (t?.title || t?.description),
);
const tr = trPreferred ?? trDefault;
const { translations: _translations, ...rest } = project;
const { translations: _translations, ...rest } = dbProject;
const localizedContent = (() => {
if (typeof tr?.content === "string") return tr.content;
if (tr?.content && typeof tr.content === "object" && "markdown" in tr.content) {
const markdown = (tr.content as Record<string, unknown>).markdown;
if (typeof markdown === "string") return markdown;
}
return project.content;
return dbProject.content;
})();
const localized = {
projectData = {
...rest,
title: tr?.title ?? project.title,
description: tr?.description ?? project.description,
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,
};
}
}
if (!projectData) return notFound();
const jsonLd = {
"@context": "https://schema.org",
"@type": "SoftwareSourceCode",
"name": localized.title,
"description": localized.description,
"codeRepository": localized.github,
"programmingLanguage": localized.technologies,
"name": projectData.title,
"description": projectData.description,
"codeRepository": projectData.github_url || projectData.github,
"programmingLanguage": projectData.technologies,
"author": {
"@type": "Person",
"name": "Dennis Konkol"
},
"dateCreated": project.date,
"dateCreated": projectData.date || projectData.created_at,
"url": toAbsoluteUrl(`/${locale}/projects/${slug}`),
"image": localized.imageUrl ? toAbsoluteUrl(localized.imageUrl) : undefined,
"image": projectData.imageUrl || projectData.image_url ? toAbsoluteUrl(projectData.imageUrl || projectData.image_url) : undefined,
};
return (
@@ -82,7 +111,7 @@ export default async function ProjectPage({
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<ProjectDetailClient project={localized} locale={locale} />
<ProjectDetailClient project={projectData} locale={locale} />
</>
);
}

View File

@@ -2,6 +2,7 @@ import { prisma } from "@/lib/prisma";
import ProjectsPageClient from "@/app/_ui/ProjectsPageClient";
import type { Metadata } from "next";
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
import { getProjects as getDirectusProjects } from "@/lib/directus";
export const revalidate = 300;
@@ -27,7 +28,8 @@ export default async function ProjectsPage({
}) {
const { locale } = await params;
const projects = await prisma.project.findMany({
// Fetch from PostgreSQL
const dbProjects = await prisma.project.findMany({
where: { published: true },
orderBy: { createdAt: "desc" },
include: {
@@ -37,7 +39,21 @@ export default async function ProjectsPage({
},
});
const localized = projects.map((p) => {
// Fetch from Directus
let directusProjects: any[] = [];
try {
const fetched = await getDirectusProjects(locale, { published: true });
if (fetched) {
directusProjects = fetched.map(p => ({
...p,
id: parseInt(p.id) || 0,
}));
}
} catch (err) {
console.error("Directus projects fetch failed:", err);
}
const localizedDb = dbProjects.map((p) => {
const trPreferred = p.translations?.find((t) => t.locale === locale && (t?.title || t?.description));
const trDefault = p.translations?.find(
(t) => t.locale === p.defaultLocale && (t?.title || t?.description),
@@ -51,6 +67,23 @@ export default async function ProjectsPage({
};
});
return <ProjectsPageClient projects={localized} locale={locale} />;
// Merge projects, prioritizing DB ones if slugs match
const allProjects = [...localizedDb];
const dbSlugs = new Set(localizedDb.map(p => p.slug));
for (const dp of directusProjects) {
if (!dbSlugs.has(dp.slug)) {
allProjects.push(dp);
}
}
// Final sort by date
allProjects.sort((a, b) => {
const dateA = new Date(a.date || a.createdAt || 0).getTime();
const dateB = new Date(b.date || b.createdAt || 0).getTime();
return dateB - dateA;
});
return <ProjectsPageClient projects={allProjects} locale={locale} />;
}

View File

@@ -47,8 +47,9 @@ export async function GET(request: NextRequest) {
const locale = searchParams.get('locale') || 'en';
// Try Directus FIRST (Primary Source)
let directusProjects: any[] = [];
try {
const directusProjects = await getDirectusProjects(locale, {
const fetched = await getDirectusProjects(locale, {
featured: featured === 'true' ? true : featured === 'false' ? false : undefined,
published: published === 'true' ? true : published === 'false' ? false : undefined,
category: category || undefined,
@@ -57,54 +58,34 @@ export async function GET(request: NextRequest) {
limit
});
if (directusProjects && directusProjects.length > 0) {
return NextResponse.json({
projects: directusProjects,
total: directusProjects.length,
page: 1,
limit: directusProjects.length,
source: 'directus'
});
if (fetched) {
directusProjects = fetched;
}
} catch (directusError) {
console.log('Directus not available, trying PostgreSQL fallback');
console.log('Directus error, continuing with PostgreSQL');
}
// Fallback 1: Try PostgreSQL
try {
await prisma.$queryRaw`SELECT 1`;
} catch (dbError) {
console.log('PostgreSQL also not available, using empty fallback');
console.log('PostgreSQL not available');
if (directusProjects.length > 0) {
return NextResponse.json({
projects: directusProjects,
total: directusProjects.length,
source: 'directus'
});
}
// Fallback 2: Return empty (components should have hardcoded fallback)
return NextResponse.json({
projects: [],
total: 0,
page: 1,
limit,
source: 'fallback'
});
}
// Create cache parameters object
const cacheParams = {
page: page.toString(),
limit: limit.toString(),
category,
featured,
published,
difficulty,
search
};
// Check cache first
const cached = await apiCache.getProjects(cacheParams);
if (cached && !search) { // Don't cache search results
return NextResponse.json(cached);
}
const skip = (page - 1) * limit;
const where: Record<string, unknown> = {};
if (category) where.category = category;
@@ -116,12 +97,11 @@ export async function GET(request: NextRequest) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
{ tags: { hasSome: [search] } },
{ content: { contains: search, mode: 'insensitive' } }
{ tags: { hasSome: [search] } }
];
}
const [projects, total] = await Promise.all([
const [dbProjects, total] = await Promise.all([
prisma.project.findMany({
where,
orderBy: { createdAt: 'desc' },
@@ -131,20 +111,21 @@ export async function GET(request: NextRequest) {
prisma.project.count({ where })
]);
const result = {
projects,
total,
pages: Math.ceil(total / limit),
currentPage: page,
source: 'postgresql'
};
// Merge logic
const dbSlugs = new Set(dbProjects.map(p => p.slug));
const mergedProjects = [...dbProjects];
// Cache the result (only for non-search queries)
if (!search) {
await apiCache.setProjects(cacheParams, result);
for (const dp of directusProjects) {
if (!dbSlugs.has(dp.slug)) {
mergedProjects.push(dp);
}
}
return NextResponse.json(result);
return NextResponse.json({
projects: mergedProjects,
total: total + (mergedProjects.length - dbProjects.length),
source: 'merged'
});
} catch (error) {
// Handle missing database table gracefully
if (error instanceof PrismaClientKnownRequestError && error.code === 'P2021') {

View File

@@ -1,149 +1,121 @@
"use client";
import { useEffect, useState } from "react";
import { motion } from "framer-motion";
import { Home, ArrowLeft, Search, Ghost, RefreshCcw } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Home, ArrowLeft, Search } from "lucide-react";
import { useEffect, useState } from "react";
export default function NotFound() {
const [mounted, setMounted] = useState(false);
const [input, setInput] = useState("");
const router = useRouter();
useEffect(() => {
setMounted(true);
}, []);
// In tests, avoid next/dynamic loadable timing and render a stable fallback
if (process.env.NODE_ENV === "test") {
return (
<div>
Oops! The page you&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');
}
};
if (!mounted) return null;
return (
<div className="min-h-screen flex items-center justify-center bg-[#faf8f3] p-4">
<div className="w-full max-w-2xl">
{/* Terminal-style 404 */}
<div className="bg-[#3e2723] rounded-2xl shadow-2xl overflow-hidden border border-[#5d4037]">
{/* Terminal Header */}
<div className="bg-[#5d4037] px-4 py-3 flex items-center gap-2 border-b border-[#795548]">
<div className="flex gap-2">
<div className="w-3 h-3 rounded-full bg-[#d84315]"></div>
<div className="w-3 h-3 rounded-full bg-[#bcaaa4]"></div>
<div className="w-3 h-3 rounded-full bg-[#a1887f]"></div>
</div>
<div className="ml-4 text-[#faf8f3] text-sm font-mono">
terminal@portfolio ~ 404
</div>
</div>
{/* 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('');
}
<div className="min-h-screen flex items-center justify-center bg-[#fdfcf8] dark:bg-stone-950 overflow-hidden relative">
{/* Liquid Background Blobs */}
<motion.div
className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-liquid-mint/20 dark:bg-liquid-mint/10 rounded-full blur-[120px]"
animate={{
scale: [1, 1.2, 1],
x: [0, 50, 0],
y: [0, 30, 0],
}}
placeholder="Type a command..."
className="flex-1 bg-transparent text-[#faf8f3] outline-none placeholder:text-[#795548] font-mono"
autoFocus
transition={{ duration: 15, repeat: Infinity, ease: "easeInOut" }}
/>
<motion.div
className="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] bg-liquid-rose/20 dark:bg-liquid-rose/10 rounded-full blur-[120px]"
animate={{
scale: [1.2, 1, 1.2],
x: [0, -50, 0],
y: [0, -30, 0],
}}
transition={{ duration: 18, repeat: Infinity, ease: "easeInOut" }}
/>
</div>
</div>
</div>
{/* Quick Action Buttons */}
<div className="mt-6 grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="relative z-10 max-w-2xl w-full px-6 text-center">
{/* Large 404 with Liquid Animation */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, ease: [0.25, 0.1, 0.25, 1] }}
className="relative inline-block mb-8"
>
<h1 className="text-[12rem] md:text-[16rem] font-black text-stone-900/5 dark:text-stone-100/5 select-none leading-none">
404
</h1>
<motion.div
className="absolute inset-0 flex items-center justify-center"
animate={{ y: [0, -10, 0] }}
transition={{ duration: 4, repeat: Infinity, ease: "easeInOut" }}
>
<Ghost size={120} className="text-stone-800 dark:text-stone-200 opacity-80" />
</motion.div>
</motion.div>
{/* Content Card */}
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.2, ease: [0.25, 0.1, 0.25, 1] }}
className="bg-white/40 dark:bg-stone-900/40 backdrop-blur-2xl border border-white/60 dark:border-white/10 rounded-[2.5rem] p-8 md:p-12 shadow-[0_20px_50px_rgba(0,0,0,0.05)] dark:shadow-none"
>
<h2 className="text-3xl md:text-4xl font-bold text-stone-900 dark:text-stone-50 mb-4 font-sans tracking-tight">
Lost in the Liquid.
</h2>
<p className="text-stone-600 dark:text-stone-400 text-lg md:text-xl font-light leading-relaxed mb-10 max-w-md mx-auto">
The page you are looking for has evaporated or never existed in this dimension.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Link
href="/"
className="flex items-center justify-center gap-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]" />
<span className="text-[#3e2723] font-medium">Home</span>
<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-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]" />
<span className="text-[#3e2723] font-medium">Go Back</span>
<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="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]" />
<span className="text-[#3e2723] font-medium">Explore Projects</span>
<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
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>
);

View File

@@ -659,3 +659,112 @@ export async function getProjects(
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-syntax-highlighter": "^15.5.11",
"@types/sanitize-html": "^2.16.0",
"autoprefixer": "^10.4.20",
"autoprefixer": "^10.4.24",
"cross-env": "^7.0.3",
"eslint": "^9",
"eslint-config-next": "^15.5.7",
@@ -69,7 +69,7 @@
"jest-environment-jsdom": "^29.7.0",
"nodemailer-mock": "^2.0.9",
"playwright": "^1.57.0",
"postcss": "^8.4.49",
"postcss": "^8.5.6",
"prisma": "^5.22.0",
"tailwindcss": "^3.4.17",
"ts-jest": "^29.4.6",
@@ -6961,9 +6961,9 @@
}
},
"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==",
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
"dev": true,
"license": "MIT"
},
@@ -14527,9 +14527,9 @@
}
},
"node_modules/postcss-load-config": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
"integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz",
"integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==",
"dev": true,
"funding": [
{
@@ -14543,28 +14543,21 @@
],
"license": "MIT",
"dependencies": {
"lilconfig": "^3.1.1"
"lilconfig": "^3.0.0",
"yaml": "^2.3.4"
},
"engines": {
"node": ">= 18"
"node": ">= 14"
},
"peerDependencies": {
"jiti": ">=1.21.0",
"postcss": ">=8.0.9",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
"ts-node": ">=9.0.0"
},
"peerDependenciesMeta": {
"jiti": {
"optional": true
},
"postcss": {
"optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"ts-node": {
"optional": true
}
}
@@ -16530,9 +16523,9 @@
}
},
"node_modules/tailwindcss": {
"version": "3.4.19",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
"version": "3.4.17",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
"integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -16544,7 +16537,7 @@
"fast-glob": "^3.3.2",
"glob-parent": "^6.0.2",
"is-glob": "^4.0.3",
"jiti": "^1.21.7",
"jiti": "^1.21.6",
"lilconfig": "^3.1.3",
"micromatch": "^4.0.8",
"normalize-path": "^3.0.0",
@@ -16553,7 +16546,7 @@
"postcss": "^8.4.47",
"postcss-import": "^15.1.0",
"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-selector-parser": "^6.1.2",
"resolve": "^1.22.8",
@@ -16567,6 +16560,13 @@
"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": {
"version": "3.3.3",
"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": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
@@ -18222,6 +18215,22 @@
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"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": {
"version": "17.7.2",
"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-syntax-highlighter": "^15.5.11",
"@types/sanitize-html": "^2.16.0",
"autoprefixer": "^10.4.20",
"autoprefixer": "^10.4.24",
"cross-env": "^7.0.3",
"eslint": "^9",
"eslint-config-next": "^15.5.7",
@@ -113,7 +113,7 @@
"jest-environment-jsdom": "^29.7.0",
"nodemailer-mock": "^2.0.9",
"playwright": "^1.57.0",
"postcss": "^8.4.49",
"postcss": "^8.5.6",
"prisma": "^5.22.0",
"tailwindcss": "^3.4.17",
"ts-jest": "^29.4.6",