Compare commits
5 Commits
b7b7ac8207
...
0766b46cc8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0766b46cc8 | ||
|
|
92e5b4936e | ||
|
|
99d0d1dba1 | ||
|
|
032568562c | ||
|
|
07741761cc |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
155
CLAUDE.md
Normal 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
|
||||||
10
Dockerfile
10
Dockerfile
@@ -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
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.
|
||||||
51
TODO.md
Normal file
51
TODO.md
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
45
app/api/book-reviews/route.ts
Normal file
45
app/api/book-reviews/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
214
app/components/ReadBooks.tsx
Normal file
214
app/components/ReadBooks.tsx
Normal 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">
|
||||||
|
“{review.review}”
|
||||||
|
</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;
|
||||||
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 */
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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
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",
|
||||||
|
|||||||
178
scripts/add-example-projects.js
Normal file
178
scripts/add-example-projects.js
Normal 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);
|
||||||
Reference in New Issue
Block a user