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
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Failing after 10m16s
This commit is contained in:
47
GEMINI.md
Normal file
47
GEMINI.md
Normal 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.
|
||||||
@@ -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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
69
app/__tests__/api/book-reviews.test.tsx
Normal file
69
app/__tests__/api/book-reviews.test.tsx
Normal 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',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
69
app/__tests__/api/hobbies.test.tsx
Normal file
69
app/__tests__/api/hobbies.test.tsx
Normal 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',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
71
app/__tests__/api/tech-stack.test.tsx
Normal file
71
app/__tests__/api/tech-stack.test.tsx
Normal 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',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
11
app/components/ThemeProvider.tsx
Normal file
11
app/components/ThemeProvider.tsx
Normal 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>;
|
||||||
|
}
|
||||||
35
app/components/ThemeToggle.tsx
Normal file
35
app/components/ThemeToggle.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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
11
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user