feat: secure and document book reviews system
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Failing after 10m3s

Added rate limiting to APIs, cleaned up docs, implemented fallback logic for reviews without text, and added comprehensive n8n guide.
This commit is contained in:
2026-02-15 22:32:49 +01:00
parent 0766b46cc8
commit 6998a0e7a1
22 changed files with 3141 additions and 4135 deletions

View File

@@ -8,6 +8,7 @@ Personal portfolio website for Dennis Konkol (dk0.dev). Built with Next.js 15 (A
- **Framework**: Next.js 15 (App Router), TypeScript 5.9 - **Framework**: Next.js 15 (App Router), TypeScript 5.9
- **Styling**: Tailwind CSS 3.4 with custom `liquid-*` color tokens - **Styling**: Tailwind CSS 3.4 with custom `liquid-*` color tokens
- **Theming**: `next-themes` for Dark Mode support (system/light/dark)
- **Animations**: Framer Motion 12 - **Animations**: Framer Motion 12
- **3D**: Three.js + React Three Fiber (shader gradient background) - **3D**: Three.js + React Three Fiber (shader gradient background)
- **Database**: PostgreSQL via Prisma ORM - **Database**: PostgreSQL via Prisma ORM

View File

@@ -1,146 +0,0 @@
# Directus Integration - Migration Guide
## 🎯 Overview
This portfolio now has a **hybrid i18n system**:
-**JSON Files** (Primary) → All translations work from `messages/*.json` files
-**Directus CMS** (Optional) → Can override translations dynamically without rebuilds
**Important**: Directus is **optional**. The app works perfectly fine without it using JSON fallbacks.
## 📁 New File Structure
### Core Infrastructure
- `lib/directus.ts` - REST Client for Directus (uses `de-DE`, `en-US` locale codes)
- `lib/i18n-loader.ts` - Loads texts with Fallback Chain
- `lib/translations-loader.ts` - Batch loader for all sections (cleaned up to match actual usage)
- `types/translations.ts` - TypeScript types for all translation objects (fixed to match components)
### Components
All component wrappers properly load and pass translations to client components.
## 🔄 How It Works
### Without Directus (Default)
```
Component → useTranslations("nav") → JSON File (messages/en.json)
```
### With Directus (Optional)
```
Server Component → getNavTranslations(locale)
→ Try Directus API (de-DE/en-US)
→ If not found: JSON File (de/en)
→ Props to Client Component
```
## 🗄️ Directus Setup (Optional)
Only set this up if you want to edit translations through a CMS without rebuilding the app.
### 1. Environment Variables
Add to `.env.local`:
```bash
DIRECTUS_URL=https://cms.example.com
DIRECTUS_STATIC_TOKEN=your_token_here
```
**If these are not set**, the system will skip Directus and use JSON files only.
### 2. Collection: `messages`
Create a `messages` collection in Directus with these fields:
- `key` (String, required) - e.g., "nav.home"
- `translations` (Translations) - Directus native translations feature
- Configure languages: `en-US` and `de-DE`
**Note**: Keys use dot notation (`nav.home`) but locales use dashes (`en-US`, `de-DE`).
### 3. Permissions
Grant **Public** role read access to `messages` collection.
## 📝 Translation Keys
See `docs/LOCALE_SYSTEM.md` for the complete list of translation keys and their structure.
All keys are organized hierarchically:
- `nav.*` - Navigation items
- `home.hero.*` - Hero section
- `home.about.*` - About section
- `home.projects.*` - Projects section
- `home.contact.*` - Contact form and info
- `footer.*` - Footer content
- `consent.*` - Privacy consent banner
## 🎨 Rich Text Content
For longer content that needs formatting (bold, italic, lists), use the `content_pages` collection:
### Collection: `content_pages` (Optional)
Fields:
- `slug` (String, unique) - e.g., "home-hero"
- `locale` (String) - `en` or `de`
- `title` (String)
- `content` (Rich Text or Long Text)
Examples:
- `home-hero` - Hero section description
- `home-about` - About section content
- `home-contact` - Contact intro text
Components fetch these via `/api/content/page` and render using `RichTextClient`.
## 🔍 Fallback Chain
For every translation key, the system searches in this order:
1. **Directus** (if configured) in requested locale (e.g., `de-DE`)
2. **Directus** in English fallback (e.g., `en-US`)
3. **JSON file** in requested locale (e.g., `messages/de.json`)
4. **JSON file** in English (e.g., `messages/en.json`)
5. **Key itself** as last resort (e.g., returns `"nav.home"`)
## ✅ What Was Fixed
Previous issues that have been resolved:
1.**Type mismatches** - All translation types now match actual component usage
2.**Unused fields** - Removed translation keys that were never used (like `hero.greeting`, `hero.name`)
3.**Wrong structure** - Fixed `AboutTranslations` structure (removed fake `interests` nesting)
4.**Missing keys** - Aligned loaders with JSON files and actual component requirements
5.**Confusing comments** - Removed misleading comments in `translations-loader.ts`
## 🎯 Best Practices
1. **Always maintain JSON files** - Even if using Directus, keep JSON files as fallback
2. **Use types** - TypeScript types ensure correct usage
3. **Test without Directus** - App should work perfectly without CMS configured
4. **Rich text for formatting** - Use `content_pages` for content that needs bold/italic/lists
5. **JSON for UI labels** - Use JSON/messages for short UI strings like buttons and labels
## 🐛 Troubleshooting
### Directus not configured
**This is normal!** The app works fine. All translations come from JSON files.
### Want to use Directus?
1. Set up `DIRECTUS_URL` and `DIRECTUS_STATIC_TOKEN`
2. Create `messages` collection
3. Add your translations
4. They will override JSON values
### Translation not showing?
Check in this order:
1. Does key exist in `messages/en.json`?
2. Is the key spelled correctly?
3. Is component using correct namespace?
## 📚 Further Reading
- **Complete locale documentation**: `docs/LOCALE_SYSTEM.md`
- **Directus setup checklist**: `DIRECTUS_CHECKLIST.md`
- **Operations guide**: `docs/OPERATIONS.md`

View File

@@ -1,47 +0,0 @@
# GEMINI.md - Portfolio Project Guide
## Project Overview
Personal portfolio for Dennis Konkol (dk0.dev). A modern, high-performance Next.js 15 application featuring a "liquid" design system, integrated with Directus CMS and n8n for real-time status and content management.
## Tech Stack & Architecture
- **Framework**: Next.js 15 (App Router), TypeScript, React 19.
- **UI/UX**: Tailwind CSS 3.4, Framer Motion 12, Three.js (Background).
- **Backend/Data**: PostgreSQL (Prisma), Redis (Caching), Directus (CMS), n8n (Automation).
- **i18n**: next-intl (German/English).
## Core Principles for Gemini
- **Safe Failovers**: Always implement fallbacks for external APIs (Directus, n8n). The site must remain functional even if all external services are down.
- **Liquid Design**: Use custom `liquid-*` color tokens for consistency.
- **Performance**: Favor Server Components where possible; use `use client` only for interactivity.
- **Code Style**: clean, modular, and well-typed. Use functional components and hooks.
- **i18n first**: Never hardcode user-facing strings; always use `messages/*.json`.
## Common Workflows
### API Route Pattern
API routes should include:
- Rate limiting (via `lib/auth.ts`)
- Timeout protection
- Proper error handling with logging in development
- Type-safe responses
### Component Pattern
- Use Framer Motion for entrance animations.
- Use `next/image` for all images to ensure optimization.
- Follow the `glassmorphism` aesthetic: `backdrop-blur-sm`, subtle borders, and gradient backgrounds.
## Development Commands
- `npm run dev`: Full development environment.
- `npm run lint`: Run ESLint checks.
- `npm run test`: Run unit tests.
- `npm run test:e2e`: Run Playwright E2E tests.
## Environment Variables (Key)
- `DIRECTUS_URL` & `DIRECTUS_STATIC_TOKEN`: CMS connectivity.
- `N8N_WEBHOOK_URL` & `N8N_SECRET_TOKEN`: Automation connectivity.
- `DATABASE_URL`: Prisma connection string.
## Git Workflow
- Work on the `dev` branch.
- Use conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`.
- Push to both GitHub and Gitea remotes.

View File

@@ -1,324 +0,0 @@
# 🚀 Safe Push to Main Branch Guide
**IMPORTANT**: This guide ensures you don't break production when merging to main.
## ⚠️ Pre-Flight Checklist
Before even thinking about pushing to main, verify ALL of these:
### 1. Code Quality ✅
```bash
# Run all checks
npm run build # Must pass with 0 errors
npm run lint # Must pass with 0 errors
npx tsc --noEmit # TypeScript must be clean
npx prisma format # Database schema must be valid
```
### 1b. Automated Testing ✅
```bash
# Run comprehensive test suite (RECOMMENDED)
npm run test:all # Runs all tests including E2E
# Or run individually:
npm run test # Unit tests
npm run test:critical # Critical path E2E tests
npm run test:hydration # Hydration tests
npm run test:email # Email API tests
```
### 2. Testing ✅
```bash
# Automated testing (RECOMMENDED)
npm run test:all # Runs all automated tests
# Manual testing (if needed)
npm run dev
# Test these critical paths:
# - Home page loads
# - Projects page works
# - Admin dashboard accessible
# - API endpoints respond
# - No console errors
# - No hydration errors
```
### 3. Database Changes ✅
```bash
# If you changed the database schema:
# 1. Create migration
npx prisma migrate dev --name your_migration_name
# 2. Test migration on a copy of production data
# 3. Document migration steps
# 4. Create rollback plan
```
### 4. Environment Variables ✅
- [ ] All new env vars documented in `env.example`
- [ ] No secrets committed to git
- [ ] Production env vars are set on server
- [ ] Optional features have fallbacks
### 5. Breaking Changes ✅
- [ ] Documented in CHANGELOG
- [ ] Backward compatible OR migration plan exists
- [ ] Team notified of changes
---
## 📋 Step-by-Step Push Process
### Step 1: Ensure You're on Dev Branch
```bash
git checkout dev
git pull origin dev # Get latest changes
```
### Step 2: Final Verification
```bash
# Clean build
rm -rf .next node_modules/.cache
npm install
npm run build
# Should complete without errors
```
### Step 3: Review Your Changes
```bash
# See what you're about to push
git log origin/main..dev --oneline
git diff origin/main..dev
# Review carefully:
# - No accidental secrets
# - No debug code
# - No temporary files
# - All changes are intentional
```
### Step 4: Create a Backup Branch (Safety Net)
```bash
# Create backup before merging
git checkout -b backup-before-main-merge-$(date +%Y%m%d)
git push origin backup-before-main-merge-$(date +%Y%m%d)
git checkout dev
```
### Step 5: Merge Dev into Main (Local)
```bash
# Switch to main
git checkout main
git pull origin main # Get latest main
# Merge dev into main
git merge dev --no-ff -m "Merge dev into main: [describe changes]"
# If conflicts occur:
# 1. Resolve conflicts carefully
# 2. Test after resolving
# 3. Don't force push if unsure
```
### Step 6: Test the Merged Code
```bash
# Build and test the merged code
npm run build
npm run dev
# Test critical paths again
# - Home page
# - Projects
# - Admin
# - APIs
```
### Step 7: Push to Main (If Everything Looks Good)
```bash
# Push to remote main
git push origin main
# If you need to force push (DANGEROUS - only if necessary):
# git push origin main --force-with-lease
```
### Step 8: Monitor Deployment
```bash
# Watch your deployment logs
# Check for errors
# Verify health endpoints
# Test production site
```
---
## 🛡️ Safety Strategies
### Strategy 1: Feature Flags
If you're adding new features, use feature flags:
```typescript
// In your code
if (process.env.ENABLE_NEW_FEATURE === 'true') {
// New feature code
}
```
### Strategy 2: Gradual Rollout
- Deploy to staging first
- Test thoroughly
- Then deploy to production
- Monitor closely
### Strategy 3: Database Migrations
```bash
# Always test migrations first
# 1. Backup production database
# 2. Test migration on copy
# 3. Create rollback script
# 4. Run migration during low-traffic period
```
### Strategy 4: Rollback Plan
Always have a rollback plan:
```bash
# If something breaks:
git revert HEAD
git push origin main
# Or rollback to previous commit:
git reset --hard <previous-commit-hash>
git push origin main --force-with-lease
```
---
## 🚨 Red Flags - DON'T PUSH IF:
- ❌ Build fails
- ❌ Tests fail
- ❌ Linter errors
- ❌ TypeScript errors
- ❌ Database migration not tested
- ❌ Breaking changes not documented
- ❌ Secrets in code
- ❌ Debug code left in
- ❌ Console.logs everywhere
- ❌ Untested features
- ❌ No rollback plan
---
## ✅ Green Lights - SAFE TO PUSH IF:
- ✅ All checks pass
- ✅ Tested locally
- ✅ Database migrations tested
- ✅ No breaking changes (or documented)
- ✅ Documentation updated
- ✅ Team notified
- ✅ Rollback plan exists
- ✅ Feature flags for new features
- ✅ Environment variables documented
---
## 📝 Pre-Push Checklist Template
Copy this and check each item:
```
[ ] npm run build passes
[ ] npm run lint passes
[ ] npx tsc --noEmit passes
[ ] npx prisma format passes
[ ] npm run test:all passes (automated tests)
[ ] OR manual testing:
[ ] Dev server starts without errors
[ ] Home page loads correctly
[ ] Projects page works
[ ] Admin dashboard accessible
[ ] API endpoints respond
[ ] No console errors
[ ] No hydration errors
[ ] Database migrations tested (if any)
[ ] Environment variables documented
[ ] No secrets in code
[ ] Breaking changes documented
[ ] CHANGELOG updated
[ ] Team notified (if needed)
[ ] Rollback plan exists
[ ] Backup branch created
[ ] Changes reviewed
```
---
## 🔄 Alternative: Pull Request Workflow
If you want extra safety, use PR workflow:
```bash
# 1. Push dev branch
git push origin dev
# 2. Create Pull Request on Git platform
# - Review changes
# - Get approval
# - Run CI/CD checks
# 3. Merge PR to main (platform handles it)
```
---
## 🆘 Emergency Rollback
If production breaks after push:
### Quick Rollback
```bash
# 1. Revert the merge commit
git revert -m 1 <merge-commit-hash>
git push origin main
# 2. Or reset to previous state
git reset --hard <previous-commit>
git push origin main --force-with-lease
```
### Database Rollback
```bash
# If you ran migrations, roll them back:
npx prisma migrate resolve --rolled-back <migration-name>
# Or restore from backup
```
---
## 📞 Need Help?
If unsure:
1. **Don't push** - better safe than sorry
2. Test more thoroughly
3. Ask for code review
4. Use staging environment first
5. Create a PR for review
---
## 🎯 Best Practices
1. **Always test locally first**
2. **Use feature flags for new features**
3. **Test database migrations on copies**
4. **Document everything**
5. **Have a rollback plan**
6. **Monitor after deployment**
7. **Deploy during low-traffic periods**
8. **Keep main branch stable**
---
**Remember**: It's better to delay a push than to break production! 🛡️

View File

@@ -1,120 +0,0 @@
# 🔒 Security Improvements
## Implemented Security Features
### 1. n8n API Endpoint Protection
All n8n endpoints are now protected with:
- **Authentication**: Admin authentication required for sensitive endpoints (`/api/n8n/generate-image`)
- **Rate Limiting**:
- `/api/n8n/generate-image`: 10 requests/minute
- `/api/n8n/chat`: 20 requests/minute
- `/api/n8n/status`: 30 requests/minute
### 2. Email Obfuscation
Email addresses can now be obfuscated to prevent automated scraping:
```typescript
import { createObfuscatedMailto } from '@/lib/email-obfuscate';
import { ObfuscatedEmail } from '@/components/ObfuscatedEmail';
// React component
<ObfuscatedEmail email="contact@dk0.dev">Contact Me</ObfuscatedEmail>
// HTML string
const mailtoLink = createObfuscatedMailto('contact@dk0.dev', 'Email Me');
```
**How it works:**
- Emails are base64 encoded in the HTML
- JavaScript decodes them on click
- Prevents simple regex-based email scrapers
- Still functional for real users
### 3. URL Obfuscation
Sensitive URLs can be obfuscated:
```typescript
import { createObfuscatedLink } from '@/lib/email-obfuscate';
const link = createObfuscatedLink('https://sensitive-url.com', 'Click Here');
```
### 4. Rate Limiting
All API endpoints have rate limiting:
- Prevents brute force attacks
- Protects against DDoS
- Configurable per endpoint
## Code Obfuscation
**Note**: Full code obfuscation for Next.js is **not recommended** because:
1. **Next.js already minifies code** in production builds
2. **Obfuscation breaks source maps** (harder to debug)
3. **Performance impact** (slower execution)
4. **Not effective** - determined attackers can still reverse engineer
5. **Maintenance burden** - harder to debug issues
**Better alternatives:**
- ✅ Minification (already enabled in Next.js)
- ✅ Environment variables for secrets
- ✅ Server-side rendering (code not exposed)
- ✅ API authentication
- ✅ Rate limiting
- ✅ Security headers
## Best Practices
### For Email Protection:
1. Use obfuscated emails in public HTML
2. Use contact forms instead of direct mailto links
3. Monitor for spam patterns
### For API Protection:
1. Always require authentication for sensitive endpoints
2. Use rate limiting
3. Log suspicious activity
4. Use HTTPS only
5. Validate all inputs
### For Webhook Protection:
1. Use secret tokens (`N8N_SECRET_TOKEN`)
2. Verify webhook signatures
3. Rate limit webhook endpoints
4. Monitor webhook usage
## Implementation Status
- ✅ n8n endpoints protected with auth + rate limiting
- ✅ Email obfuscation utility created
- ✅ URL obfuscation utility created
- ✅ Rate limiting on all n8n endpoints
- ⚠️ Email obfuscation not yet applied to pages (manual step)
- ⚠️ Code obfuscation not implemented (not recommended)
## Next Steps
To apply email obfuscation to your pages:
1. Import the utility:
```typescript
import { ObfuscatedEmail } from '@/lib/email-obfuscate';
```
2. Replace email links:
```tsx
// Before
<a href="mailto:contact@dk0.dev">Contact</a>
// After
<ObfuscatedEmail email="contact@dk0.dev">Contact</ObfuscatedEmail>
```
3. For static HTML, use the string function:
```typescript
const html = createObfuscatedMailto('contact@dk0.dev', 'Email Me');
```

67
TODO.md
View File

@@ -1,51 +1,28 @@
# TODO - Portfolio Roadmap # Portfolio Roadmap
## Book Reviews (Neu) ## Completed ✅
- [ ] **Directus Collection erstellen**: `book_reviews` mit Feldern: - [x] **Dark Mode Support**: `next-themes` integration, `ThemeToggle` component, and dark mode styles.
- `status` (draft/published) - [x] **Performance**: Replaced `<img>` with Next.js `<Image>` for optimization.
- `book_title` (String) - [x] **SEO**: Added JSON-LD Structured Data for projects.
- `book_author` (String) - [x] **Security**: Rate limiting added to `book-reviews`, `hobbies`, and `tech-stack` APIs.
- `book_image` (String, URL zum Cover) - [x] **Book Reviews**:
- `rating` (Integer, 1-5) - `ReadBooks` component updated to handle optional ratings/reviews.
- `hardcover_id` (String, optional) - `CurrentlyReading` component verified.
- `finished_at` (Datetime, optional) - Automation guide created (`docs/N8N_HARDCOVER_GUIDE.md`).
- Translations: `review` (Text) + `languages_code` (FK) - [x] **Testing**: Added tests for `book-reviews`, `hobbies`, `tech-stack`, `CurrentlyReading`, and `ThemeToggle`.
- [ ] **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 ## Next Steps
- [ ] Messages Collection: `messages` mit key + translations (ersetzt `messages/*.json`) ### Directus CMS
- [ ] Projects vollständig zu Directus migrieren (`node scripts/migrate-projects-to-directus.js`) - [ ] **Messages Collection**: Create `messages` collection in Directus for dynamic i18n (currently using `messages/*.json`).
- [ ] Directus Webhooks einrichten: On-Demand ISR Revalidation bei Content-Änderungen - [ ] **Projects Migration**: Finish migrating projects content to Directus (script exists: `scripts/migrate-projects-to-directus.js`).
- [ ] Directus Roles: Public Read Token, Admin Write - [ ] **Webhooks**: Configure Directus webhooks for On-Demand ISR Revalidation.
## n8n Integrationen ### Features
- [ ] **Blog/Articles**: Design and implement the blog section.
- [ ] **Project Detail Gallery**: Add a lightbox/gallery for project screenshots.
- [ ] Hardcover "Read Books" Webhook: Gelesene Bücher automatisch in Directus importieren ### DevOps
- [ ] Spotify Now Playing verbessern: Album-Art Caching - [ ] **GitHub Actions**: Migrate CI/CD fully to GitHub Actions (from Gitea).
- [ ] Discord Rich Presence: Gaming-Status automatisch aktualisieren - [ ] **Docker Optimization**: Further reduce image size.
## 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

@@ -0,0 +1,102 @@
import { render, screen, waitFor } from "@testing-library/react";
import CurrentlyReading from "@/app/components/CurrentlyReading";
// Mock next-intl
jest.mock("next-intl", () => ({
useTranslations: () => (key: string) => {
const translations: Record<string, string> = {
title: "Reading",
progress: "Progress",
};
return translations[key] || key;
},
}));
// Mock next/image
jest.mock("next/image", () => ({
__esModule: true,
default: (props: any) => <img {...props} />,
}));
// Mock fetch
global.fetch = jest.fn();
describe("CurrentlyReading Component", () => {
beforeEach(() => {
jest.clearAllMocks();
});
it("renders nothing when loading", () => {
// Return a never-resolving promise to simulate loading state
(global.fetch as jest.Mock).mockReturnValue(new Promise(() => {}));
const { container } = render(<CurrentlyReading />);
expect(container).toBeEmptyDOMElement();
});
it("renders nothing when no books are returned", async () => {
(global.fetch as jest.Mock).mockResolvedValue({
ok: true,
json: async () => ({ currentlyReading: null }),
});
const { container } = render(<CurrentlyReading />);
await waitFor(() => expect(global.fetch).toHaveBeenCalled());
expect(container).toBeEmptyDOMElement();
});
it("renders a book when data is fetched", async () => {
const mockBook = {
title: "Test Book",
authors: ["Test Author"],
image: "/test-image.jpg",
progress: 50,
startedAt: "2023-01-01",
};
(global.fetch as jest.Mock).mockResolvedValue({
ok: true,
json: async () => ({ currentlyReading: mockBook }),
});
render(<CurrentlyReading />);
await waitFor(() => {
expect(screen.getByText("Reading (1)")).toBeInTheDocument();
expect(screen.getByText("Test Book")).toBeInTheDocument();
expect(screen.getByText("Test Author")).toBeInTheDocument();
expect(screen.getByText("50%")).toBeInTheDocument();
});
});
it("renders multiple books correctly", async () => {
const mockBooks = [
{
title: "Book 1",
authors: ["Author 1"],
image: "/img1.jpg",
progress: 10,
startedAt: "2023-01-01",
},
{
title: "Book 2",
authors: ["Author 2"],
image: "/img2.jpg",
progress: 90,
startedAt: "2023-02-01",
},
];
(global.fetch as jest.Mock).mockResolvedValue({
ok: true,
json: async () => ({ currentlyReading: mockBooks }),
});
render(<CurrentlyReading />);
await waitFor(() => {
expect(screen.getByText("Reading (2)")).toBeInTheDocument();
expect(screen.getByText("Book 1")).toBeInTheDocument();
expect(screen.getByText("Book 2")).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,53 @@
import React from "react";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { ThemeToggle } from "@/app/components/ThemeToggle";
import { useTheme } from "next-themes";
// Mock next-themes
jest.mock("next-themes", () => ({
useTheme: jest.fn(),
}));
describe("ThemeToggle Component", () => {
const setThemeMock = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
(useTheme as jest.Mock).mockReturnValue({
theme: "light",
setTheme: setThemeMock,
});
});
it("renders a placeholder initially (to avoid hydration mismatch)", () => {
const { container } = render(<ThemeToggle />);
// Initial render should be the loading div
expect(container.firstChild).toHaveClass("w-9 h-9");
});
it("toggles to dark mode when clicked", async () => {
render(<ThemeToggle />);
// Wait for effect to set mounted=true
const button = await screen.findByRole("button", { name: /toggle theme/i });
fireEvent.click(button);
expect(setThemeMock).toHaveBeenCalledWith("dark");
});
it("toggles to light mode when clicked if currently dark", async () => {
(useTheme as jest.Mock).mockReturnValue({
theme: "dark",
setTheme: setThemeMock,
});
render(<ThemeToggle />);
const button = await screen.findByRole("button", { name: /toggle theme/i });
fireEvent.click(button);
expect(setThemeMock).toHaveBeenCalledWith("light");
});
});

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { getBookReviews } from '@/lib/directus'; import { getBookReviews } from '@/lib/directus';
import { checkRateLimit, getClientIp } from '@/lib/auth';
export const runtime = 'nodejs'; export const runtime = 'nodejs';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -13,6 +14,12 @@ export const dynamic = 'force-dynamic';
* - locale: en or de (default: en) * - locale: en or de (default: en)
*/ */
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
// Rate Limit: 60 requests per minute
const ip = getClientIp(request);
if (!checkRateLimit(ip, 60, 60000)) {
return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 });
}
try { try {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const locale = searchParams.get('locale') || 'en'; const locale = searchParams.get('locale') || 'en';

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { getHobbies } from '@/lib/directus'; import { getHobbies } from '@/lib/directus';
import { checkRateLimit, getClientIp } from '@/lib/auth';
export const runtime = 'nodejs'; export const runtime = 'nodejs';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -13,6 +14,12 @@ export const dynamic = 'force-dynamic';
* - locale: en or de (default: en) * - locale: en or de (default: en)
*/ */
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
// Rate Limit: 60 requests per minute
const ip = getClientIp(request);
if (!checkRateLimit(ip, 60, 60000)) {
return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 });
}
try { try {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const locale = searchParams.get('locale') || 'en'; const locale = searchParams.get('locale') || 'en';

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { getTechStack } from '@/lib/directus'; import { getTechStack } from '@/lib/directus';
import { checkRateLimit, getClientIp } from '@/lib/auth';
export const runtime = 'nodejs'; export const runtime = 'nodejs';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -13,6 +14,12 @@ export const dynamic = 'force-dynamic';
* - locale: en or de (default: en) * - locale: en or de (default: en)
*/ */
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
// Rate Limit: 60 requests per minute
const ip = getClientIp(request);
if (!checkRateLimit(ip, 60, 60000)) {
return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 });
}
try { try {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const locale = searchParams.get('locale') || 'en'; const locale = searchParams.get('locale') || 'en';

View File

@@ -63,8 +63,8 @@ const CurrentlyReading = () => {
<div className="space-y-4"> <div className="space-y-4">
{/* Header */} {/* Header */}
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2 mb-4">
<BookOpen size={18} className="text-stone-600 flex-shrink-0" /> <BookOpen size={18} className="text-stone-600 dark:text-stone-300 flex-shrink-0" />
<h3 className="text-lg font-bold text-stone-900"> <h3 className="text-lg font-bold text-stone-900 dark:text-stone-100">
{t("title")} {books.length > 1 && `(${books.length})`} {t("title")} {books.length > 1 && `(${books.length})`}
</h3> </h3>
</div> </div>
@@ -81,11 +81,11 @@ const CurrentlyReading = () => {
scale: 1.02, scale: 1.02,
transition: { duration: 0.4, ease: "easeOut" }, transition: { duration: 0.4, ease: "easeOut" },
}} }}
className="relative overflow-hidden bg-gradient-to-br from-liquid-lavender/15 via-liquid-pink/10 to-liquid-rose/15 border-2 border-liquid-lavender/30 rounded-xl p-6 backdrop-blur-sm hover:border-liquid-lavender/50 hover:from-liquid-lavender/20 hover:via-liquid-pink/15 hover:to-liquid-rose/20 transition-all duration-500 ease-out" className="relative overflow-hidden bg-gradient-to-br from-liquid-lavender/15 via-liquid-pink/10 to-liquid-rose/15 dark:from-stone-800 dark:via-stone-800 dark:to-stone-700 border-2 border-liquid-lavender/30 dark:border-stone-700 rounded-xl p-6 backdrop-blur-sm hover:border-liquid-lavender/50 dark:hover:border-stone-600 hover:from-liquid-lavender/20 hover:via-liquid-pink/15 hover:to-liquid-rose/20 transition-all duration-500 ease-out"
> >
{/* Background Blob Animation */} {/* Background Blob Animation */}
<motion.div <motion.div
className="absolute -top-10 -right-10 w-32 h-32 bg-gradient-to-br from-liquid-lavender/20 to-liquid-pink/20 rounded-full blur-2xl" className="absolute -top-10 -right-10 w-32 h-32 bg-gradient-to-br from-liquid-lavender/20 to-liquid-pink/20 dark:from-stone-700 dark:to-stone-600 rounded-full blur-2xl"
animate={{ animate={{
scale: [1, 1.2, 1], scale: [1, 1.2, 1],
opacity: [0.3, 0.5, 0.3], opacity: [0.3, 0.5, 0.3],
@@ -107,7 +107,7 @@ const CurrentlyReading = () => {
transition={{ duration: 0.5, delay: 0.2 + index * 0.1 }} transition={{ duration: 0.5, delay: 0.2 + index * 0.1 }}
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 dark:border-stone-600">
<Image <Image
src={book.image} src={book.image}
alt={book.title} alt={book.title}
@@ -124,22 +124,22 @@ const CurrentlyReading = () => {
{/* Book Info */} {/* Book Info */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
{/* Title */} {/* Title */}
<h4 className="text-lg font-bold text-stone-900 mb-1 line-clamp-2"> <h4 className="text-lg font-bold text-stone-900 dark:text-stone-100 mb-1 line-clamp-2">
{book.title} {book.title}
</h4> </h4>
{/* Authors */} {/* Authors */}
<p className="text-sm text-stone-600 mb-4 line-clamp-1"> <p className="text-sm text-stone-600 dark:text-stone-400 mb-4 line-clamp-1">
{book.authors.join(", ")} {book.authors.join(", ")}
</p> </p>
{/* Progress Bar */} {/* Progress Bar */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between text-xs text-stone-600"> <div className="flex items-center justify-between text-xs text-stone-600 dark:text-stone-400">
<span>{t("progress")}</span> <span>{t("progress")}</span>
<span className="font-semibold">{book.progress}%</span> <span className="font-semibold">{book.progress}%</span>
</div> </div>
<div className="relative h-2 bg-white/50 rounded-full overflow-hidden border border-white/70"> <div className="relative h-2 bg-white/50 dark:bg-stone-700 rounded-full overflow-hidden border border-white/70 dark:border-stone-600">
<motion.div <motion.div
initial={{ width: 0 }} initial={{ width: 0 }}
animate={{ width: `${book.progress}%` }} animate={{ width: `${book.progress}%` }}

View File

@@ -12,8 +12,8 @@ interface BookReview {
book_title: string; book_title: string;
book_author: string; book_author: string;
book_image?: string; book_image?: string;
rating: number; rating?: number | null;
review?: string; review?: string | null;
finished_at?: string; finished_at?: string;
} }
@@ -27,7 +27,7 @@ const StarRating = ({ rating }: { rating: number }) => {
className={ className={
star <= rating star <= rating
? "text-amber-500 fill-amber-500" ? "text-amber-500 fill-amber-500"
: "text-stone-300" : "text-stone-300 dark:text-stone-600"
} }
/> />
))} ))}
@@ -86,8 +86,8 @@ const ReadBooks = () => {
<div className="space-y-4"> <div className="space-y-4">
{/* Header */} {/* Header */}
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2 mb-4">
<BookCheck size={18} className="text-stone-600 flex-shrink-0" /> <BookCheck size={18} className="text-stone-600 dark:text-stone-300 flex-shrink-0" />
<h3 className="text-lg font-bold text-stone-900"> <h3 className="text-lg font-bold text-stone-900 dark:text-stone-100">
{t("title")} ({reviews.length}) {t("title")} ({reviews.length})
</h3> </h3>
</div> </div>
@@ -108,11 +108,11 @@ const ReadBooks = () => {
scale: 1.02, scale: 1.02,
transition: { duration: 0.4, ease: "easeOut" }, 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" className="relative overflow-hidden bg-gradient-to-br from-liquid-mint/15 via-liquid-sky/10 to-liquid-teal/15 dark:from-stone-800 dark:via-stone-800 dark:to-stone-700 border-2 border-liquid-mint/30 dark:border-stone-700 rounded-xl p-5 backdrop-blur-sm hover:border-liquid-mint/50 dark:hover:border-stone-600 hover:from-liquid-mint/20 hover:via-liquid-sky/15 hover:to-liquid-teal/20 transition-all duration-500 ease-out"
> >
{/* Background Blob */} {/* Background Blob */}
<motion.div <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" className="absolute -bottom-8 -left-8 w-28 h-28 bg-gradient-to-br from-liquid-mint/20 to-liquid-sky/20 dark:from-stone-700 dark:to-stone-600 rounded-full blur-2xl"
animate={{ animate={{
scale: [1, 1.15, 1], scale: [1, 1.15, 1],
opacity: [0.3, 0.45, 0.3], opacity: [0.3, 0.45, 0.3],
@@ -134,7 +134,7 @@ const ReadBooks = () => {
transition={{ duration: 0.5, delay: 0.2 + index * 0.1 }} transition={{ duration: 0.5, delay: 0.2 + index * 0.1 }}
className="flex-shrink-0" className="flex-shrink-0"
> >
<div className="relative w-20 h-[7.5rem] sm:w-24 sm:h-32 rounded-lg overflow-hidden shadow-lg border-2 border-white/50"> <div className="relative w-20 h-[7.5rem] sm:w-24 sm:h-32 rounded-lg overflow-hidden shadow-lg border-2 border-white/50 dark:border-stone-600">
<Image <Image
src={review.book_image} src={review.book_image}
alt={review.book_title} alt={review.book_title}
@@ -149,31 +149,33 @@ const ReadBooks = () => {
{/* Book Info */} {/* Book Info */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h4 className="text-base font-bold text-stone-900 mb-0.5 line-clamp-2"> <h4 className="text-base font-bold text-stone-900 dark:text-stone-100 mb-0.5 line-clamp-2">
{review.book_title} {review.book_title}
</h4> </h4>
<p className="text-sm text-stone-600 mb-2 line-clamp-1"> <p className="text-sm text-stone-600 dark:text-stone-400 mb-2 line-clamp-1">
{review.book_author} {review.book_author}
</p> </p>
{/* Rating */} {/* Rating (Optional) */}
{review.rating && review.rating > 0 && (
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<StarRating rating={review.rating} /> <StarRating rating={review.rating} />
<span className="text-xs text-stone-500 font-medium"> <span className="text-xs text-stone-500 dark:text-stone-400 font-medium">
{review.rating}/5 {review.rating}/5
</span> </span>
</div> </div>
)}
{/* Review Text */} {/* Review Text (Optional) */}
{review.review && ( {review.review && (
<p className="text-sm text-stone-700 leading-relaxed line-clamp-3 italic"> <p className="text-sm text-stone-700 dark:text-stone-300 leading-relaxed line-clamp-3 italic">
&ldquo;{review.review}&rdquo; &ldquo;{review.review}&rdquo;
</p> </p>
)} )}
{/* Finished Date */} {/* Finished Date */}
{review.finished_at && ( {review.finished_at && (
<p className="text-xs text-stone-400 mt-2"> <p className="text-xs text-stone-400 dark:text-stone-500 mt-2">
{t("finishedAt")}{" "} {t("finishedAt")}{" "}
{new Date(review.finished_at).toLocaleDateString( {new Date(review.finished_at).toLocaleDateString(
locale === "de" ? "de-DE" : "en-US", locale === "de" ? "de-DE" : "en-US",
@@ -193,7 +195,7 @@ const ReadBooks = () => {
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
transition={{ delay: 0.3 }} transition={{ delay: 0.3 }}
onClick={() => setExpanded(!expanded)} 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" className="w-full flex items-center justify-center gap-1.5 py-2.5 text-sm font-medium text-stone-600 dark:text-stone-400 hover:text-stone-800 dark:hover:text-stone-200 rounded-lg border-2 border-dashed border-stone-200 dark:border-stone-700 hover:border-stone-300 dark:hover:border-stone-600 transition-colors duration-300"
> >
{expanded ? ( {expanded ? (
<> <>

View File

@@ -0,0 +1,99 @@
# Automatisierung: Gelesene Bücher (Hardcover → Directus)
Diese Anleitung erklärt, wie du n8n einrichtest, damit Bücher, die du auf Hardcover als "Read" markierst, automatisch in deinem Directus CMS landen.
## Ziel
- **Quelle:** Hardcover (Status: Read)
- **Ziel:** Directus (Collection: `book_reviews`)
- **Verhalten:**
- Buch wird automatisch angelegt.
- Status wird auf `draft` gesetzt (damit du optional eine Bewertung/Review schreiben kannst).
- Wenn du keine Review schreiben willst, kannst du den Status im n8n Workflow direkt auf `published` setzen.
## Voraussetzungen
1. **n8n Instanz** (self-hosted oder Cloud).
2. **Directus URL & Token** (Admin oder Token mit Schreibrechten auf `book_reviews`).
3. **Hardcover Account** (GraphQL API Zugriff).
## Schritt-für-Schritt Einrichtung
### 1. Directus Collection `book_reviews` vorbereiten
Stelle sicher, dass deine Collection in Directus folgende Felder hat (nullable = optional):
- `status` (String: `draft`, `published`, `archived`)
- `book_title` (String, required)
- `book_author` (String, required)
- `book_image` (String, URL)
- `rating` (Integer, nullable, 1-5)
- `hardcover_id` (String, unique, um Duplikate zu vermeiden)
- `finished_at` (Date, wann du es gelesen hast)
- `review` (Text/Markdown, nullable - DEINE Meinung)
### 2. n8n Workflow erstellen
Erstelle einen neuen Workflow in n8n.
#### Node 1: Trigger (Zeitgesteuert)
- **Typ:** `Schedule Trigger`
- **Intervall:** Alle 60 Minuten (oder wie oft du willst).
#### Node 2: Hardcover API Abfrage
- **Typ:** `GraphQL` (oder `HTTP Request` POST an `https://api.hardcover.app/graphql`)
- **Query:**
```graphql
query {
me {
books_read(limit: 5, order_by: {finished_at: desc}) {
finished_at
book {
id
title
contributions {
author {
name
}
}
images {
url
}
}
}
}
```
- **Auth:** Bearer Token (Dein Hardcover API Key).
#### Node 3: Auf neue Bücher prüfen
- **Typ:** `Function` / `Code`
- **Logik:** Vergleiche die `id` von Hardcover mit den `hardcover_id`s, die schon in Directus sind (du musst vorher eine Directus Abfrage machen, um existierende IDs zu holen).
- **Ziel:** Filtere Bücher heraus, die schon importiert wurden.
#### Node 4: Buch in Directus anlegen
- **Typ:** `Directus` (oder `HTTP Request` POST an dein Directus)
- **Resource:** `Items` -> `book_reviews` -> `Create`
- **Mapping:**
- `book_title`: `{{ $json.book.title }}`
- `book_author`: `{{ $json.book.contributions[0].author.name }}`
- `book_image`: `{{ $json.book.images[0].url }}`
- `hardcover_id`: `{{ $json.book.id }}`
- `finished_at`: `{{ $json.finished_at }}`
- `status`: `draft` (oder `published` wenn du es sofort live haben willst)
- `rating`: `null` (das füllst du dann manuell in Directus aus!)
- `review`: `null` (das schreibst du dann manuell in Directus!)
### 3. Workflow aktivieren
- Teste den Workflow einmal manuell.
- Aktiviere ihn ("Active" Switch oben rechts).
## Workflow: Bewertung schreiben (Optional)
1. Das Buch erscheint automatisch in Directus als `draft`.
2. Du bekommst (optional) eine Benachrichtigung (via n8n -> Email/Discord/Telegram).
3. Du loggst dich in Directus ein.
4. Du öffnest das Buch.
5. **Möchtest du bewerten?**
- Ja: Gib `rating` (1-5) und `review` Text ein. Setze Status auf `published`.
- Nein, nur auflisten: Lass `rating` leer. Setze Status auf `published`.
## Frontend Logik (Code Anpassung)
Der Code im Frontend (`ReadBooks.tsx`) ist bereits so gebaut, dass er:
- Bücher anzeigt, die `status: published` haben.
- Wenn `rating` vorhanden ist, werden Sterne angezeigt.
- Wenn `review` vorhanden ist, wird der Text angezeigt.
- Wenn beides fehlt, wird das Buch einfach nur als "Gelesen" aufgelistet (Cover + Titel + Autor).

6226
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -116,7 +116,7 @@
"postcss": "^8.4.49", "postcss": "^8.4.49",
"prisma": "^5.22.0", "prisma": "^5.22.0",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"ts-jest": "^29.2.5", "ts-jest": "^29.4.6",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsx": "^4.20.5", "tsx": "^4.20.5",
"typescript": "5.9.3", "typescript": "5.9.3",