diff --git a/GEMINI.md b/GEMINI.md
new file mode 100644
index 0000000..f582442
--- /dev/null
+++ b/GEMINI.md
@@ -0,0 +1,47 @@
+# GEMINI.md - Portfolio Project Guide
+
+## Project Overview
+Personal portfolio for Dennis Konkol (dk0.dev). A modern, high-performance Next.js 15 application featuring a "liquid" design system, integrated with Directus CMS and n8n for real-time status and content management.
+
+## Tech Stack & Architecture
+- **Framework**: Next.js 15 (App Router), TypeScript, React 19.
+- **UI/UX**: Tailwind CSS 3.4, Framer Motion 12, Three.js (Background).
+- **Backend/Data**: PostgreSQL (Prisma), Redis (Caching), Directus (CMS), n8n (Automation).
+- **i18n**: next-intl (German/English).
+
+## Core Principles for Gemini
+- **Safe Failovers**: Always implement fallbacks for external APIs (Directus, n8n). The site must remain functional even if all external services are down.
+- **Liquid Design**: Use custom `liquid-*` color tokens for consistency.
+- **Performance**: Favor Server Components where possible; use `use client` only for interactivity.
+- **Code Style**: clean, modular, and well-typed. Use functional components and hooks.
+- **i18n first**: Never hardcode user-facing strings; always use `messages/*.json`.
+
+## Common Workflows
+
+### API Route Pattern
+API routes should include:
+- Rate limiting (via `lib/auth.ts`)
+- Timeout protection
+- Proper error handling with logging in development
+- Type-safe responses
+
+### Component Pattern
+- Use Framer Motion for entrance animations.
+- Use `next/image` for all images to ensure optimization.
+- Follow the `glassmorphism` aesthetic: `backdrop-blur-sm`, subtle borders, and gradient backgrounds.
+
+## Development Commands
+- `npm run dev`: Full development environment.
+- `npm run lint`: Run ESLint checks.
+- `npm run test`: Run unit tests.
+- `npm run test:e2e`: Run Playwright E2E tests.
+
+## Environment Variables (Key)
+- `DIRECTUS_URL` & `DIRECTUS_STATIC_TOKEN`: CMS connectivity.
+- `N8N_WEBHOOK_URL` & `N8N_SECRET_TOKEN`: Automation connectivity.
+- `DATABASE_URL`: Prisma connection string.
+
+## Git Workflow
+- Work on the `dev` branch.
+- Use conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`.
+- Push to both GitHub and Gitea remotes.
diff --git a/app/[locale]/projects/[slug]/page.tsx b/app/[locale]/projects/[slug]/page.tsx
index 9311494..a986ca3 100644
--- a/app/[locale]/projects/[slug]/page.tsx
+++ b/app/[locale]/projects/[slug]/page.tsx
@@ -60,6 +60,30 @@ export default async function ProjectPage({
content: localizedContent,
};
- return ;
+ const jsonLd = {
+ "@context": "https://schema.org",
+ "@type": "SoftwareSourceCode",
+ "name": localized.title,
+ "description": localized.description,
+ "codeRepository": localized.github,
+ "programmingLanguage": localized.technologies,
+ "author": {
+ "@type": "Person",
+ "name": "Dennis Konkol"
+ },
+ "dateCreated": project.date,
+ "url": toAbsoluteUrl(`/${locale}/projects/${slug}`),
+ "image": localized.imageUrl ? toAbsoluteUrl(localized.imageUrl) : undefined,
+ };
+
+ return (
+ <>
+
+
+ >
+ );
}
diff --git a/app/__tests__/api/book-reviews.test.tsx b/app/__tests__/api/book-reviews.test.tsx
new file mode 100644
index 0000000..3a40968
--- /dev/null
+++ b/app/__tests__/api/book-reviews.test.tsx
@@ -0,0 +1,69 @@
+import { NextResponse } from 'next/server';
+import { GET } from '@/app/api/book-reviews/route';
+import { getBookReviews } from '@/lib/directus';
+
+jest.mock('@/lib/directus', () => ({
+ getBookReviews: jest.fn(),
+}));
+
+jest.mock('next/server', () => ({
+ NextRequest: jest.fn((url) => ({
+ url,
+ })),
+ NextResponse: {
+ json: jest.fn((data, options) => ({
+ json: async () => data,
+ status: options?.status || 200,
+ })),
+ },
+}));
+
+describe('GET /api/book-reviews', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should return book reviews from Directus', async () => {
+ const mockReviews = [
+ {
+ id: '1',
+ book_title: 'Test Book',
+ book_author: 'Test Author',
+ rating: 5,
+ review: 'Great book!',
+ },
+ ];
+
+ (getBookReviews as jest.Mock).mockResolvedValue(mockReviews);
+
+ const request = {
+ url: 'http://localhost/api/book-reviews?locale=en',
+ } as any;
+
+ await GET(request);
+
+ expect(NextResponse.json).toHaveBeenCalledWith(
+ expect.objectContaining({
+ bookReviews: mockReviews,
+ source: 'directus',
+ })
+ );
+ });
+
+ it('should return fallback when no reviews found', async () => {
+ (getBookReviews as jest.Mock).mockResolvedValue(null);
+
+ const request = {
+ url: 'http://localhost/api/book-reviews?locale=en',
+ } as any;
+
+ await GET(request);
+
+ expect(NextResponse.json).toHaveBeenCalledWith(
+ expect.objectContaining({
+ bookReviews: null,
+ source: 'fallback',
+ })
+ );
+ });
+});
diff --git a/app/__tests__/api/hobbies.test.tsx b/app/__tests__/api/hobbies.test.tsx
new file mode 100644
index 0000000..aeb8cc7
--- /dev/null
+++ b/app/__tests__/api/hobbies.test.tsx
@@ -0,0 +1,69 @@
+import { NextResponse } from 'next/server';
+import { GET } from '@/app/api/hobbies/route';
+import { getHobbies } from '@/lib/directus';
+
+jest.mock('@/lib/directus', () => ({
+ getHobbies: jest.fn(),
+}));
+
+jest.mock('next/server', () => ({
+ NextRequest: jest.fn((url) => ({
+ url,
+ })),
+ NextResponse: {
+ json: jest.fn((data, options) => ({
+ json: async () => data,
+ status: options?.status || 200,
+ })),
+ },
+}));
+
+describe('GET /api/hobbies', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should return hobbies from Directus', async () => {
+ const mockHobbies = [
+ {
+ id: '1',
+ key: 'coding',
+ icon: 'Code',
+ title: 'Coding',
+ description: 'I love coding',
+ },
+ ];
+
+ (getHobbies as jest.Mock).mockResolvedValue(mockHobbies);
+
+ const request = {
+ url: 'http://localhost/api/hobbies?locale=en',
+ } as any;
+
+ await GET(request);
+
+ expect(NextResponse.json).toHaveBeenCalledWith(
+ expect.objectContaining({
+ hobbies: mockHobbies,
+ source: 'directus',
+ })
+ );
+ });
+
+ it('should return fallback when no hobbies found', async () => {
+ (getHobbies as jest.Mock).mockResolvedValue(null);
+
+ const request = {
+ url: 'http://localhost/api/hobbies?locale=en',
+ } as any;
+
+ await GET(request);
+
+ expect(NextResponse.json).toHaveBeenCalledWith(
+ expect.objectContaining({
+ hobbies: null,
+ source: 'fallback',
+ })
+ );
+ });
+});
diff --git a/app/__tests__/api/tech-stack.test.tsx b/app/__tests__/api/tech-stack.test.tsx
new file mode 100644
index 0000000..d1ec32e
--- /dev/null
+++ b/app/__tests__/api/tech-stack.test.tsx
@@ -0,0 +1,71 @@
+import { NextResponse } from 'next/server';
+import { GET } from '@/app/api/tech-stack/route';
+import { getTechStack } from '@/lib/directus';
+
+jest.mock('@/lib/directus', () => ({
+ getTechStack: jest.fn(),
+}));
+
+jest.mock('next/server', () => ({
+ NextRequest: jest.fn((url) => ({
+ url,
+ })),
+ NextResponse: {
+ json: jest.fn((data, options) => ({
+ json: async () => data,
+ status: options?.status || 200,
+ })),
+ },
+}));
+
+describe('GET /api/tech-stack', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should return tech stack from Directus', async () => {
+ const mockTechStack = [
+ {
+ id: '1',
+ key: 'frontend',
+ icon: 'Globe',
+ name: 'Frontend',
+ items: [
+ { id: '1-1', name: 'React' }
+ ],
+ },
+ ];
+
+ (getTechStack as jest.Mock).mockResolvedValue(mockTechStack);
+
+ const request = {
+ url: 'http://localhost/api/tech-stack?locale=en',
+ } as any;
+
+ await GET(request);
+
+ expect(NextResponse.json).toHaveBeenCalledWith(
+ expect.objectContaining({
+ techStack: mockTechStack,
+ source: 'directus',
+ })
+ );
+ });
+
+ it('should return fallback when no tech stack found', async () => {
+ (getTechStack as jest.Mock).mockResolvedValue(null);
+
+ const request = {
+ url: 'http://localhost/api/tech-stack?locale=en',
+ } as any;
+
+ await GET(request);
+
+ expect(NextResponse.json).toHaveBeenCalledWith(
+ expect.objectContaining({
+ techStack: null,
+ source: 'fallback',
+ })
+ );
+ });
+});
diff --git a/app/_ui/ProjectDetailClient.tsx b/app/_ui/ProjectDetailClient.tsx
index a3367aa..dcb47e2 100644
--- a/app/_ui/ProjectDetailClient.tsx
+++ b/app/_ui/ProjectDetailClient.tsx
@@ -6,6 +6,7 @@ import Link from "next/link";
import { useEffect } from "react";
import ReactMarkdown from "react-markdown";
import { useTranslations } from "next-intl";
+import Image from "next/image";
export type ProjectDetailData = {
id: number;
@@ -130,8 +131,14 @@ export default function ProjectDetailClient({
className="mb-16 rounded-2xl overflow-hidden shadow-2xl bg-stone-100 aspect-video relative"
>
{project.imageUrl ? (
- // eslint-disable-next-line @next/next/no-img-element
-
+
) : (
diff --git a/app/components/ClientProviders.tsx b/app/components/ClientProviders.tsx
index 406b600..4bc9260 100644
--- a/app/components/ClientProviders.tsx
+++ b/app/components/ClientProviders.tsx
@@ -7,6 +7,7 @@ import { ToastProvider } from "@/components/Toast";
import ErrorBoundary from "@/components/ErrorBoundary";
import { AnalyticsProvider } from "@/components/AnalyticsProvider";
import { ConsentProvider, useConsent } from "./ConsentProvider";
+import { ThemeProvider } from "./ThemeProvider";
// Dynamic import with SSR disabled to avoid framer-motion issues
const BackgroundBlobs = dynamic(() => import("@/components/BackgroundBlobs").catch(() => ({ default: () => null })), {
@@ -72,9 +73,11 @@ export default function ClientProviders({
-
- {children}
-
+
+
+ {children}
+
+
diff --git a/app/components/CurrentlyReading.tsx b/app/components/CurrentlyReading.tsx
index 2c18486..ad340e2 100644
--- a/app/components/CurrentlyReading.tsx
+++ b/app/components/CurrentlyReading.tsx
@@ -4,6 +4,7 @@ import { motion } from "framer-motion";
import { BookOpen } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
+import Image from "next/image";
interface CurrentlyReading {
title: string;
@@ -107,11 +108,12 @@ const CurrentlyReading = () => {
className="flex-shrink-0"
>
-

{/* Glossy Overlay */}
diff --git a/app/components/Header.tsx b/app/components/Header.tsx
index 62e1ea7..d452abb 100644
--- a/app/components/Header.tsx
+++ b/app/components/Header.tsx
@@ -7,6 +7,7 @@ import { SiGithub, SiLinkedin } from "react-icons/si";
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
import { usePathname, useSearchParams } from "next/navigation";
+import { ThemeToggle } from "./ThemeToggle";
const Header = () => {
const [isOpen, setIsOpen] = useState(false);
@@ -155,6 +156,7 @@ const Header = () => {
DE
+
{socialLinks.map((social) => (
{
))}
-
+
+
{socialLinks.map((social, index) => (
{
];
return (
-
+
{/* Profile Image with Organic Blob Mask */}
{
{/* Large Rotating Liquid Blobs behind image - Very slow and smooth */}
{
}}
/>
{
{/* The Image Container with Organic Border Radius */}
{
transition={{ duration: 0.6, delay: 0.3, ease: "easeOut" }}
className="absolute -bottom-8 left-1/2 -translate-x-1/2 z-30"
>
-
+
dk0.dev
@@ -144,7 +144,7 @@ const Hero = () => {
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.4, duration: 0.5, ease: "easeOut" }}
whileHover={{ scale: 1.1, rotate: 5 }}
- className="absolute -top-4 right-0 md:-right-4 p-3 bg-white/95 backdrop-blur-md shadow-lg rounded-full text-stone-700 z-30"
+ className="absolute -top-4 right-0 md:-right-4 p-3 bg-white/95 dark:bg-stone-800/95 backdrop-blur-md shadow-lg rounded-full text-stone-700 dark:text-stone-300 z-30"
>
@@ -153,7 +153,7 @@ const Hero = () => {
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.5, duration: 0.5, ease: "easeOut" }}
whileHover={{ scale: 1.1, rotate: -5 }}
- className="absolute bottom-4 -left-4 md:-left-8 p-3 bg-white/95 backdrop-blur-md shadow-lg rounded-full text-stone-700 z-30"
+ className="absolute bottom-4 -left-4 md:-left-8 p-3 bg-white/95 dark:bg-stone-800/95 backdrop-blur-md shadow-lg rounded-full text-stone-700 dark:text-stone-300 z-30"
>
@@ -167,10 +167,10 @@ const Hero = () => {
transition={{ duration: 0.6, delay: 0.2, ease: [0.25, 0.1, 0.25, 1] }}
className="mb-8 flex flex-col items-center justify-center relative"
>
-
+
Dennis Konkol
-
+
Software Engineer
@@ -180,10 +180,10 @@ const Hero = () => {
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.3, ease: [0.25, 0.1, 0.25, 1] }}
- className="text-lg md:text-xl text-stone-700 mb-12 max-w-2xl mx-auto leading-relaxed"
+ className="text-lg md:text-xl text-stone-700 dark:text-stone-300 mb-12 max-w-2xl mx-auto leading-relaxed"
>
{cmsDoc ? (
-
+
) : (
{t("description")}
)}
@@ -207,10 +207,10 @@ const Hero = () => {
ease: [0.25, 0.1, 0.25, 1],
}}
whileHover={{ scale: 1.03, y: -3 }}
- className="flex items-center space-x-2 px-5 py-2.5 rounded-full bg-white/85 border-2 border-stone-300 shadow-md backdrop-blur-lg"
+ className="flex items-center space-x-2 px-5 py-2.5 rounded-full bg-white/85 dark:bg-stone-800/85 border-2 border-stone-300 dark:border-stone-700 shadow-md backdrop-blur-lg"
>
-
-
+
+
{feature.text}
diff --git a/app/components/ReadBooks.tsx b/app/components/ReadBooks.tsx
index 1a8756c..0327137 100644
--- a/app/components/ReadBooks.tsx
+++ b/app/components/ReadBooks.tsx
@@ -4,6 +4,7 @@ import { motion } from "framer-motion";
import { BookCheck, Star, ChevronDown, ChevronUp } from "lucide-react";
import { useEffect, useState } from "react";
import { useLocale, useTranslations } from "next-intl";
+import Image from "next/image";
interface BookReview {
id: string;
@@ -134,11 +135,12 @@ const ReadBooks = () => {
className="flex-shrink-0"
>
-
diff --git a/app/components/ThemeProvider.tsx b/app/components/ThemeProvider.tsx
new file mode 100644
index 0000000..189a2b1
--- /dev/null
+++ b/app/components/ThemeProvider.tsx
@@ -0,0 +1,11 @@
+"use client";
+
+import * as React from "react";
+import { ThemeProvider as NextThemesProvider } from "next-themes";
+
+export function ThemeProvider({
+ children,
+ ...props
+}: React.ComponentProps) {
+ return {children};
+}
diff --git a/app/components/ThemeToggle.tsx b/app/components/ThemeToggle.tsx
new file mode 100644
index 0000000..0d61707
--- /dev/null
+++ b/app/components/ThemeToggle.tsx
@@ -0,0 +1,35 @@
+"use client";
+
+import * as React from "react";
+import { Moon, Sun } from "lucide-react";
+import { useTheme } from "next-themes";
+import { motion } from "framer-motion";
+
+export function ThemeToggle() {
+ const { theme, setTheme } = useTheme();
+ const [mounted, setMounted] = React.useState(false);
+
+ React.useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ if (!mounted) {
+ return ;
+ }
+
+ return (
+ setTheme(theme === "dark" ? "light" : "dark")}
+ className="p-2 rounded-full bg-stone-100 dark:bg-stone-800 text-stone-800 dark:text-stone-100 hover:bg-stone-200 dark:hover:bg-stone-700 transition-colors border border-stone-200 dark:border-stone-700 shadow-sm"
+ aria-label="Toggle theme"
+ >
+ {theme === "dark" ? (
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/app/globals.css b/app/globals.css
index face5e2..c34ce66 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -26,8 +26,30 @@
--radius: 1rem;
}
+.dark {
+ --background: #1c1917; /* stone-900 */
+ --foreground: #f5f5f4; /* stone-100 */
+ --card: rgba(28, 25, 23, 0.7);
+ --card-foreground: #f5f5f4;
+ --popover: #1c1917;
+ --popover-foreground: #f5f5f4;
+ --primary: #d6d3d1; /* stone-300 */
+ --primary-foreground: #1c1917;
+ --secondary: #44403c; /* stone-700 */
+ --secondary-foreground: #f5f5f4;
+ --muted: #292524; /* stone-800 */
+ --muted-foreground: #a8a29e; /* stone-400 */
+ --accent: #57534e; /* stone-600 */
+ --accent-foreground: #f5f5f4;
+ --destructive: #7f1d1d; /* dark red */
+ --destructive-foreground: #f5f5f4;
+ --border: #44403c;
+ --input: #292524;
+ --ring: #d6d3d1;
+}
+
body {
- background: linear-gradient(135deg, rgba(250, 248, 243, 0.95) 0%, rgba(250, 248, 243, 0.92) 100%);
+ background: var(--background);
color: var(--foreground);
font-family: "Inter", sans-serif;
margin: 0;
@@ -37,6 +59,7 @@ body {
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
position: relative;
+ transition: background-color 0.3s ease, color 0.3s ease;
}
/* Custom Selection */
@@ -52,35 +75,33 @@ html {
/* Liquid Glass Effects */
.glass-panel {
- background: rgba(250, 248, 243, 0.75);
+ background: var(--card);
backdrop-filter: blur(20px) saturate(130%);
-webkit-backdrop-filter: blur(20px) saturate(130%);
- border: 1px solid rgba(215, 204, 200, 0.6);
- box-shadow: 0 8px 32px rgba(62, 39, 35, 0.12);
+ border: 1px solid var(--border);
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
will-change: backdrop-filter;
}
.glass-card {
- background: rgba(255, 252, 245, 0.85);
+ background: var(--card);
backdrop-filter: blur(30px) saturate(200%);
-webkit-backdrop-filter: blur(30px) saturate(200%);
- border: 1px solid rgba(215, 204, 200, 0.7);
+ border: 1px solid var(--border);
box-shadow:
- 0 4px 6px -1px rgba(62, 39, 35, 0.06),
- 0 2px 4px -1px rgba(62, 39, 35, 0.05),
- inset 0 0 30px rgba(255, 252, 245, 0.6);
+ 0 4px 6px -1px rgba(0, 0, 0, 0.06),
+ 0 2px 4px -1px rgba(0, 0, 0, 0.05);
transition: all 0.6s cubic-bezier(0.25, 0.1, 0.25, 1);
will-change: transform, box-shadow;
}
.glass-card:hover {
- background: rgba(255, 252, 245, 0.95);
+ background: var(--card);
box-shadow:
- 0 20px 25px -5px rgba(62, 39, 35, 0.15),
- 0 10px 10px -5px rgba(62, 39, 35, 0.08),
- inset 0 0 30px rgba(255, 252, 245, 0.9);
+ 0 20px 25px -5px rgba(0, 0, 0, 0.15),
+ 0 10px 10px -5px rgba(0, 0, 0, 0.08);
transform: translateY(-4px);
- border-color: rgba(215, 204, 200, 0.9);
+ border-color: var(--ring);
}
/* Typography & Headings */
@@ -93,7 +114,7 @@ h6 {
font-family: var(--font-playfair), Georgia, serif;
letter-spacing: -0.02em;
font-weight: 700;
- color: #3e2723;
+ color: var(--foreground);
}
/* Improve text contrast - using foreground variable for WCAG AA compliance */
@@ -154,34 +175,34 @@ div {
/* Markdown Specifics for Blog/Projects */
.markdown h1 {
@apply text-4xl font-bold mb-6 tracking-tight;
- color: #3e2723;
+ color: var(--foreground);
}
.markdown h2 {
@apply text-2xl font-semibold mt-8 mb-4 tracking-tight;
- color: #3e2723;
+ color: var(--foreground);
}
.markdown p {
@apply mb-4 leading-relaxed;
- color: #4e342e;
+ color: var(--foreground);
}
.markdown a {
@apply underline decoration-2 underline-offset-2 hover:opacity-80 transition-colors duration-300;
- color: #5d4037;
- text-decoration-color: #a1887f;
+ color: var(--primary);
+ text-decoration-color: var(--accent);
}
.markdown ul {
@apply list-disc list-inside mb-4 space-y-2;
- color: #4e342e;
+ color: var(--foreground);
}
.markdown code {
@apply px-1.5 py-0.5 rounded text-sm font-mono;
- background: #efebe9;
- color: #3e2723;
+ background: var(--muted);
+ color: var(--foreground);
}
.markdown pre {
@apply p-4 rounded-xl overflow-x-auto mb-6;
- background: #3e2723;
- color: #faf8f3;
+ background: var(--foreground);
+ color: var(--background);
}
/* Admin Dashboard Styles - Warm Brown Theme */
diff --git a/next.config.ts b/next.config.ts
index 44121fc..5224a12 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -60,6 +60,18 @@ const nextConfig: NextConfig = {
protocol: "https",
hostname: "media.discordapp.net",
},
+ {
+ protocol: "https",
+ hostname: "cms.dk0.dev",
+ },
+ {
+ protocol: "https",
+ hostname: "assets.hardcover.app",
+ },
+ {
+ protocol: "https",
+ hostname: "dki.one",
+ },
],
},
diff --git a/package-lock.json b/package-lock.json
index 53320a7..fea6529 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -31,6 +31,7 @@
"lucide-react": "^0.542.0",
"next": "^15.5.7",
"next-intl": "^4.7.0",
+ "next-themes": "^0.4.6",
"node-cache": "^5.1.2",
"node-fetch": "^2.7.0",
"nodemailer": "^7.0.11",
@@ -14488,6 +14489,16 @@
}
}
},
+ "node_modules/next-themes": {
+ "version": "0.4.6",
+ "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
+ "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
+ }
+ },
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
diff --git a/package.json b/package.json
index 85f9701..11c710e 100644
--- a/package.json
+++ b/package.json
@@ -75,6 +75,7 @@
"lucide-react": "^0.542.0",
"next": "^15.5.7",
"next-intl": "^4.7.0",
+ "next-themes": "^0.4.6",
"node-cache": "^5.1.2",
"node-fetch": "^2.7.0",
"nodemailer": "^7.0.11",