diff --git a/app/[locale]/projects/[slug]/page.tsx b/app/[locale]/projects/[slug]/page.tsx index a986ca3..a7eac0a 100644 --- a/app/[locale]/projects/[slug]/page.tsx +++ b/app/[locale]/projects/[slug]/page.tsx @@ -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 { const { locale, slug } = await params; + + // Try Directus first for metadata + const directusProject = await getProjectBySlug(slug, locale); + if (directusProject) { + return { + title: directusProject.title, + description: directusProject.description, + alternates: { + canonical: toAbsoluteUrl(`/${locale}/projects/${slug}`), + languages: getLanguageAlternates({ pathWithoutLocale: `projects/${slug}` }), + }, + }; + } + const languages = getLanguageAlternates({ pathWithoutLocale: `projects/${slug}` }); return { alternates: { @@ -28,7 +43,8 @@ export default async function ProjectPage({ }) { const { locale, slug } = await params; - const project = await prisma.project.findFirst({ + // Try PostgreSQL first + const dbProject = await prisma.project.findFirst({ where: { slug, published: true }, include: { translations: { @@ -37,43 +53,56 @@ export default async function ProjectPage({ }, }); - if (!project) return notFound(); + let projectData: any = null; - const trPreferred = project.translations?.find((t) => t.locale === locale && (t?.title || t?.description)); - const trDefault = project.translations?.find( - (t) => t.locale === project.defaultLocale && (t?.title || t?.description), - ); - const tr = trPreferred ?? trDefault; - const { translations: _translations, ...rest } = project; - const localizedContent = (() => { - if (typeof tr?.content === "string") return tr.content; - if (tr?.content && typeof tr.content === "object" && "markdown" in tr.content) { - const markdown = (tr.content as Record).markdown; - if (typeof markdown === "string") return markdown; + if (dbProject) { + const trPreferred = dbProject.translations?.find((t) => t.locale === locale && (t?.title || t?.description)); + const trDefault = dbProject.translations?.find( + (t) => t.locale === dbProject.defaultLocale && (t?.title || t?.description), + ); + const tr = trPreferred ?? trDefault; + const { translations: _translations, ...rest } = dbProject; + const localizedContent = (() => { + if (typeof tr?.content === "string") return tr.content; + if (tr?.content && typeof tr.content === "object" && "markdown" in tr.content) { + const markdown = (tr.content as Record).markdown; + if (typeof markdown === "string") return markdown; + } + return dbProject.content; + })(); + projectData = { + ...rest, + title: tr?.title ?? dbProject.title, + description: tr?.description ?? dbProject.description, + content: localizedContent, + }; + } else { + // Try Directus fallback + const directusProject = await getProjectBySlug(slug, locale); + if (directusProject) { + projectData = { + ...directusProject, + id: parseInt(directusProject.id) || 0, + }; } - return project.content; - })(); - const localized = { - ...rest, - title: tr?.title ?? project.title, - description: tr?.description ?? project.description, - content: localizedContent, - }; + } + + if (!projectData) return notFound(); const jsonLd = { "@context": "https://schema.org", "@type": "SoftwareSourceCode", - "name": localized.title, - "description": localized.description, - "codeRepository": localized.github, - "programmingLanguage": localized.technologies, + "name": projectData.title, + "description": projectData.description, + "codeRepository": projectData.github_url || projectData.github, + "programmingLanguage": projectData.technologies, "author": { "@type": "Person", "name": "Dennis Konkol" }, - "dateCreated": project.date, + "dateCreated": projectData.date || projectData.created_at, "url": toAbsoluteUrl(`/${locale}/projects/${slug}`), - "image": localized.imageUrl ? toAbsoluteUrl(localized.imageUrl) : undefined, + "image": projectData.imageUrl || projectData.image_url ? toAbsoluteUrl(projectData.imageUrl || projectData.image_url) : undefined, }; return ( @@ -82,7 +111,7 @@ export default async function ProjectPage({ type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} /> - + ); } diff --git a/app/[locale]/projects/page.tsx b/app/[locale]/projects/page.tsx index f194b43..0db8dac 100644 --- a/app/[locale]/projects/page.tsx +++ b/app/[locale]/projects/page.tsx @@ -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 ; + // 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 ; } diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts index c64262f..11a1ed0 100644 --- a/app/api/projects/route.ts +++ b/app/api/projects/route.ts @@ -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 = {}; if (category) where.category = category; @@ -116,12 +97,11 @@ export async function GET(request: NextRequest) { where.OR = [ { title: { contains: search, mode: 'insensitive' } }, { description: { contains: search, mode: 'insensitive' } }, - { tags: { hasSome: [search] } }, - { content: { contains: search, mode: 'insensitive' } } + { tags: { hasSome: [search] } } ]; } - const [projects, total] = await Promise.all([ + const [dbProjects, total] = await Promise.all([ prisma.project.findMany({ where, orderBy: { createdAt: 'desc' }, @@ -131,20 +111,21 @@ export async function GET(request: NextRequest) { prisma.project.count({ where }) ]); - const result = { - projects, - total, - pages: Math.ceil(total / limit), - currentPage: page, - source: 'postgresql' - }; - - // Cache the result (only for non-search queries) - if (!search) { - await apiCache.setProjects(cacheParams, result); + // Merge logic + const dbSlugs = new Set(dbProjects.map(p => p.slug)); + const mergedProjects = [...dbProjects]; + + for (const dp of directusProjects) { + if (!dbSlugs.has(dp.slug)) { + mergedProjects.push(dp); + } } - return NextResponse.json(result); + return NextResponse.json({ + projects: mergedProjects, + total: total + (mergedProjects.length - dbProjects.length), + source: 'merged' + }); } catch (error) { // Handle missing database table gracefully if (error instanceof PrismaClientKnownRequestError && error.code === 'P2021') { diff --git a/app/not-found.tsx b/app/not-found.tsx index 259f0e8..1bf0115 100644 --- a/app/not-found.tsx +++ b/app/not-found.tsx @@ -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 ( -
- Oops! The page you're looking for doesn't exist. -
- ); - } - - if (!mounted) { - return ( -
-
-
Loading...
-
-
- ); - } - - 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 ( -
-
- {/* Terminal-style 404 */} -
- {/* Terminal Header */} -
-
-
-
-
-
-
- terminal@portfolio ~ 404 -
+
+ {/* Liquid Background Blobs */} + + + +
+ {/* Large 404 with Liquid Animation */} + +

+ 404 +

+ + + +
+ + {/* Content Card */} + +

+ Lost in the Liquid. +

+

+ The page you are looking for has evaporated or never existed in this dimension. +

+ +
+ + + Back Home + + +
- {/* Terminal Body */} -
-
-
$ cd {mounted ? window.location.pathname : '/unknown'}
-
- - Error: ENOENT: no such file or directory -
-
-
-{`
-  ██╗  ██╗ ██████╗ ██╗  ██╗
-  ██║  ██║██╔═████╗██║  ██║
-  ███████║██║██╔██║███████║
-  ╚════██║████╔╝██║╚════██║
-       ██║╚██████╔╝     ██║
-       ╚═╝ ╚═════╝      ╚═╝
-`}
-                
-
- -
-

The page you're looking for seems to have wandered off.

-

Perhaps it never existed, or maybe it's on a coffee break.

-
- -
-
Available commands:
-
-
home - Return to homepage
-
back - Go back to previous page
-
search - Search the website
-
-
-
- - {/* Interactive Command Line */} -
- $ - 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 - /> -
+
+ + + Looking for my work? Explore projects +
-
+
- {/* Quick Action Buttons */} -
- + - - - - Explore Projects - -
+
); diff --git a/lib/directus.ts b/lib/directus.ts index b4e0956..c115079 100644 --- a/lib/directus.ts +++ b/lib/directus.ts @@ -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 { + 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; + } +} diff --git a/package-lock.json b/package-lock.json index 5287bc1..149ba8b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 78e8787..6f59f4b 100644 --- a/package.json +++ b/package.json @@ -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",