feat: secure and document book reviews system
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`
|
|
||||||
|
|
||||||
@@ -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');
|
|
||||||
```
|
|
||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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).
|
||||||
Generated
+2807
-3419
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -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