feat: implement dark mode infrastructure, optimize images, and add SEO structured data
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Failing after 10m16s

This commit is contained in:
2026-02-15 22:20:43 +01:00
parent 92e5b4936e
commit 0766b46cc8
17 changed files with 440 additions and 52 deletions

47
GEMINI.md Normal file
View File

@@ -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.

View File

@@ -60,6 +60,30 @@ export default async function ProjectPage({
content: localizedContent, content: localizedContent,
}; };
return <ProjectDetailClient project={localized} locale={locale} />; 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 (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<ProjectDetailClient project={localized} locale={locale} />
</>
);
} }

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ import Link from "next/link";
import { useEffect } from "react"; import { useEffect } from "react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import Image from "next/image";
export type ProjectDetailData = { export type ProjectDetailData = {
id: number; 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" className="mb-16 rounded-2xl overflow-hidden shadow-2xl bg-stone-100 aspect-video relative"
> >
{project.imageUrl ? ( {project.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element <Image
<img src={project.imageUrl} alt={project.title} className="w-full h-full object-cover" /> src={project.imageUrl}
alt={project.title}
fill
className="object-cover"
priority
sizes="(max-width: 896px) 100vw, 896px"
/>
) : ( ) : (
<div className="absolute inset-0 bg-gradient-to-br from-stone-200 to-stone-300 flex items-center justify-center"> <div className="absolute inset-0 bg-gradient-to-br from-stone-200 to-stone-300 flex items-center justify-center">
<span className="text-9xl font-serif font-bold text-stone-500/20 select-none"> <span className="text-9xl font-serif font-bold text-stone-500/20 select-none">

View File

@@ -7,6 +7,7 @@ import { ToastProvider } from "@/components/Toast";
import ErrorBoundary from "@/components/ErrorBoundary"; import ErrorBoundary from "@/components/ErrorBoundary";
import { AnalyticsProvider } from "@/components/AnalyticsProvider"; import { AnalyticsProvider } from "@/components/AnalyticsProvider";
import { ConsentProvider, useConsent } from "./ConsentProvider"; import { ConsentProvider, useConsent } from "./ConsentProvider";
import { ThemeProvider } from "./ThemeProvider";
// Dynamic import with SSR disabled to avoid framer-motion issues // Dynamic import with SSR disabled to avoid framer-motion issues
const BackgroundBlobs = dynamic(() => import("@/components/BackgroundBlobs").catch(() => ({ default: () => null })), { const BackgroundBlobs = dynamic(() => import("@/components/BackgroundBlobs").catch(() => ({ default: () => null })), {
@@ -72,9 +73,11 @@ export default function ClientProviders({
<ErrorBoundary> <ErrorBoundary>
<ErrorBoundary> <ErrorBoundary>
<ConsentProvider> <ConsentProvider>
<GatedProviders mounted={mounted} is404Page={is404Page}> <ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
{children} <GatedProviders mounted={mounted} is404Page={is404Page}>
</GatedProviders> {children}
</GatedProviders>
</ThemeProvider>
</ConsentProvider> </ConsentProvider>
</ErrorBoundary> </ErrorBoundary>
</ErrorBoundary> </ErrorBoundary>

View File

@@ -4,6 +4,7 @@ import { motion } from "framer-motion";
import { BookOpen } from "lucide-react"; import { BookOpen } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import Image from "next/image";
interface CurrentlyReading { interface CurrentlyReading {
title: string; title: string;
@@ -107,11 +108,12 @@ const CurrentlyReading = () => {
className="flex-shrink-0" className="flex-shrink-0"
> >
<div className="relative w-24 h-36 sm:w-28 sm:h-40 rounded-lg overflow-hidden shadow-lg border-2 border-white/50"> <div className="relative w-24 h-36 sm:w-28 sm:h-40 rounded-lg overflow-hidden shadow-lg border-2 border-white/50">
<img <Image
src={book.image} src={book.image}
alt={book.title} alt={book.title}
className="w-full h-full object-cover" fill
loading="lazy" className="object-cover"
sizes="(max-width: 640px) 96px, 112px"
/> />
{/* Glossy Overlay */} {/* Glossy Overlay */}
<div className="absolute inset-0 bg-gradient-to-tr from-white/20 via-transparent to-white/10 pointer-events-none" /> <div className="absolute inset-0 bg-gradient-to-tr from-white/20 via-transparent to-white/10 pointer-events-none" />

View File

@@ -7,6 +7,7 @@ import { SiGithub, SiLinkedin } from "react-icons/si";
import Link from "next/link"; import Link from "next/link";
import { useLocale, useTranslations } from "next-intl"; import { useLocale, useTranslations } from "next-intl";
import { usePathname, useSearchParams } from "next/navigation"; import { usePathname, useSearchParams } from "next/navigation";
import { ThemeToggle } from "./ThemeToggle";
const Header = () => { const Header = () => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
@@ -155,6 +156,7 @@ const Header = () => {
DE DE
</Link> </Link>
</div> </div>
<ThemeToggle />
{socialLinks.map((social) => ( {socialLinks.map((social) => (
<motion.a <motion.a
key={social.label} key={social.label}
@@ -233,7 +235,8 @@ const Header = () => {
))} ))}
<div className="pt-6 mt-4 border-t border-stone-200"> <div className="pt-6 mt-4 border-t border-stone-200">
<div className="flex justify-center space-x-4"> <div className="flex justify-center items-center space-x-4">
<ThemeToggle />
{socialLinks.map((social, index) => ( {socialLinks.map((social, index) => (
<motion.a <motion.a
key={social.label} key={social.label}

View File

@@ -41,7 +41,7 @@ const Hero = () => {
]; ];
return ( return (
<section className="relative min-h-screen flex items-center justify-center overflow-hidden pt-32 pb-16 bg-gradient-to-br from-liquid-mint/10 via-liquid-lavender/10 to-liquid-rose/10"> <section className="relative min-h-screen flex items-center justify-center overflow-hidden pt-32 pb-16 bg-gradient-to-br from-liquid-mint/10 via-liquid-lavender/10 to-liquid-rose/10 dark:from-stone-900 dark:via-stone-900 dark:to-stone-800 transition-colors duration-500">
<div className="relative z-10 text-center px-4 max-w-5xl mx-auto"> <div className="relative z-10 text-center px-4 max-w-5xl mx-auto">
{/* Profile Image with Organic Blob Mask */} {/* Profile Image with Organic Blob Mask */}
<motion.div <motion.div
@@ -53,7 +53,7 @@ const Hero = () => {
<div className="relative w-64 h-64 md:w-80 md:h-80 flex items-center justify-center"> <div className="relative w-64 h-64 md:w-80 md:h-80 flex items-center justify-center">
{/* Large Rotating Liquid Blobs behind image - Very slow and smooth */} {/* Large Rotating Liquid Blobs behind image - Very slow and smooth */}
<motion.div <motion.div
className="absolute w-[150%] h-[150%] bg-gradient-to-tr from-liquid-mint/40 via-liquid-blue/30 to-liquid-lavender/40 blur-3xl -z-10" className="absolute w-[150%] h-[150%] bg-gradient-to-tr from-liquid-mint/40 via-liquid-blue/30 to-liquid-lavender/40 blur-3xl -z-10 dark:from-liquid-mint/20 dark:via-liquid-blue/15 dark:to-liquid-lavender/20"
animate={{ animate={{
borderRadius: [ borderRadius: [
"60% 40% 30% 70%/60% 30% 70% 40%", "60% 40% 30% 70%/60% 30% 70% 40%",
@@ -71,7 +71,7 @@ const Hero = () => {
}} }}
/> />
<motion.div <motion.div
className="absolute w-[130%] h-[130%] bg-gradient-to-bl from-liquid-rose/35 via-purple-200/25 to-liquid-mint/35 blur-2xl -z-10" className="absolute w-[130%] h-[130%] bg-gradient-to-bl from-liquid-rose/35 via-purple-200/25 to-liquid-mint/35 blur-2xl -z-10 dark:from-liquid-rose/15 dark:via-purple-900/10 dark:to-liquid-mint/15"
animate={{ animate={{
borderRadius: [ borderRadius: [
"40% 60% 70% 30%/40% 50% 60% 50%", "40% 60% 70% 30%/40% 50% 60% 50%",
@@ -91,7 +91,7 @@ const Hero = () => {
{/* The Image Container with Organic Border Radius */} {/* The Image Container with Organic Border Radius */}
<motion.div <motion.div
className="absolute inset-0 overflow-hidden bg-stone-100" className="absolute inset-0 overflow-hidden bg-stone-100 dark:bg-stone-800"
style={{ style={{
filter: "drop-shadow(0 20px 40px rgba(0,0,0,0.1))", filter: "drop-shadow(0 20px 40px rgba(0,0,0,0.1))",
willChange: "border-radius", willChange: "border-radius",
@@ -133,7 +133,7 @@ const Hero = () => {
transition={{ duration: 0.6, delay: 0.3, ease: "easeOut" }} transition={{ duration: 0.6, delay: 0.3, ease: "easeOut" }}
className="absolute -bottom-8 left-1/2 -translate-x-1/2 z-30" className="absolute -bottom-8 left-1/2 -translate-x-1/2 z-30"
> >
<div className="px-6 py-2.5 rounded-full bg-white/90 backdrop-blur-xl text-stone-900 font-sans font-bold text-sm tracking-wide shadow-lg border-2 border-stone-300"> <div className="px-6 py-2.5 rounded-full bg-white/90 dark:bg-stone-800/90 backdrop-blur-xl text-stone-900 dark:text-stone-50 font-sans font-bold text-sm tracking-wide shadow-lg border-2 border-stone-300 dark:border-stone-700">
dk<span className="text-red-500 font-extrabold">0</span>.dev dk<span className="text-red-500 font-extrabold">0</span>.dev
</div> </div>
</motion.div> </motion.div>
@@ -144,7 +144,7 @@ const Hero = () => {
animate={{ scale: 1, opacity: 1 }} animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.4, duration: 0.5, ease: "easeOut" }} transition={{ delay: 0.4, duration: 0.5, ease: "easeOut" }}
whileHover={{ scale: 1.1, rotate: 5 }} 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"
> >
<Code size={24} /> <Code size={24} />
</motion.div> </motion.div>
@@ -153,7 +153,7 @@ const Hero = () => {
animate={{ scale: 1, opacity: 1 }} animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.5, duration: 0.5, ease: "easeOut" }} transition={{ delay: 0.5, duration: 0.5, ease: "easeOut" }}
whileHover={{ scale: 1.1, rotate: -5 }} 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"
> >
<Zap size={24} /> <Zap size={24} />
</motion.div> </motion.div>
@@ -167,10 +167,10 @@ const Hero = () => {
transition={{ duration: 0.6, delay: 0.2, ease: [0.25, 0.1, 0.25, 1] }} 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" className="mb-8 flex flex-col items-center justify-center relative"
> >
<h1 className="text-5xl md:text-8xl font-bold tracking-tighter text-stone-900 mb-2"> <h1 className="text-5xl md:text-8xl font-bold tracking-tighter text-stone-900 dark:text-stone-50 mb-2">
Dennis Konkol Dennis Konkol
</h1> </h1>
<h2 className="text-2xl md:text-4xl font-light tracking-wide text-stone-600 mt-2"> <h2 className="text-2xl md:text-4xl font-light tracking-wide text-stone-600 dark:text-stone-400 mt-2">
Software Engineer Software Engineer
</h2> </h2>
</motion.div> </motion.div>
@@ -180,10 +180,10 @@ const Hero = () => {
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.3, ease: [0.25, 0.1, 0.25, 1] }} 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 ? ( {cmsDoc ? (
<RichTextClient doc={cmsDoc} className="prose prose-stone max-w-none" /> <RichTextClient doc={cmsDoc} className="prose prose-stone dark:prose-invert max-w-none" />
) : ( ) : (
<p>{t("description")}</p> <p>{t("description")}</p>
)} )}
@@ -207,10 +207,10 @@ const Hero = () => {
ease: [0.25, 0.1, 0.25, 1], ease: [0.25, 0.1, 0.25, 1],
}} }}
whileHover={{ scale: 1.03, y: -3 }} 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.icon className="w-4 h-4 text-stone-800" /> <feature.icon className="w-4 h-4 text-stone-800 dark:text-stone-200" />
<span className="text-stone-800 font-semibold text-sm"> <span className="text-stone-800 dark:text-stone-200 font-semibold text-sm">
{feature.text} {feature.text}
</span> </span>
</motion.div> </motion.div>

View File

@@ -4,6 +4,7 @@ import { motion } from "framer-motion";
import { BookCheck, Star, ChevronDown, ChevronUp } from "lucide-react"; import { BookCheck, Star, ChevronDown, ChevronUp } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useLocale, useTranslations } from "next-intl"; import { useLocale, useTranslations } from "next-intl";
import Image from "next/image";
interface BookReview { interface BookReview {
id: string; id: string;
@@ -134,11 +135,12 @@ const ReadBooks = () => {
className="flex-shrink-0" className="flex-shrink-0"
> >
<div className="relative w-20 h-[7.5rem] sm:w-24 sm:h-32 rounded-lg overflow-hidden shadow-lg border-2 border-white/50"> <div className="relative w-20 h-[7.5rem] sm:w-24 sm:h-32 rounded-lg overflow-hidden shadow-lg border-2 border-white/50">
<img <Image
src={review.book_image} src={review.book_image}
alt={review.book_title} alt={review.book_title}
className="w-full h-full object-cover" fill
loading="lazy" className="object-cover"
sizes="(max-width: 640px) 80px, 96px"
/> />
<div className="absolute inset-0 bg-gradient-to-tr from-white/20 via-transparent to-white/10 pointer-events-none" /> <div className="absolute inset-0 bg-gradient-to-tr from-white/20 via-transparent to-white/10 pointer-events-none" />
</div> </div>

View File

@@ -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<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

View File

@@ -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 <div className="w-9 h-9" />;
}
return (
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => 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" ? (
<Sun size={18} className="text-amber-400" />
) : (
<Moon size={18} className="text-stone-600" />
)}
</motion.button>
);
}

View File

@@ -26,8 +26,30 @@
--radius: 1rem; --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 { 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); color: var(--foreground);
font-family: "Inter", sans-serif; font-family: "Inter", sans-serif;
margin: 0; margin: 0;
@@ -37,6 +59,7 @@ body {
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
position: relative; position: relative;
transition: background-color 0.3s ease, color 0.3s ease;
} }
/* Custom Selection */ /* Custom Selection */
@@ -52,35 +75,33 @@ html {
/* Liquid Glass Effects */ /* Liquid Glass Effects */
.glass-panel { .glass-panel {
background: rgba(250, 248, 243, 0.75); background: var(--card);
backdrop-filter: blur(20px) saturate(130%); backdrop-filter: blur(20px) saturate(130%);
-webkit-backdrop-filter: blur(20px) saturate(130%); -webkit-backdrop-filter: blur(20px) saturate(130%);
border: 1px solid rgba(215, 204, 200, 0.6); border: 1px solid var(--border);
box-shadow: 0 8px 32px rgba(62, 39, 35, 0.12); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
will-change: backdrop-filter; will-change: backdrop-filter;
} }
.glass-card { .glass-card {
background: rgba(255, 252, 245, 0.85); background: var(--card);
backdrop-filter: blur(30px) saturate(200%); backdrop-filter: blur(30px) saturate(200%);
-webkit-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: box-shadow:
0 4px 6px -1px rgba(62, 39, 35, 0.06), 0 4px 6px -1px rgba(0, 0, 0, 0.06),
0 2px 4px -1px rgba(62, 39, 35, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.05);
inset 0 0 30px rgba(255, 252, 245, 0.6);
transition: all 0.6s cubic-bezier(0.25, 0.1, 0.25, 1); transition: all 0.6s cubic-bezier(0.25, 0.1, 0.25, 1);
will-change: transform, box-shadow; will-change: transform, box-shadow;
} }
.glass-card:hover { .glass-card:hover {
background: rgba(255, 252, 245, 0.95); background: var(--card);
box-shadow: box-shadow:
0 20px 25px -5px rgba(62, 39, 35, 0.15), 0 20px 25px -5px rgba(0, 0, 0, 0.15),
0 10px 10px -5px rgba(62, 39, 35, 0.08), 0 10px 10px -5px rgba(0, 0, 0, 0.08);
inset 0 0 30px rgba(255, 252, 245, 0.9);
transform: translateY(-4px); transform: translateY(-4px);
border-color: rgba(215, 204, 200, 0.9); border-color: var(--ring);
} }
/* Typography & Headings */ /* Typography & Headings */
@@ -93,7 +114,7 @@ h6 {
font-family: var(--font-playfair), Georgia, serif; font-family: var(--font-playfair), Georgia, serif;
letter-spacing: -0.02em; letter-spacing: -0.02em;
font-weight: 700; font-weight: 700;
color: #3e2723; color: var(--foreground);
} }
/* Improve text contrast - using foreground variable for WCAG AA compliance */ /* Improve text contrast - using foreground variable for WCAG AA compliance */
@@ -154,34 +175,34 @@ div {
/* Markdown Specifics for Blog/Projects */ /* Markdown Specifics for Blog/Projects */
.markdown h1 { .markdown h1 {
@apply text-4xl font-bold mb-6 tracking-tight; @apply text-4xl font-bold mb-6 tracking-tight;
color: #3e2723; color: var(--foreground);
} }
.markdown h2 { .markdown h2 {
@apply text-2xl font-semibold mt-8 mb-4 tracking-tight; @apply text-2xl font-semibold mt-8 mb-4 tracking-tight;
color: #3e2723; color: var(--foreground);
} }
.markdown p { .markdown p {
@apply mb-4 leading-relaxed; @apply mb-4 leading-relaxed;
color: #4e342e; color: var(--foreground);
} }
.markdown a { .markdown a {
@apply underline decoration-2 underline-offset-2 hover:opacity-80 transition-colors duration-300; @apply underline decoration-2 underline-offset-2 hover:opacity-80 transition-colors duration-300;
color: #5d4037; color: var(--primary);
text-decoration-color: #a1887f; text-decoration-color: var(--accent);
} }
.markdown ul { .markdown ul {
@apply list-disc list-inside mb-4 space-y-2; @apply list-disc list-inside mb-4 space-y-2;
color: #4e342e; color: var(--foreground);
} }
.markdown code { .markdown code {
@apply px-1.5 py-0.5 rounded text-sm font-mono; @apply px-1.5 py-0.5 rounded text-sm font-mono;
background: #efebe9; background: var(--muted);
color: #3e2723; color: var(--foreground);
} }
.markdown pre { .markdown pre {
@apply p-4 rounded-xl overflow-x-auto mb-6; @apply p-4 rounded-xl overflow-x-auto mb-6;
background: #3e2723; background: var(--foreground);
color: #faf8f3; color: var(--background);
} }
/* Admin Dashboard Styles - Warm Brown Theme */ /* Admin Dashboard Styles - Warm Brown Theme */

View File

@@ -60,6 +60,18 @@ const nextConfig: NextConfig = {
protocol: "https", protocol: "https",
hostname: "media.discordapp.net", hostname: "media.discordapp.net",
}, },
{
protocol: "https",
hostname: "cms.dk0.dev",
},
{
protocol: "https",
hostname: "assets.hardcover.app",
},
{
protocol: "https",
hostname: "dki.one",
},
], ],
}, },

11
package-lock.json generated
View File

@@ -31,6 +31,7 @@
"lucide-react": "^0.542.0", "lucide-react": "^0.542.0",
"next": "^15.5.7", "next": "^15.5.7",
"next-intl": "^4.7.0", "next-intl": "^4.7.0",
"next-themes": "^0.4.6",
"node-cache": "^5.1.2", "node-cache": "^5.1.2",
"node-fetch": "^2.7.0", "node-fetch": "^2.7.0",
"nodemailer": "^7.0.11", "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": { "node_modules/next/node_modules/postcss": {
"version": "8.4.31", "version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",

View File

@@ -75,6 +75,7 @@
"lucide-react": "^0.542.0", "lucide-react": "^0.542.0",
"next": "^15.5.7", "next": "^15.5.7",
"next-intl": "^4.7.0", "next-intl": "^4.7.0",
"next-themes": "^0.4.6",
"node-cache": "^5.1.2", "node-cache": "^5.1.2",
"node-fetch": "^2.7.0", "node-fetch": "^2.7.0",
"nodemailer": "^7.0.11", "nodemailer": "^7.0.11",