Compare commits

...

5 Commits

Author SHA1 Message Date
denshooter
0766b46cc8 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
2026-02-15 22:20:49 +01:00
denshooter
92e5b4936e Claude/add book ratings comments kq0 lx (#66)
* feat: Add book ratings and reviews managed via Directus CMS

Adds a new "Read Books" section below "Currently Reading" in the About
page. Book reviews with star ratings and comments are fetched from a
Directus CMS collection (book_reviews) and displayed with the existing
liquid design system. Includes i18n support (EN/DE), show more/less
toggle, and graceful fallback when the CMS collection does not exist yet.

https://claude.ai/code/session_017E8W9CcHFM5WQVHw74JP34

* chore: Add CLAUDE.md, TODO.md, and fix ReadBooks Tailwind classes

- Add CLAUDE.md with project architecture, conventions, and common tasks
- Add TODO.md with prioritized roadmap (book reviews, CMS, n8n, frontend)
- Fix invalid Tailwind classes in ReadBooks.tsx (h-30 -> h-[7.5rem], w-22 -> w-24)

https://claude.ai/code/session_017E8W9CcHFM5WQVHw74JP34

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-15 22:13:34 +01:00
Claude
99d0d1dba1 chore: Add CLAUDE.md, TODO.md, and fix ReadBooks Tailwind classes
- Add CLAUDE.md with project architecture, conventions, and common tasks
- Add TODO.md with prioritized roadmap (book reviews, CMS, n8n, frontend)
- Fix invalid Tailwind classes in ReadBooks.tsx (h-30 -> h-[7.5rem], w-22 -> w-24)

https://claude.ai/code/session_017E8W9CcHFM5WQVHw74JP34
2026-02-15 22:12:44 +01:00
Claude
032568562c feat: Add book ratings and reviews managed via Directus CMS
Adds a new "Read Books" section below "Currently Reading" in the About
page. Book reviews with star ratings and comments are fetched from a
Directus CMS collection (book_reviews) and displayed with the existing
liquid design system. Includes i18n support (EN/DE), show more/less
toggle, and graceful fallback when the CMS collection does not exist yet.

https://claude.ai/code/session_017E8W9CcHFM5WQVHw74JP34
2026-02-15 22:12:44 +01:00
denshooter
07741761cc Updating (#65)
* Fix ActivityFeed: Remove dynamic import that was causing it to disappear in production

* Fix ActivityFeed hydration error: Move localStorage read to useEffect to prevent server/client mismatch

* Update Node.js version to 25 in Gitea workflows

- Fix EBADENGINE error for camera-controls@3.1.2 which requires Node.js >=22
- Update production-deploy.yml, dev-deploy.yml, and ci-cd-with-gitea-vars.yml.disabled
- Node.js v25 matches local development environment

* Update Dockerfile to use Node.js 25

- Update base image from node:20 to node:25
- Matches Gitea workflow configuration and camera-controls@3.1.2 requirements

* Fix production deployment: Start database dependencies

- Remove --no-deps flag which prevented postgres and redis from starting
- Remove --build flag as image is already built in previous step
- This fixes 'Can't reach database server at postgres:5432' error

* Fix postgres health check in production

- Remove init-db.sql volume mount (not available in CI/CD environment)
- Init script not needed as Prisma handles schema migrations
- Postgres will initialize empty database automatically

* Fix cache permission error in Docker container

- Create cache directories AFTER copying standalone files
- Create both fetch-cache and images subdirectories
- Set proper ownership for nextjs user
- Fixes EACCES permission denied errors for prerender cache

* Fix German jogging fallback text

* Use Directus content in production

* fix: Security vulnerability - block malicious file requests

* fix: Switch projects to Directus, add security fixes and example projects
2026-02-15 22:04:26 +01:00
34 changed files with 1265 additions and 107 deletions

View File

@@ -5,7 +5,7 @@ on:
branches: [ dev, main, production ] branches: [ dev, main, production ]
env: env:
NODE_VERSION: '20' NODE_VERSION: '25'
DOCKER_IMAGE: portfolio-app DOCKER_IMAGE: portfolio-app
CONTAINER_NAME: portfolio-app CONTAINER_NAME: portfolio-app

View File

@@ -5,7 +5,7 @@ on:
branches: [ dev ] branches: [ dev ]
env: env:
NODE_VERSION: '20' NODE_VERSION: '25'
DOCKER_IMAGE: portfolio-app DOCKER_IMAGE: portfolio-app
IMAGE_TAG: dev IMAGE_TAG: dev

View File

@@ -5,7 +5,7 @@ on:
branches: [ production ] branches: [ production ]
env: env:
NODE_VERSION: '20' NODE_VERSION: '25'
DOCKER_IMAGE: portfolio-app DOCKER_IMAGE: portfolio-app
IMAGE_TAG: production IMAGE_TAG: production
@@ -70,11 +70,13 @@ jobs:
export MY_INFO_PASSWORD="${{ secrets.MY_INFO_PASSWORD }}" export MY_INFO_PASSWORD="${{ secrets.MY_INFO_PASSWORD }}"
export ADMIN_BASIC_AUTH="${{ secrets.ADMIN_BASIC_AUTH }}" export ADMIN_BASIC_AUTH="${{ secrets.ADMIN_BASIC_AUTH }}"
export ADMIN_SESSION_SECRET="${{ secrets.ADMIN_SESSION_SECRET }}" export ADMIN_SESSION_SECRET="${{ secrets.ADMIN_SESSION_SECRET }}"
export DIRECTUS_URL="${{ vars.DIRECTUS_URL || 'https://cms.dk0.dev' }}"
export DIRECTUS_STATIC_TOKEN="${{ secrets.DIRECTUS_STATIC_TOKEN || '' }}"
# Start new container with updated image (docker-compose will handle this) # Start new container with updated image (docker-compose will handle this)
echo "🆕 Starting new production container..." echo "🆕 Starting new production container..."
echo "📝 Environment check: N8N_WEBHOOK_URL=${N8N_WEBHOOK_URL:-(not set)}" echo "📝 Environment check: N8N_WEBHOOK_URL=${N8N_WEBHOOK_URL:-(not set)}"
docker compose -f $COMPOSE_FILE up -d --no-deps --build portfolio docker compose -f $COMPOSE_FILE up -d portfolio
# Wait for new container to be healthy # Wait for new container to be healthy
echo "⏳ Waiting for new container to be healthy..." echo "⏳ Waiting for new container to be healthy..."

155
CLAUDE.md Normal file
View File

@@ -0,0 +1,155 @@
# CLAUDE.md - Portfolio Project Guide
## Project Overview
Personal portfolio website for Dennis Konkol (dk0.dev). Built with Next.js 15 (App Router), TypeScript, Tailwind CSS, and Framer Motion. Uses a "liquid" design system with soft gradient colors and glassmorphism effects.
## Tech Stack
- **Framework**: Next.js 15 (App Router), TypeScript 5.9
- **Styling**: Tailwind CSS 3.4 with custom `liquid-*` color tokens
- **Animations**: Framer Motion 12
- **3D**: Three.js + React Three Fiber (shader gradient background)
- **Database**: PostgreSQL via Prisma ORM
- **Cache**: Redis (optional)
- **CMS**: Directus (self-hosted, REST/GraphQL, optional)
- **Automation**: n8n webhooks (status, chat, hardcover, image generation)
- **i18n**: next-intl (EN + DE), message files in `messages/`
- **Monitoring**: Sentry
- **Deployment**: Docker + Nginx, CI via Gitea Actions
## Commands
```bash
npm run dev # Full dev environment (Docker + Next.js)
npm run dev:simple # Next.js only (no Docker)
npm run dev:next # Plain Next.js dev server
npm run build # Production build
npm run lint # ESLint
npm run test # Jest unit tests
npm run test:e2e # Playwright E2E tests
```
## Project Structure
```
app/
[locale]/ # i18n routes (en, de)
page.tsx # Homepage (hero, about, projects, contact)
projects/ # Project listing + detail pages
api/ # API routes
book-reviews/ # Book reviews from Directus CMS
content/ # CMS content pages
hobbies/ # Hobbies from Directus
n8n/ # n8n webhook proxies
hardcover/ # Currently reading (Hardcover API via n8n)
status/ # Activity status (coding, music, gaming)
chat/ # AI chatbot
generate-image/ # AI image generation
projects/ # Projects API (PostgreSQL + Directus fallback)
tech-stack/ # Tech stack from Directus
components/ # React components
About.tsx # About section (tech stack, hobbies, books)
CurrentlyReading.tsx # Currently reading widget (n8n/Hardcover)
ReadBooks.tsx # Read books with ratings (Directus CMS)
Projects.tsx # Featured projects section
Hero.tsx # Hero section
Contact.tsx # Contact form
lib/
directus.ts # Directus GraphQL client (no SDK)
auth.ts # Auth utilities + rate limiting
prisma/
schema.prisma # Database schema
messages/
en.json # English translations
de.json # German translations
docs/ # Documentation
```
## Architecture Patterns
### Data Source Hierarchy (Fallback Chain)
1. Directus CMS (if configured via `DIRECTUS_STATIC_TOKEN`)
2. PostgreSQL (for projects, analytics)
3. JSON files (`messages/*.json`)
4. Hardcoded defaults
5. Display key itself as last resort
All external data sources fail gracefully - the site never crashes if Directus, PostgreSQL, n8n, or Redis are unavailable.
### CMS Integration (Directus)
- REST/GraphQL calls via `lib/directus.ts` (no Directus SDK)
- Collections: `tech_stack_categories`, `tech_stack_items`, `hobbies`, `content_pages`, `projects`, `book_reviews`
- Translations use Directus native translation system (M2O to `languages`)
- Locale mapping: `en` -> `en-US`, `de` -> `de-DE`
### n8n Integration
- Webhook base URL: `N8N_WEBHOOK_URL` env var
- Auth via `N8N_SECRET_TOKEN` and/or `N8N_API_KEY` headers
- All n8n endpoints have rate limiting and timeout protection (10s)
- Hardcover data cached for 5 minutes
### Component Patterns
- Client components with `"use client"` for interactive/data-fetching parts
- `useEffect` for data loading on mount
- `useTranslations` from next-intl for i18n
- Framer Motion `variants` pattern with `staggerContainer` + `fadeInUp`
- Gradient cards with `liquid-*` color tokens and `backdrop-blur-sm`
## Design System
Custom Tailwind colors prefixed with `liquid-`:
- `liquid-sky`, `liquid-mint`, `liquid-lavender`, `liquid-pink`
- `liquid-rose`, `liquid-peach`, `liquid-coral`, `liquid-teal`, `liquid-lime`
Cards use gradient backgrounds (`bg-gradient-to-br from-liquid-*/15 via-liquid-*/10 to-liquid-*/15`) with `border-2` and `rounded-xl`.
## Key Environment Variables
```bash
# Required for CMS
DIRECTUS_URL=https://cms.dk0.dev
DIRECTUS_STATIC_TOKEN=...
# Required for n8n features
N8N_WEBHOOK_URL=https://n8n.dk0.dev
N8N_SECRET_TOKEN=...
N8N_API_KEY=...
# Database
DATABASE_URL=postgresql://...
# Optional
REDIS_URL=redis://...
SENTRY_DSN=...
```
## Conventions
- Language: Code in English, user-facing text via i18n (EN + DE)
- Commit messages: Conventional Commits (`feat:`, `fix:`, `chore:`)
- Components: PascalCase files in `app/components/`
- API routes: kebab-case directories in `app/api/`
- CMS data always has a static fallback - never rely solely on Directus
- Error logging: Only in `development` mode (`process.env.NODE_ENV === "development"`)
- No emojis in code unless explicitly requested
## Common Tasks
### Adding a new CMS-managed section
1. Define the GraphQL query + types in `lib/directus.ts`
2. Create an API route in `app/api/<name>/route.ts`
3. Create a component in `app/components/<Name>.tsx`
4. Add i18n keys to `messages/en.json` and `messages/de.json`
5. Integrate into the parent component (usually `About.tsx`)
### Adding i18n strings
1. Add keys to `messages/en.json` and `messages/de.json`
2. Access via `useTranslations("key.path")` in client components
3. Or `getTranslations("key.path")` in server components
### Working with Directus collections
- All queries go through `directusRequest()` in `lib/directus.ts`
- Uses GraphQL endpoint (`/graphql`)
- 2-second timeout, graceful null fallback
- Translations filtered by `languages_code.code` matching Directus locale

View File

@@ -1,5 +1,5 @@
# Multi-stage build for optimized production image # Multi-stage build for optimized production image
FROM node:20 AS base FROM node:25 AS base
# Install dependencies only when needed # Install dependencies only when needed
FROM base AS deps FROM base AS deps
@@ -67,10 +67,6 @@ RUN adduser --system --uid 1001 nextjs
# Copy the built application # Copy the built application
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size # Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing # https://nextjs.org/docs/advanced-features/output-file-tracing
# Copy standalone output (contains server.js and all dependencies) # Copy standalone output (contains server.js and all dependencies)
@@ -79,6 +75,10 @@ RUN chown nextjs:nodejs .next
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Create cache directories with correct permissions AFTER copying standalone
RUN mkdir -p .next/cache/fetch-cache .next/cache/images && \
chown -R nextjs:nodejs .next/cache
# Copy Prisma files # Copy Prisma files
COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma

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.

51
TODO.md Normal file
View File

@@ -0,0 +1,51 @@
# TODO - Portfolio Roadmap
## Book Reviews (Neu)
- [ ] **Directus Collection erstellen**: `book_reviews` mit Feldern:
- `status` (draft/published)
- `book_title` (String)
- `book_author` (String)
- `book_image` (String, URL zum Cover)
- `rating` (Integer, 1-5)
- `hardcover_id` (String, optional)
- `finished_at` (Datetime, optional)
- Translations: `review` (Text) + `languages_code` (FK)
- [ ] **n8n Workflow**: Automatisch Directus-Entwurf erstellen wenn Buch auf Hardcover als "gelesen" markiert wird
- [ ] **Hardcover GraphQL Query** für gelesene Bücher: `status_id: {_eq: 3}` (Read)
- [ ] **Erste Testdaten**: 2-3 gelesene Bücher mit Rating + Kommentar in Directus anlegen
## Directus CMS
- [ ] Messages Collection: `messages` mit key + translations (ersetzt `messages/*.json`)
- [ ] Projects vollständig zu Directus migrieren (`node scripts/migrate-projects-to-directus.js`)
- [ ] Directus Webhooks einrichten: On-Demand ISR Revalidation bei Content-Änderungen
- [ ] Directus Roles: Public Read Token, Admin Write
## n8n Integrationen
- [ ] Hardcover "Read Books" Webhook: Gelesene Bücher automatisch in Directus importieren
- [ ] Spotify Now Playing verbessern: Album-Art Caching
- [ ] Discord Rich Presence: Gaming-Status automatisch aktualisieren
## Frontend
- [ ] Dark Mode Support (Theme Toggle)
- [ ] Blog/Artikel Sektion (Directus-basiert)
- [ ] Projekt-Detail Seite: Bildergalerie/Lightbox
- [ ] Performance: Bilder auf Next.js `<Image>` umstellen (statt `<img>`)
- [ ] SEO: Structured Data (JSON-LD) für Projekte
## Testing & Qualität
- [ ] Jest Tests für neue API-Routes (`book-reviews`, `hobbies`, `tech-stack`)
- [ ] Playwright E2E: Book Reviews Sektion testen
- [ ] Lighthouse Score > 95 auf allen Seiten sicherstellen
- [ ] Accessibility Audit (WCAG 2.1 AA)
## DevOps
- [ ] Staging Environment aufräumen und dokumentieren
- [ ] GitHub Actions Migration (von Gitea Actions)
- [ ] Docker Image Size optimieren (Multi-Stage Build prüfen)
- [ ] Health Check Endpoint erweitern: Directus + n8n Connectivity

View File

@@ -1,10 +1,19 @@
import { NextIntlClientProvider } from "next-intl"; import { NextIntlClientProvider } from "next-intl";
import { setRequestLocale } from "next-intl/server"; import { setRequestLocale } from "next-intl/server";
import React from "react"; import React from "react";
import { notFound } from "next/navigation";
import ConsentBanner from "../components/ConsentBanner"; import ConsentBanner from "../components/ConsentBanner";
import { getLocalizedMessage } from "@/lib/i18n-loader"; import { getLocalizedMessage } from "@/lib/i18n-loader";
async function loadEnhancedMessages(locale: string) { // Supported locales - must match middleware.ts
const SUPPORTED_LOCALES = ["en", "de"] as const;
type SupportedLocale = (typeof SUPPORTED_LOCALES)[number];
function isValidLocale(locale: string): locale is SupportedLocale {
return SUPPORTED_LOCALES.includes(locale as SupportedLocale);
}
async function loadEnhancedMessages(locale: SupportedLocale) {
// Lade basis JSON Messages // Lade basis JSON Messages
const baseMessages = (await import(`../../messages/${locale}.json`)).default; const baseMessages = (await import(`../../messages/${locale}.json`)).default;
@@ -13,6 +22,11 @@ async function loadEnhancedMessages(locale: string) {
return baseMessages; return baseMessages;
} }
// Define valid static params to prevent malicious path traversal
export function generateStaticParams() {
return SUPPORTED_LOCALES.map((locale) => ({ locale }));
}
export default async function LocaleLayout({ export default async function LocaleLayout({
children, children,
params, params,
@@ -21,6 +35,12 @@ export default async function LocaleLayout({
params: Promise<{ locale: string }>; params: Promise<{ locale: string }>;
}) { }) {
const { locale } = await params; const { locale } = await params;
// Security: Validate locale to prevent malicious imports
if (!isValidLocale(locale)) {
notFound();
}
// Ensure next-intl actually uses the route segment locale for this request. // Ensure next-intl actually uses the route segment locale for this request.
setRequestLocale(locale); setRequestLocale(locale);
// Load messages explicitly by route locale to avoid falling back to the wrong // Load messages explicitly by route locale to avoid falling back to the wrong

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

@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from 'next/server';
import { getBookReviews } from '@/lib/directus';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
/**
* GET /api/book-reviews
*
* Loads Book Reviews from Directus CMS
*
* Query params:
* - locale: en or de (default: en)
*/
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const locale = searchParams.get('locale') || 'en';
const reviews = await getBookReviews(locale);
if (reviews && reviews.length > 0) {
return NextResponse.json({
bookReviews: reviews,
source: 'directus'
});
}
return NextResponse.json({
bookReviews: null,
source: 'fallback'
});
} catch (error) {
console.error('Error loading book reviews:', error);
return NextResponse.json(
{
bookReviews: null,
error: 'Failed to load book reviews',
source: 'error'
},
{ status: 500 }
);
}
}

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma'; import { getProjects } from '@/lib/directus';
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
@@ -7,56 +7,27 @@ export async function GET(request: NextRequest) {
const slug = searchParams.get('slug'); const slug = searchParams.get('slug');
const search = searchParams.get('search'); const search = searchParams.get('search');
const category = searchParams.get('category'); const category = searchParams.get('category');
const locale = searchParams.get('locale') || 'en';
if (slug) { // Use Directus instead of Prisma
const project = await prisma.project.findFirst({ const projects = await getProjects(locale, {
where: { featured: undefined,
published: true, published: true,
slug, category: category && category !== 'All' ? category : undefined,
}, search: search || undefined,
orderBy: { createdAt: 'desc' },
}); });
if (!projects) {
// Directus not available or no projects found
return NextResponse.json({ projects: [] });
}
// Filter by slug if provided (since Directus query doesn't support slug filter directly)
if (slug) {
const project = projects.find(p => p.slug === slug);
return NextResponse.json({ projects: project ? [project] : [] }); return NextResponse.json({ projects: project ? [project] : [] });
} }
if (search) {
// General search
const projects = await prisma.project.findMany({
where: {
published: true,
OR: [
{ title: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
{ tags: { hasSome: [search] } },
{ content: { contains: search, mode: 'insensitive' } }
]
},
orderBy: { createdAt: 'desc' }
});
return NextResponse.json({ projects });
}
if (category && category !== 'All') {
// Filter by category
const projects = await prisma.project.findMany({
where: {
published: true,
category: category
},
orderBy: { createdAt: 'desc' }
});
return NextResponse.json({ projects });
}
// Return all published projects if no specific search
const projects = await prisma.project.findMany({
where: { published: true },
orderBy: { createdAt: 'desc' }
});
return NextResponse.json({ projects }); return NextResponse.json({ projects });
} catch (error) { } catch (error) {
console.error('Error searching projects:', error); console.error('Error searching projects:', error);

View File

@@ -7,6 +7,7 @@ import { useLocale, useTranslations } from "next-intl";
import type { JSONContent } from "@tiptap/react"; import type { JSONContent } from "@tiptap/react";
import RichTextClient from "./RichTextClient"; import RichTextClient from "./RichTextClient";
import CurrentlyReading from "./CurrentlyReading"; import CurrentlyReading from "./CurrentlyReading";
import ReadBooks from "./ReadBooks";
// Type definitions for CMS data // Type definitions for CMS data
interface TechStackItem { interface TechStackItem {
@@ -389,6 +390,14 @@ const About = () => {
> >
<CurrentlyReading /> <CurrentlyReading />
</motion.div> </motion.div>
{/* Read Books with Ratings */}
<motion.div
variants={fadeInUp}
className="mt-6"
>
<ReadBooks />
</motion.div>
</motion.div> </motion.div>
</div> </div>
</div> </div>

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>
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
<GatedProviders mounted={mounted} is404Page={is404Page}> <GatedProviders mounted={mounted} is404Page={is404Page}>
{children} {children}
</GatedProviders> </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

@@ -0,0 +1,214 @@
"use client";
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;
hardcover_id?: string;
book_title: string;
book_author: string;
book_image?: string;
rating: number;
review?: string;
finished_at?: string;
}
const StarRating = ({ rating }: { rating: number }) => {
return (
<div className="flex gap-0.5">
{[1, 2, 3, 4, 5].map((star) => (
<Star
key={star}
size={14}
className={
star <= rating
? "text-amber-500 fill-amber-500"
: "text-stone-300"
}
/>
))}
</div>
);
};
const ReadBooks = () => {
const locale = useLocale();
const t = useTranslations("home.about.readBooks");
const [reviews, setReviews] = useState<BookReview[]>([]);
const [loading, setLoading] = useState(true);
const [expanded, setExpanded] = useState(false);
const INITIAL_SHOW = 3;
useEffect(() => {
const fetchReviews = async () => {
try {
const res = await fetch(
`/api/book-reviews?locale=${encodeURIComponent(locale)}`,
{ cache: "default" }
);
if (!res.ok) {
throw new Error("Failed to fetch");
}
const data = await res.json();
if (data.bookReviews) {
setReviews(data.bookReviews);
} else {
setReviews([]);
}
} catch (error) {
if (process.env.NODE_ENV === "development") {
console.error("Error fetching book reviews:", error);
}
setReviews([]);
} finally {
setLoading(false);
}
};
fetchReviews();
}, [locale]);
if (loading || reviews.length === 0) {
return null;
}
const visibleReviews = expanded ? reviews : reviews.slice(0, INITIAL_SHOW);
const hasMore = reviews.length > INITIAL_SHOW;
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center gap-2 mb-4">
<BookCheck size={18} className="text-stone-600 flex-shrink-0" />
<h3 className="text-lg font-bold text-stone-900">
{t("title")} ({reviews.length})
</h3>
</div>
{/* Book Reviews */}
{visibleReviews.map((review, index) => (
<motion.div
key={review.id}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-50px" }}
transition={{
duration: 0.6,
delay: index * 0.1,
ease: [0.25, 0.1, 0.25, 1],
}}
whileHover={{
scale: 1.02,
transition: { duration: 0.4, ease: "easeOut" },
}}
className="relative overflow-hidden bg-gradient-to-br from-liquid-mint/15 via-liquid-sky/10 to-liquid-teal/15 border-2 border-liquid-mint/30 rounded-xl p-5 backdrop-blur-sm hover:border-liquid-mint/50 hover:from-liquid-mint/20 hover:via-liquid-sky/15 hover:to-liquid-teal/20 transition-all duration-500 ease-out"
>
{/* Background Blob */}
<motion.div
className="absolute -bottom-8 -left-8 w-28 h-28 bg-gradient-to-br from-liquid-mint/20 to-liquid-sky/20 rounded-full blur-2xl"
animate={{
scale: [1, 1.15, 1],
opacity: [0.3, 0.45, 0.3],
}}
transition={{
duration: 8,
repeat: Infinity,
ease: "easeInOut",
delay: index * 0.5,
}}
/>
<div className="relative z-10 flex flex-col sm:flex-row gap-4 items-start">
{/* Book Cover */}
{review.book_image && (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5, delay: 0.2 + index * 0.1 }}
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">
<Image
src={review.book_image}
alt={review.book_title}
fill
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>
</motion.div>
)}
{/* Book Info */}
<div className="flex-1 min-w-0">
<h4 className="text-base font-bold text-stone-900 mb-0.5 line-clamp-2">
{review.book_title}
</h4>
<p className="text-sm text-stone-600 mb-2 line-clamp-1">
{review.book_author}
</p>
{/* Rating */}
<div className="flex items-center gap-2 mb-2">
<StarRating rating={review.rating} />
<span className="text-xs text-stone-500 font-medium">
{review.rating}/5
</span>
</div>
{/* Review Text */}
{review.review && (
<p className="text-sm text-stone-700 leading-relaxed line-clamp-3 italic">
&ldquo;{review.review}&rdquo;
</p>
)}
{/* Finished Date */}
{review.finished_at && (
<p className="text-xs text-stone-400 mt-2">
{t("finishedAt")}{" "}
{new Date(review.finished_at).toLocaleDateString(
locale === "de" ? "de-DE" : "en-US",
{ year: "numeric", month: "short" }
)}
</p>
)}
</div>
</div>
</motion.div>
))}
{/* Show More / Show Less */}
{hasMore && (
<motion.button
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
onClick={() => setExpanded(!expanded)}
className="w-full flex items-center justify-center gap-1.5 py-2.5 text-sm font-medium text-stone-600 hover:text-stone-800 rounded-lg border-2 border-dashed border-stone-200 hover:border-stone-300 transition-colors duration-300"
>
{expanded ? (
<>
{t("showLess")} <ChevronUp size={16} />
</>
) : (
<>
{t("showMore", { count: reviews.length - INITIAL_SHOW })}{" "}
<ChevronDown size={16} />
</>
)}
</motion.button>
)}
</div>
);
};
export default ReadBooks;

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

@@ -12,6 +12,8 @@ services:
- NODE_ENV=production - NODE_ENV=production
- DATABASE_URL=postgresql://portfolio_user:portfolio_pass@postgres:5432/portfolio_db?schema=public - DATABASE_URL=postgresql://portfolio_user:portfolio_pass@postgres:5432/portfolio_db?schema=public
- REDIS_URL=redis://redis:6379 - REDIS_URL=redis://redis:6379
- DIRECTUS_URL=${DIRECTUS_URL:-https://cms.dk0.dev}
- DIRECTUS_STATIC_TOKEN=${DIRECTUS_STATIC_TOKEN:-}
- NEXT_PUBLIC_BASE_URL=https://dk0.dev - NEXT_PUBLIC_BASE_URL=https://dk0.dev
- MY_EMAIL=${MY_EMAIL:-contact@dk0.dev} - MY_EMAIL=${MY_EMAIL:-contact@dk0.dev}
- MY_INFO_EMAIL=${MY_INFO_EMAIL:-info@dk0.dev} - MY_INFO_EMAIL=${MY_INFO_EMAIL:-info@dk0.dev}
@@ -60,7 +62,6 @@ services:
- POSTGRES_PASSWORD=portfolio_pass - POSTGRES_PASSWORD=portfolio_pass
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
- ./scripts/init-db.sql:/docker-entrypoint-initdb.d/init-db.sql:ro
networks: networks:
- portfolio_net - portfolio_net
healthcheck: healthcheck:

View File

@@ -422,6 +422,71 @@ export async function getHobbies(locale: string): Promise<Hobby[] | null> {
} }
} }
// Book Review Types
export interface BookReview {
id: string;
hardcover_id?: string;
book_title: string;
book_author: string;
book_image?: string;
rating: number; // 1-5
review?: string; // Translated review text
finished_at?: string;
}
/**
* Get Book Reviews from Directus with translations
*/
export async function getBookReviews(locale: string): Promise<BookReview[] | null> {
const directusLocale = toDirectusLocale(locale);
const query = `
query {
book_reviews(
filter: { status: { _eq: "published" } }
sort: ["-finished_at", "-date_created"]
) {
id
hardcover_id
book_title
book_author
book_image
rating
finished_at
translations(filter: { languages_code: { code: { _eq: "${directusLocale}" } } }) {
review
}
}
}
`;
try {
const result = await directusRequest(
'',
{ body: { query } }
);
const reviews = (result as any)?.book_reviews;
if (!reviews || reviews.length === 0) {
return null;
}
return reviews.map((item: any) => ({
id: item.id,
hardcover_id: item.hardcover_id || undefined,
book_title: item.book_title,
book_author: item.book_author,
book_image: item.book_image || undefined,
rating: typeof item.rating === 'number' ? item.rating : parseInt(item.rating) || 0,
review: item.translations?.[0]?.review || undefined,
finished_at: item.finished_at || undefined,
}));
} catch (error) {
console.error(`Failed to fetch book reviews (${locale}):`, error);
return null;
}
}
// Projects Types // Projects Types
export interface Project { export interface Project {
id: string; id: string;

View File

@@ -58,11 +58,17 @@
"selfHosting": "Self-Hosting & DevOps", "selfHosting": "Self-Hosting & DevOps",
"gaming": "Gaming", "gaming": "Gaming",
"gameServers": "Game-Server einrichten", "gameServers": "Game-Server einrichten",
"jogging": "Joggen la Kopf freibekommen und aktiv bleiben" "jogging": "Joggen um den Kopf freizubekommen und aktiv bleiben"
}, },
"currentlyReading": { "currentlyReading": {
"title": "Aktuell am Lesen", "title": "Aktuell am Lesen",
"progress": "Fortschritt" "progress": "Fortschritt"
},
"readBooks": {
"title": "Gelesen",
"finishedAt": "Beendet",
"showMore": "{count} weitere",
"showLess": "Weniger anzeigen"
} }
}, },
"projects": { "projects": {

View File

@@ -64,6 +64,12 @@
"currentlyReading": { "currentlyReading": {
"title": "Currently Reading", "title": "Currently Reading",
"progress": "Progress" "progress": "Progress"
},
"readBooks": {
"title": "Read",
"finishedAt": "Finished",
"showMore": "{count} more",
"showLess": "Show less"
} }
}, },
"projects": { "projects": {

View File

@@ -4,6 +4,29 @@ import type { NextRequest } from "next/server";
const SUPPORTED_LOCALES = ["en", "de"] as const; const SUPPORTED_LOCALES = ["en", "de"] as const;
type SupportedLocale = (typeof SUPPORTED_LOCALES)[number]; type SupportedLocale = (typeof SUPPORTED_LOCALES)[number];
// Security: Block common malicious file patterns
const BLOCKED_PATTERNS = [
/\.php$/i,
/\.asp$/i,
/\.aspx$/i,
/\.jsp$/i,
/\.cgi$/i,
/\.env$/i,
/\.sql$/i,
/\.gz$/i,
/\.tar$/i,
/\.zip$/i,
/\.rar$/i,
/\.bash_history$/i,
/ftpsync\.settings$/i,
/__MACOSX/i,
/\.well-known\.zip$/i,
];
function isBlockedPath(pathname: string): boolean {
return BLOCKED_PATTERNS.some((pattern) => pattern.test(pathname));
}
function pickLocaleFromHeader(acceptLanguage: string | null): SupportedLocale { function pickLocaleFromHeader(acceptLanguage: string | null): SupportedLocale {
if (!acceptLanguage) return "en"; if (!acceptLanguage) return "en";
const lower = acceptLanguage.toLowerCase(); const lower = acceptLanguage.toLowerCase();
@@ -20,6 +43,11 @@ function hasLocalePrefix(pathname: string): boolean {
export function middleware(request: NextRequest) { export function middleware(request: NextRequest) {
const { pathname, search } = request.nextUrl; const { pathname, search } = request.nextUrl;
// Security: Block malicious/suspicious requests immediately
if (isBlockedPath(pathname)) {
return new NextResponse(null, { status: 404 });
}
// If a locale-prefixed request hits a public asset path (e.g. /de/images/me.jpg), // If a locale-prefixed request hits a public asset path (e.g. /de/images/me.jpg),
// redirect to the non-prefixed asset path. // redirect to the non-prefixed asset path.
if (hasLocalePrefix(pathname)) { if (hasLocalePrefix(pathname)) {

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",
},
], ],
}, },

View File

@@ -82,6 +82,27 @@ http {
# Avoid `unsafe-eval` in production CSP # Avoid `unsafe-eval` in production CSP
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://analytics.dk0.dev; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https://analytics.dk0.dev;"; add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://analytics.dk0.dev; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https://analytics.dk0.dev;";
# Block common malicious file extensions and paths
location ~* \.(php|asp|aspx|jsp|cgi|sh|bat|cmd|exe|dll)$ {
return 404;
}
# Block access to sensitive files
location ~* (\.env|\.sql|\.tar|\.gz|\.zip|\.rar|\.bash_history|ftpsync\.settings|__MACOSX) {
return 404;
}
# Block access to .well-known if not explicitly needed
location ~ /\.well-known(?!\/acme-challenge) {
return 404;
}
# Block access to hidden files and directories
location ~ /\. {
deny all;
return 404;
}
# Cache static assets # Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y; expires 1y;

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",

View File

@@ -0,0 +1,178 @@
#!/usr/bin/env node
/**
* Add Example Projects to Directus
*
* Creates 3 example projects in Directus CMS
*
* Usage:
* node scripts/add-example-projects.js
*/
require('dotenv').config();
const DIRECTUS_URL = process.env.DIRECTUS_URL || 'https://cms.dk0.dev';
const DIRECTUS_TOKEN = process.env.DIRECTUS_STATIC_TOKEN;
if (!DIRECTUS_TOKEN) {
console.error('❌ Error: DIRECTUS_STATIC_TOKEN not found in .env');
process.exit(1);
}
async function directusRequest(endpoint, method = 'GET', body = null) {
const url = `${DIRECTUS_URL}/${endpoint}`;
const options = {
method,
headers: {
'Authorization': `Bearer ${DIRECTUS_TOKEN}`,
'Content-Type': 'application/json'
}
};
if (body) {
options.body = JSON.stringify(body);
}
try {
const response = await fetch(url, options);
if (!response.ok) {
const text = await response.text();
console.error(`HTTP ${response.status}: ${text}`);
return null;
}
return await response.json();
} catch (error) {
console.error(`Error calling ${method} ${endpoint}:`, error.message);
return null;
}
}
const exampleProjects = [
{
slug: 'portfolio-website',
status: 'published',
featured: true,
category: 'Web Application',
difficulty: 'Advanced',
date: '2024',
github: 'https://github.com/denshooter/portfolio',
live: 'https://dk0.dev',
image_url: 'https://images.unsplash.com/photo-1460925895917-afdab827c52f?w=800',
tags: ['Next.js', 'React', 'TypeScript', 'Tailwind CSS'],
technologies: ['Next.js 14', 'TypeScript', 'Tailwind CSS', 'Directus', 'Docker'],
challenges: 'Building a performant, SEO-optimized portfolio with multilingual support',
lessons_learned: 'Learned about Next.js App Router, Server Components, and modern deployment strategies',
future_improvements: 'Add blog section, improve animations, add dark mode',
translations: [
{
languages_code: 'en-US',
title: 'Portfolio Website',
description: 'A modern, performant portfolio website built with Next.js and Directus CMS',
content: '# Portfolio Website\n\nThis is my personal portfolio built with modern web technologies.\n\n## Features\n\n- 🚀 Fast and performant\n- 🌍 Multilingual (EN/DE)\n- 📱 Fully responsive\n- ♿ Accessible\n- 🎨 Beautiful design\n\n## Tech Stack\n\n- Next.js 14 with App Router\n- TypeScript\n- Tailwind CSS\n- Directus CMS\n- Docker deployment',
meta_description: 'Modern portfolio website showcasing my projects and skills',
keywords: 'portfolio, nextjs, react, typescript, web development'
},
{
languages_code: 'de-DE',
title: 'Portfolio Website',
description: 'Eine moderne, performante Portfolio-Website mit Next.js und Directus CMS',
content: '# Portfolio Website\n\nDies ist mein persönliches Portfolio mit modernen Web-Technologien.\n\n## Features\n\n- 🚀 Schnell und performant\n- 🌍 Mehrsprachig (EN/DE)\n- 📱 Voll responsiv\n- ♿ Barrierefrei\n- 🎨 Schönes Design\n\n## Tech Stack\n\n- Next.js 14 mit App Router\n- TypeScript\n- Tailwind CSS\n- Directus CMS\n- Docker Deployment',
meta_description: 'Moderne Portfolio-Website mit meinen Projekten und Fähigkeiten',
keywords: 'portfolio, nextjs, react, typescript, webentwicklung'
}
]
},
{
slug: 'task-manager-app',
status: 'published',
featured: true,
category: 'Web Application',
difficulty: 'Intermediate',
date: '2023',
github: 'https://github.com/example/task-manager',
image_url: 'https://images.unsplash.com/photo-1484480974693-6ca0a78fb36b?w=800',
tags: ['React', 'Node.js', 'MongoDB', 'REST API'],
technologies: ['React', 'Node.js', 'Express', 'MongoDB', 'JWT'],
challenges: 'Implementing real-time updates and secure authentication',
lessons_learned: 'Learned about WebSockets, JWT authentication, and database optimization',
future_improvements: 'Add team collaboration features, mobile app, calendar integration',
translations: [
{
languages_code: 'en-US',
title: 'Task Manager App',
description: 'A full-stack task management application with real-time updates',
content: '# Task Manager App\n\nA comprehensive task management solution for individuals and teams.\n\n## Features\n\n- ✅ Create and manage tasks\n- 🔔 Real-time notifications\n- 🔐 Secure authentication\n- 📊 Progress tracking\n- 🎨 Customizable categories\n\n## Implementation\n\nBuilt with React on the frontend and Node.js/Express on the backend. Uses MongoDB for data storage and JWT for authentication.',
meta_description: 'Full-stack task management application',
keywords: 'task manager, productivity, react, nodejs, mongodb'
},
{
languages_code: 'de-DE',
title: 'Task Manager App',
description: 'Eine Full-Stack Aufgabenverwaltungs-App mit Echtzeit-Updates',
content: '# Task Manager App\n\nEine umfassende Aufgabenverwaltungslösung für Einzelpersonen und Teams.\n\n## Features\n\n- ✅ Aufgaben erstellen und verwalten\n- 🔔 Echtzeit-Benachrichtigungen\n- 🔐 Sichere Authentifizierung\n- 📊 Fortschrittsverfolgung\n- 🎨 Anpassbare Kategorien\n\n## Implementierung\n\nErstellt mit React im Frontend und Node.js/Express im Backend. Nutzt MongoDB für Datenspeicherung und JWT für Authentifizierung.',
meta_description: 'Full-Stack Aufgabenverwaltungs-Anwendung',
keywords: 'task manager, produktivität, react, nodejs, mongodb'
}
]
},
{
slug: 'weather-dashboard',
status: 'published',
featured: false,
category: 'Web Application',
difficulty: 'Beginner',
date: '2023',
live: 'https://weather-demo.example.com',
image_url: 'https://images.unsplash.com/photo-1504608524841-42fe6f032b4b?w=800',
tags: ['JavaScript', 'API', 'CSS', 'HTML'],
technologies: ['Vanilla JavaScript', 'OpenWeather API', 'CSS Grid', 'LocalStorage'],
challenges: 'Working with external APIs and handling async data',
lessons_learned: 'Learned about API integration, error handling, and responsive design',
future_improvements: 'Add weather forecasts, save favorite locations, add charts',
translations: [
{
languages_code: 'en-US',
title: 'Weather Dashboard',
description: 'A simple weather dashboard showing current weather conditions',
content: '# Weather Dashboard\n\nA clean and simple weather dashboard for checking current conditions.\n\n## Features\n\n- 🌤️ Current weather data\n- 📍 Location search\n- 💾 Save favorite locations\n- 📱 Responsive design\n- 🎨 Clean UI\n\n## Technical Details\n\nBuilt with vanilla JavaScript and the OpenWeather API. Uses CSS Grid for layout and LocalStorage for saving user preferences.',
meta_description: 'Simple weather dashboard application',
keywords: 'weather, dashboard, javascript, api'
},
{
languages_code: 'de-DE',
title: 'Wetter Dashboard',
description: 'Ein einfaches Wetter-Dashboard mit aktuellen Wetterbedingungen',
content: '# Wetter Dashboard\n\nEin übersichtliches Wetter-Dashboard zur Anzeige aktueller Bedingungen.\n\n## Features\n\n- 🌤️ Aktuelle Wetterdaten\n- 📍 Standortsuche\n- 💾 Favoriten speichern\n- 📱 Responsives Design\n- 🎨 Sauberes UI\n\n## Technische Details\n\nErstellt mit Vanilla JavaScript und der OpenWeather API. Nutzt CSS Grid für das Layout und LocalStorage zum Speichern von Benutzereinstellungen.',
meta_description: 'Einfache Wetter-Dashboard Anwendung',
keywords: 'wetter, dashboard, javascript, api'
}
]
}
];
async function addProjects() {
console.log('\n📦 Adding Example Projects to Directus...\n');
for (const projectData of exampleProjects) {
console.log(`\n📁 Creating: ${projectData.translations[0].title}`);
try {
const result = await directusRequest(
'items/projects',
'POST',
projectData
);
if (result) {
console.log(` ✅ Successfully created project: ${projectData.slug}`);
} else {
console.log(` ❌ Failed to create project: ${projectData.slug}`);
}
} catch (error) {
console.error(` ❌ Error creating project ${projectData.slug}:`, error.message);
}
}
console.log('\n✅ Done!\n');
}
addProjects().catch(console.error);