feat: secure and document book reviews system
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Failing after 10m3s
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:
@@ -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
|
||||||
|
|||||||
@@ -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`
|
|
||||||
|
|
||||||
47
GEMINI.md
47
GEMINI.md
@@ -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.
|
|
||||||
@@ -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! 🛡️
|
|
||||||
@@ -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
67
TODO.md
@@ -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
|
|
||||||
|
|||||||
102
app/__tests__/components/CurrentlyReading.test.tsx
Normal file
102
app/__tests__/components/CurrentlyReading.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
53
app/__tests__/components/ThemeToggle.test.tsx
Normal file
53
app/__tests__/components/ThemeToggle.test.tsx
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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}%` }}
|
||||||
|
|||||||
@@ -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">
|
||||||
“{review.review}”
|
“{review.review}”
|
||||||
</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 ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
99
docs/N8N_HARDCOVER_GUIDE.md
Normal file
99
docs/N8N_HARDCOVER_GUIDE.md
Normal 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
6226
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user