update
This commit is contained in:
239
DEV-SETUP.md
Normal file
239
DEV-SETUP.md
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
# 🚀 Development Environment Setup
|
||||||
|
|
||||||
|
This document explains how to set up and use the development environment for the portfolio project.
|
||||||
|
|
||||||
|
## ✨ Features
|
||||||
|
|
||||||
|
- **Automatic Database Setup**: PostgreSQL and Redis start automatically
|
||||||
|
- **Hot Reload**: Next.js development server with hot reload
|
||||||
|
- **Database Integration**: Real database integration for email management
|
||||||
|
- **Modern Admin Dashboard**: Completely redesigned admin interface
|
||||||
|
- **Minimal Setup**: Only essential services for fast development
|
||||||
|
|
||||||
|
## 🛠️ Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js 18+
|
||||||
|
- Docker & Docker Compose
|
||||||
|
- npm or yarn
|
||||||
|
|
||||||
|
### 1. Install Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Start Development Environment
|
||||||
|
|
||||||
|
#### Option A: Full Development Environment (with Docker)
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
This single command will:
|
||||||
|
- Start PostgreSQL database
|
||||||
|
- Start Redis cache
|
||||||
|
- Start Next.js development server
|
||||||
|
- Set up all environment variables
|
||||||
|
|
||||||
|
#### Option B: Simple Development Mode (without Docker)
|
||||||
|
```bash
|
||||||
|
npm run dev:simple
|
||||||
|
```
|
||||||
|
|
||||||
|
This starts only the Next.js development server without Docker services. Use this if you don't have Docker installed or want a faster startup.
|
||||||
|
|
||||||
|
### 3. Access Services
|
||||||
|
|
||||||
|
- **Portfolio**: http://localhost:3000
|
||||||
|
- **Admin Dashboard**: http://localhost:3000/admin
|
||||||
|
- **PostgreSQL**: localhost:5432
|
||||||
|
- **Redis**: localhost:6379
|
||||||
|
|
||||||
|
## 📧 Email Testing
|
||||||
|
|
||||||
|
The development environment supports email functionality:
|
||||||
|
|
||||||
|
1. Send emails through the contact form or admin panel
|
||||||
|
2. Emails are sent directly (configure SMTP in production)
|
||||||
|
3. Check console logs for email debugging
|
||||||
|
|
||||||
|
## 🗄️ Database
|
||||||
|
|
||||||
|
### Development Database
|
||||||
|
|
||||||
|
- **Host**: localhost:5432
|
||||||
|
- **Database**: portfolio_dev
|
||||||
|
- **User**: portfolio_user
|
||||||
|
- **Password**: portfolio_dev_pass
|
||||||
|
|
||||||
|
### Database Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate Prisma client
|
||||||
|
npm run db:generate
|
||||||
|
|
||||||
|
# Push schema changes
|
||||||
|
npm run db:push
|
||||||
|
|
||||||
|
# Seed database with sample data
|
||||||
|
npm run db:seed
|
||||||
|
|
||||||
|
# Open Prisma Studio
|
||||||
|
npm run db:studio
|
||||||
|
|
||||||
|
# Reset database
|
||||||
|
npm run db:reset
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎨 Admin Dashboard
|
||||||
|
|
||||||
|
The new admin dashboard includes:
|
||||||
|
|
||||||
|
- **Overview**: Statistics and recent activity
|
||||||
|
- **Projects**: Manage portfolio projects
|
||||||
|
- **Emails**: Handle contact form submissions with beautiful templates
|
||||||
|
- **Analytics**: View performance metrics
|
||||||
|
- **Settings**: Import/export functionality
|
||||||
|
|
||||||
|
### Email Templates
|
||||||
|
|
||||||
|
Three beautiful email templates are available:
|
||||||
|
|
||||||
|
1. **Welcome Template** (Green): Friendly greeting with portfolio links
|
||||||
|
2. **Project Template** (Purple): Professional project discussion response
|
||||||
|
3. **Quick Template** (Orange): Fast acknowledgment response
|
||||||
|
|
||||||
|
## 🔧 Environment Variables
|
||||||
|
|
||||||
|
Create a `.env.local` file:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Development Database
|
||||||
|
DATABASE_URL="postgresql://portfolio_user:portfolio_dev_pass@localhost:5432/portfolio_dev?schema=public"
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_URL="redis://localhost:6379"
|
||||||
|
|
||||||
|
# Email (for production)
|
||||||
|
MY_EMAIL=contact@dk0.dev
|
||||||
|
MY_PASSWORD=your-email-password
|
||||||
|
|
||||||
|
# Application
|
||||||
|
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||||
|
NODE_ENV=development
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🛑 Stopping the Environment
|
||||||
|
|
||||||
|
Use Ctrl+C to stop all services, or:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stop Docker services only
|
||||||
|
npm run docker:dev:down
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🐳 Docker Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start only database services
|
||||||
|
npm run docker:dev
|
||||||
|
|
||||||
|
# Stop database services
|
||||||
|
npm run docker:dev:down
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker compose -f docker-compose.dev.minimal.yml logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📁 Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
├── docker-compose.dev.minimal.yml # Minimal development services
|
||||||
|
├── scripts/
|
||||||
|
│ ├── dev-minimal.js # Main development script
|
||||||
|
│ ├── dev-simple.js # Simple development script
|
||||||
|
│ ├── setup-database.js # Database setup script
|
||||||
|
│ └── init-db.sql # Database initialization
|
||||||
|
├── app/
|
||||||
|
│ ├── admin/ # Admin dashboard
|
||||||
|
│ ├── api/
|
||||||
|
│ │ ├── contacts/ # Contact management API
|
||||||
|
│ │ └── email/ # Email sending API
|
||||||
|
│ └── components/
|
||||||
|
│ ├── ModernAdminDashboard.tsx
|
||||||
|
│ ├── EmailManager.tsx
|
||||||
|
│ └── EmailResponder.tsx
|
||||||
|
└── prisma/
|
||||||
|
└── schema.prisma # Database schema
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚨 Troubleshooting
|
||||||
|
|
||||||
|
### Docker Compose Not Found
|
||||||
|
|
||||||
|
If you get the error `spawn docker compose ENOENT`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Try the simple dev mode instead
|
||||||
|
npm run dev:simple
|
||||||
|
|
||||||
|
# Or install Docker Desktop
|
||||||
|
# Download from: https://www.docker.com/products/docker-desktop
|
||||||
|
```
|
||||||
|
|
||||||
|
### Port Conflicts
|
||||||
|
|
||||||
|
If ports are already in use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check what's using the ports
|
||||||
|
lsof -i :3000
|
||||||
|
lsof -i :5432
|
||||||
|
lsof -i :6379
|
||||||
|
|
||||||
|
# Kill processes if needed
|
||||||
|
kill -9 <PID>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Connection Issues
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Restart database services
|
||||||
|
npm run docker:dev:down
|
||||||
|
npm run docker:dev
|
||||||
|
|
||||||
|
# Check database status
|
||||||
|
docker compose -f docker-compose.dev.minimal.yml ps
|
||||||
|
```
|
||||||
|
|
||||||
|
### Email Not Working
|
||||||
|
|
||||||
|
1. Verify environment variables
|
||||||
|
2. Check browser console for errors
|
||||||
|
3. Ensure SMTP is configured for production
|
||||||
|
|
||||||
|
## 🎯 Production Deployment
|
||||||
|
|
||||||
|
For production deployment, use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm run start
|
||||||
|
```
|
||||||
|
|
||||||
|
The production environment uses the production Docker Compose configuration.
|
||||||
|
|
||||||
|
## 📝 Notes
|
||||||
|
|
||||||
|
- The development environment automatically creates sample data
|
||||||
|
- Database changes are persisted in Docker volumes
|
||||||
|
- Hot reload works for all components and API routes
|
||||||
|
- Minimal setup for fast development startup
|
||||||
|
|
||||||
|
## 🔗 Links
|
||||||
|
|
||||||
|
- **Portfolio**: https://dk0.dev
|
||||||
|
- **Admin**: https://dk0.dev/admin
|
||||||
|
- **GitHub**: https://github.com/denniskonkol/portfolio
|
||||||
56
README.md
56
README.md
@@ -1,30 +1,58 @@
|
|||||||
# Dennis Konkol Portfolio - Modern Dark Theme
|
# Dennis Konkol Portfolio - Modern Dark Theme
|
||||||
|
|
||||||
Ein modernes, responsives Portfolio mit dunklem Design, coolen Animationen und einem integrierten Markdown-Editor.
|
Ein modernes, responsives Portfolio mit dunklem Design, coolen Animationen und einem integrierten Admin-Dashboard.
|
||||||
|
|
||||||
## Features
|
## ✨ Features
|
||||||
|
|
||||||
- **Dunkles Theme** mit Glassmorphism-Effekten
|
- **Dunkles Theme** mit Glassmorphism-Effekten
|
||||||
- **Responsive Design** für alle Geräte
|
- **Responsive Design** für alle Geräte
|
||||||
- **Smooth Animationen** mit Framer Motion
|
- **Smooth Animationen** mit Framer Motion
|
||||||
- **Markdown-Editor** für Projekte
|
|
||||||
- **Admin Dashboard** für Content-Management
|
- **Admin Dashboard** für Content-Management
|
||||||
|
- **E-Mail-System** mit schönen Templates
|
||||||
|
- **Analytics Dashboard** mit Performance-Metriken
|
||||||
|
- **Redis Caching** für optimale Performance
|
||||||
|
|
||||||
## Technologien
|
## 🛠️ Technologien
|
||||||
|
|
||||||
- Next.js 15 mit App Router
|
- **Frontend**: Next.js 15, TypeScript, Tailwind CSS, Framer Motion
|
||||||
- TypeScript für Type Safety
|
- **Backend**: PostgreSQL, Redis, Prisma ORM
|
||||||
- Tailwind CSS für Styling
|
- **Deployment**: Docker, Nginx
|
||||||
- Framer Motion für Animationen
|
- **Analytics**: Umami Analytics
|
||||||
- React Markdown für Content
|
|
||||||
|
|
||||||
## Installation
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Dependencies installieren
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
|
# Development Environment starten
|
||||||
npm run dev
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
## Verwendung
|
## 📁 Verfügbare Scripts
|
||||||
|
|
||||||
- `/` - Homepage
|
```bash
|
||||||
- `/projects` - Alle Projekte
|
npm run dev # Vollständiges Dev-Environment (Docker + Next.js)
|
||||||
- `/admin` - Admin Dashboard mit Markdown-Editor
|
npm run dev:simple # Nur Next.js (ohne Docker)
|
||||||
|
npm run build # Production Build
|
||||||
|
npm run start # Production Server
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🌐 URLs
|
||||||
|
|
||||||
|
- **Portfolio**: http://localhost:3000
|
||||||
|
- **Admin Dashboard**: http://localhost:3000/admin
|
||||||
|
- **PostgreSQL**: localhost:5432
|
||||||
|
- **Redis**: localhost:6379
|
||||||
|
|
||||||
|
## 📖 Dokumentation
|
||||||
|
|
||||||
|
- [Development Setup](DEV-SETUP.md) - Detaillierte Setup-Anleitung
|
||||||
|
- [Deployment Guide](DEPLOYMENT.md) - Production Deployment
|
||||||
|
- [Analytics](ANALYTICS.md) - Analytics und Performance
|
||||||
|
|
||||||
|
## 🔗 Links
|
||||||
|
|
||||||
|
- **Live Portfolio**: https://dk0.dev
|
||||||
|
- **Admin Dashboard**: https://dk0.dev/admin
|
||||||
|
- **GitHub**: https://github.com/denniskonkol/portfolio
|
||||||
|
|||||||
1574
app/admin/page.tsx
1574
app/admin/page.tsx
File diff suppressed because it is too large
Load Diff
74
app/api/contacts/[id]/route.tsx
Normal file
74
app/api/contacts/[id]/route.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const id = parseInt(params.id);
|
||||||
|
const body = await request.json();
|
||||||
|
const { responded, responseTemplate } = body;
|
||||||
|
|
||||||
|
if (isNaN(id)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid contact ID' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contact = await prisma.contact.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
responded: responded !== undefined ? responded : undefined,
|
||||||
|
responseTemplate: responseTemplate || undefined,
|
||||||
|
updatedAt: new Date()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: 'Contact updated successfully',
|
||||||
|
contact
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating contact:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to update contact' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const id = parseInt(params.id);
|
||||||
|
|
||||||
|
if (isNaN(id)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid contact ID' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.contact.delete({
|
||||||
|
where: { id }
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: 'Contact deleted successfully'
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting contact:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to delete contact' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
95
app/api/contacts/route.tsx
Normal file
95
app/api/contacts/route.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const filter = searchParams.get('filter') || 'all';
|
||||||
|
const limit = parseInt(searchParams.get('limit') || '50');
|
||||||
|
const offset = parseInt(searchParams.get('offset') || '0');
|
||||||
|
|
||||||
|
let whereClause = {};
|
||||||
|
|
||||||
|
switch (filter) {
|
||||||
|
case 'unread':
|
||||||
|
whereClause = { responded: false };
|
||||||
|
break;
|
||||||
|
case 'responded':
|
||||||
|
whereClause = { responded: true };
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
whereClause = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const [contacts, total] = await Promise.all([
|
||||||
|
prisma.contact.findMany({
|
||||||
|
where: whereClause,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: limit,
|
||||||
|
skip: offset,
|
||||||
|
}),
|
||||||
|
prisma.contact.count({ where: whereClause })
|
||||||
|
]);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
contacts,
|
||||||
|
total,
|
||||||
|
hasMore: offset + contacts.length < total
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching contacts:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch contacts' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { name, email, subject, message } = body;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!name || !email || !subject || !message) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'All fields are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate email format
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!emailRegex.test(email)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid email format' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contact = await prisma.contact.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
subject,
|
||||||
|
message,
|
||||||
|
responded: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: 'Contact created successfully',
|
||||||
|
contact
|
||||||
|
}, { status: 201 });
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating contact:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to create contact' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
464
app/api/email/respond/route.tsx
Normal file
464
app/api/email/respond/route.tsx
Normal file
@@ -0,0 +1,464 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import nodemailer from "nodemailer";
|
||||||
|
import SMTPTransport from "nodemailer/lib/smtp-transport";
|
||||||
|
import Mail from "nodemailer/lib/mailer";
|
||||||
|
|
||||||
|
// Email templates with beautiful designs
|
||||||
|
const emailTemplates = {
|
||||||
|
welcome: {
|
||||||
|
subject: "Vielen Dank für deine Nachricht! 👋",
|
||||||
|
template: (name: string, originalMessage: string) => `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Willkommen - Dennis Konkol</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; background-color: #f8fafc; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;">
|
||||||
|
<div style="max-width: 600px; margin: 0 auto; background-color: #ffffff; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div style="background: linear-gradient(135deg, #10b981 0%, #059669 100%); padding: 40px 30px; text-align: center;">
|
||||||
|
<h1 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: 600; letter-spacing: -0.5px;">
|
||||||
|
👋 Hallo ${name}!
|
||||||
|
</h1>
|
||||||
|
<p style="color: #d1fae5; margin: 8px 0 0 0; font-size: 16px; opacity: 0.9;">
|
||||||
|
Vielen Dank für deine Nachricht
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div style="padding: 40px 30px;">
|
||||||
|
|
||||||
|
<!-- Welcome Message -->
|
||||||
|
<div style="background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%); padding: 30px; border-radius: 12px; margin-bottom: 30px; border: 1px solid #bbf7d0;">
|
||||||
|
<div style="text-align: center; margin-bottom: 20px;">
|
||||||
|
<div style="width: 60px; height: 60px; background: linear-gradient(135deg, #10b981 0%, #059669 100%); border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 15px;">
|
||||||
|
<span style="color: #ffffff; font-size: 24px;">✓</span>
|
||||||
|
</div>
|
||||||
|
<h2 style="color: #065f46; margin: 0; font-size: 22px; font-weight: 600;">Nachricht erhalten!</h2>
|
||||||
|
</div>
|
||||||
|
<p style="color: #047857; margin: 0; text-align: center; line-height: 1.6; font-size: 16px;">
|
||||||
|
Vielen Dank für deine Nachricht! Ich habe sie erhalten und werde mich so schnell wie möglich bei dir melden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Original Message Reference -->
|
||||||
|
<div style="background: #ffffff; padding: 25px; border-radius: 12px; border: 1px solid #e5e7eb; margin-bottom: 30px;">
|
||||||
|
<h3 style="color: #374151; margin: 0 0 15px 0; font-size: 16px; font-weight: 600; display: flex; align-items: center;">
|
||||||
|
<span style="width: 6px; height: 6px; background: #6b7280; border-radius: 50%; margin-right: 10px;"></span>
|
||||||
|
Deine ursprüngliche Nachricht
|
||||||
|
</h3>
|
||||||
|
<div style="background: #f9fafb; padding: 20px; border-radius: 8px; border-left: 4px solid #10b981;">
|
||||||
|
<p style="color: #4b5563; margin: 0; line-height: 1.6; font-style: italic; white-space: pre-wrap;">${originalMessage}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Next Steps -->
|
||||||
|
<div style="background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%); padding: 30px; border-radius: 12px; border: 1px solid #bfdbfe;">
|
||||||
|
<h3 style="color: #1e40af; margin: 0 0 20px 0; font-size: 18px; font-weight: 600; text-align: center;">
|
||||||
|
🚀 Was passiert als nächstes?
|
||||||
|
</h3>
|
||||||
|
<div style="display: grid; gap: 15px;">
|
||||||
|
<div style="display: flex; align-items: center; padding: 15px; background: #ffffff; border-radius: 8px; border-left: 4px solid #3b82f6;">
|
||||||
|
<span style="color: #3b82f6; font-size: 20px; margin-right: 15px;">📧</span>
|
||||||
|
<div>
|
||||||
|
<h4 style="color: #1e40af; margin: 0 0 4px 0; font-size: 14px; font-weight: 600;">Schnelle Antwort</h4>
|
||||||
|
<p style="color: #4b5563; margin: 0; font-size: 14px;">Ich antworte normalerweise innerhalb von 24 Stunden</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; align-items: center; padding: 15px; background: #ffffff; border-radius: 8px; border-left: 4px solid #8b5cf6;">
|
||||||
|
<span style="color: #8b5cf6; font-size: 20px; margin-right: 15px;">💼</span>
|
||||||
|
<div>
|
||||||
|
<h4 style="color: #7c3aed; margin: 0 0 4px 0; font-size: 14px; font-weight: 600;">Projekt-Diskussion</h4>
|
||||||
|
<p style="color: #4b5563; margin: 0; font-size: 14px;">Gerne besprechen wir dein Projekt im Detail</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; align-items: center; padding: 15px; background: #ffffff; border-radius: 8px; border-left: 4px solid #f59e0b;">
|
||||||
|
<span style="color: #f59e0b; font-size: 20px; margin-right: 15px;">🤝</span>
|
||||||
|
<div>
|
||||||
|
<h4 style="color: #d97706; margin: 0 0 4px 0; font-size: 14px; font-weight: 600;">Zusammenarbeit</h4>
|
||||||
|
<p style="color: #4b5563; margin: 0; font-size: 14px;">Lass uns gemeinsam etwas Großartiges schaffen!</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Portfolio Links -->
|
||||||
|
<div style="text-align: center; margin-top: 30px;">
|
||||||
|
<h3 style="color: #374151; margin: 0 0 20px 0; font-size: 18px; font-weight: 600;">Entdecke mehr von mir</h3>
|
||||||
|
<div style="display: flex; justify-content: center; gap: 15px; flex-wrap: wrap;">
|
||||||
|
<a href="https://dk0.dev" style="display: inline-block; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #ffffff; text-decoration: none; padding: 12px 24px; border-radius: 8px; font-weight: 600; font-size: 14px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);">
|
||||||
|
🌐 Portfolio
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/denniskonkol" style="display: inline-block; background: linear-gradient(135deg, #374151 0%, #111827 100%); color: #ffffff; text-decoration: none; padding: 12px 24px; border-radius: 8px; font-weight: 600; font-size: 14px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);">
|
||||||
|
💻 GitHub
|
||||||
|
</a>
|
||||||
|
<a href="https://linkedin.com/in/denniskonkol" style="display: inline-block; background: linear-gradient(135deg, #0077b5 0%, #005885 100%); color: #ffffff; text-decoration: none; padding: 12px 24px; border-radius: 8px; font-weight: 600; font-size: 14px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);">
|
||||||
|
💼 LinkedIn
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div style="background: #f8fafc; padding: 30px; text-align: center; border-top: 1px solid #e5e7eb;">
|
||||||
|
<div style="margin-bottom: 15px;">
|
||||||
|
<span style="display: inline-block; width: 40px; height: 2px; background: linear-gradient(135deg, #10b981 0%, #059669 100%); border-radius: 1px;"></span>
|
||||||
|
</div>
|
||||||
|
<p style="color: #6b7280; margin: 0; font-size: 14px; line-height: 1.5;">
|
||||||
|
<strong>Dennis Konkol</strong> • Software Engineer & Student<br>
|
||||||
|
<a href="https://dk0.dev" style="color: #10b981; text-decoration: none; font-family: 'Monaco', 'Menlo', 'Consolas', monospace; font-weight: bold;">dk<span style="color: #ef4444;">0</span>.dev</a> •
|
||||||
|
<a href="mailto:contact@dk0.dev" style="color: #10b981; text-decoration: none;">contact@dk0.dev</a>
|
||||||
|
</p>
|
||||||
|
<p style="color: #9ca3af; margin: 10px 0 0 0; font-size: 12px;">
|
||||||
|
${new Date().toLocaleString('de-DE', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
|
||||||
|
project: {
|
||||||
|
subject: "Projekt-Anfrage erhalten! 🚀",
|
||||||
|
template: (name: string, originalMessage: string) => `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Projekt-Anfrage - Dennis Konkol</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; background-color: #f8fafc; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;">
|
||||||
|
<div style="max-width: 600px; margin: 0 auto; background-color: #ffffff; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div style="background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); padding: 40px 30px; text-align: center;">
|
||||||
|
<h1 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: 600; letter-spacing: -0.5px;">
|
||||||
|
🚀 Projekt-Anfrage erhalten!
|
||||||
|
</h1>
|
||||||
|
<p style="color: #e9d5ff; margin: 8px 0 0 0; font-size: 16px; opacity: 0.9;">
|
||||||
|
Hallo ${name}, lass uns etwas Großartiges schaffen!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div style="padding: 40px 30px;">
|
||||||
|
|
||||||
|
<!-- Project Message -->
|
||||||
|
<div style="background: linear-gradient(135deg, #faf5ff 0%, #f3e8ff 100%); padding: 30px; border-radius: 12px; margin-bottom: 30px; border: 1px solid #e9d5ff;">
|
||||||
|
<div style="text-align: center; margin-bottom: 20px;">
|
||||||
|
<div style="width: 60px; height: 60px; background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 15px;">
|
||||||
|
<span style="color: #ffffff; font-size: 24px;">💼</span>
|
||||||
|
</div>
|
||||||
|
<h2 style="color: #6b21a8; margin: 0; font-size: 22px; font-weight: 600;">Bereit für dein Projekt!</h2>
|
||||||
|
</div>
|
||||||
|
<p style="color: #7c2d12; margin: 0; text-align: center; line-height: 1.6; font-size: 16px;">
|
||||||
|
Vielen Dank für deine Projekt-Anfrage! Ich bin gespannt darauf, mehr über deine Ideen zu erfahren und wie wir sie gemeinsam umsetzen können.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Original Message -->
|
||||||
|
<div style="background: #ffffff; padding: 25px; border-radius: 12px; border: 1px solid #e5e7eb; margin-bottom: 30px;">
|
||||||
|
<h3 style="color: #374151; margin: 0 0 15px 0; font-size: 16px; font-weight: 600; display: flex; align-items: center;">
|
||||||
|
<span style="width: 6px; height: 6px; background: #8b5cf6; border-radius: 50%; margin-right: 10px;"></span>
|
||||||
|
Deine Projekt-Nachricht
|
||||||
|
</h3>
|
||||||
|
<div style="background: #f9fafb; padding: 20px; border-radius: 8px; border-left: 4px solid #8b5cf6;">
|
||||||
|
<p style="color: #4b5563; margin: 0; line-height: 1.6; font-style: italic; white-space: pre-wrap;">${originalMessage}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Process Steps -->
|
||||||
|
<div style="background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%); padding: 30px; border-radius: 12px; border: 1px solid #bfdbfe;">
|
||||||
|
<h3 style="color: #1e40af; margin: 0 0 20px 0; font-size: 18px; font-weight: 600; text-align: center;">
|
||||||
|
🎯 Mein Arbeitsprozess
|
||||||
|
</h3>
|
||||||
|
<div style="display: grid; gap: 15px;">
|
||||||
|
<div style="display: flex; align-items: center; padding: 15px; background: #ffffff; border-radius: 8px; border-left: 4px solid #3b82f6;">
|
||||||
|
<span style="color: #3b82f6; font-size: 20px; margin-right: 15px;">💬</span>
|
||||||
|
<div>
|
||||||
|
<h4 style="color: #1e40af; margin: 0 0 4px 0; font-size: 14px; font-weight: 600;">1. Erstgespräch</h4>
|
||||||
|
<p style="color: #4b5563; margin: 0; font-size: 14px;">Wir besprechen deine Anforderungen im Detail</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; align-items: center; padding: 15px; background: #ffffff; border-radius: 8px; border-left: 4px solid #8b5cf6;">
|
||||||
|
<span style="color: #8b5cf6; font-size: 20px; margin-right: 15px;">📋</span>
|
||||||
|
<div>
|
||||||
|
<h4 style="color: #7c3aed; margin: 0 0 4px 0; font-size: 14px; font-weight: 600;">2. Konzept & Planung</h4>
|
||||||
|
<p style="color: #4b5563; margin: 0; font-size: 14px;">Ich erstelle ein detailliertes Konzept für dein Projekt</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; align-items: center; padding: 15px; background: #ffffff; border-radius: 8px; border-left: 4px solid #10b981;">
|
||||||
|
<span style="color: #10b981; font-size: 20px; margin-right: 15px;">⚡</span>
|
||||||
|
<div>
|
||||||
|
<h4 style="color: #059669; margin: 0 0 4px 0; font-size: 14px; font-weight: 600;">3. Entwicklung</h4>
|
||||||
|
<p style="color: #4b5563; margin: 0; font-size: 14px;">Agile Entwicklung mit regelmäßigen Updates</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; align-items: center; padding: 15px; background: #ffffff; border-radius: 8px; border-left: 4px solid #f59e0b;">
|
||||||
|
<span style="color: #f59e0b; font-size: 20px; margin-right: 15px;">🎉</span>
|
||||||
|
<div>
|
||||||
|
<h4 style="color: #d97706; margin: 0 0 4px 0; font-size: 14px; font-weight: 600;">4. Launch & Support</h4>
|
||||||
|
<p style="color: #4b5563; margin: 0; font-size: 14px;">Deployment und kontinuierlicher Support</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CTA -->
|
||||||
|
<div style="text-align: center; margin-top: 30px;">
|
||||||
|
<a href="mailto:contact@dk0.dev?subject=Projekt-Diskussion mit ${name}" style="display: inline-block; background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); color: #ffffff; text-decoration: none; padding: 15px 30px; border-radius: 8px; font-weight: 600; font-size: 16px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);">
|
||||||
|
💬 Projekt besprechen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div style="background: #f8fafc; padding: 30px; text-align: center; border-top: 1px solid #e5e7eb;">
|
||||||
|
<div style="margin-bottom: 15px;">
|
||||||
|
<span style="display: inline-block; width: 40px; height: 2px; background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); border-radius: 1px;"></span>
|
||||||
|
</div>
|
||||||
|
<p style="color: #6b7280; margin: 0; font-size: 14px; line-height: 1.5;">
|
||||||
|
<strong>Dennis Konkol</strong> • Software Engineer & Student<br>
|
||||||
|
<a href="https://dki.one" style="color: #8b5cf6; text-decoration: none;">dki.one</a> •
|
||||||
|
<a href="mailto:contact@dk0.dev" style="color: #8b5cf6; text-decoration: none;">contact@dk0.dev</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
|
||||||
|
quick: {
|
||||||
|
subject: "Danke für deine Nachricht! ⚡",
|
||||||
|
template: (name: string, originalMessage: string) => `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Quick Response - Dennis Konkol</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; background-color: #f8fafc; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;">
|
||||||
|
<div style="max-width: 600px; margin: 0 auto; background-color: #ffffff; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div style="background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); padding: 40px 30px; text-align: center;">
|
||||||
|
<h1 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: 600; letter-spacing: -0.5px;">
|
||||||
|
⚡ Schnelle Antwort!
|
||||||
|
</h1>
|
||||||
|
<p style="color: #fef3c7; margin: 8px 0 0 0; font-size: 16px; opacity: 0.9;">
|
||||||
|
Hallo ${name}, danke für deine Nachricht!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div style="padding: 40px 30px;">
|
||||||
|
|
||||||
|
<!-- Quick Response -->
|
||||||
|
<div style="background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%); padding: 30px; border-radius: 12px; margin-bottom: 30px; border: 1px solid #fde68a;">
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<div style="width: 60px; height: 60px; background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 15px;">
|
||||||
|
<span style="color: #ffffff; font-size: 24px;">⚡</span>
|
||||||
|
</div>
|
||||||
|
<h2 style="color: #92400e; margin: 0 0 15px 0; font-size: 22px; font-weight: 600;">Nachricht erhalten!</h2>
|
||||||
|
<p style="color: #a16207; margin: 0; line-height: 1.6; font-size: 16px;">
|
||||||
|
Vielen Dank für deine Nachricht! Ich werde mich so schnell wie möglich bei dir melden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Original Message -->
|
||||||
|
<div style="background: #ffffff; padding: 25px; border-radius: 12px; border: 1px solid #e5e7eb; margin-bottom: 30px;">
|
||||||
|
<h3 style="color: #374151; margin: 0 0 15px 0; font-size: 16px; font-weight: 600; display: flex; align-items: center;">
|
||||||
|
<span style="width: 6px; height: 6px; background: #f59e0b; border-radius: 50%; margin-right: 10px;"></span>
|
||||||
|
Deine Nachricht
|
||||||
|
</h3>
|
||||||
|
<div style="background: #f9fafb; padding: 20px; border-radius: 8px; border-left: 4px solid #f59e0b;">
|
||||||
|
<p style="color: #4b5563; margin: 0; line-height: 1.6; font-style: italic; white-space: pre-wrap;">${originalMessage}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Info -->
|
||||||
|
<div style="background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%); padding: 25px; border-radius: 12px; border: 1px solid #bfdbfe;">
|
||||||
|
<h3 style="color: #1e40af; margin: 0 0 15px 0; font-size: 16px; font-weight: 600; text-align: center;">
|
||||||
|
📞 Kontakt
|
||||||
|
</h3>
|
||||||
|
<p style="color: #1e40af; margin: 0; text-align: center; line-height: 1.6; font-size: 14px;">
|
||||||
|
<strong>E-Mail:</strong> <a href="mailto:contact@dk0.dev" style="color: #1e40af; text-decoration: none;">contact@dk0.dev</a><br>
|
||||||
|
<strong>Portfolio:</strong> <a href="https://dki.one" style="color: #1e40af; text-decoration: none;">dki.one</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div style="background: #f8fafc; padding: 30px; text-align: center; border-top: 1px solid #e5e7eb;">
|
||||||
|
<div style="margin-bottom: 15px;">
|
||||||
|
<span style="display: inline-block; width: 40px; height: 2px; background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); border-radius: 1px;"></span>
|
||||||
|
</div>
|
||||||
|
<p style="color: #6b7280; margin: 0; font-size: 14px; line-height: 1.5;">
|
||||||
|
<strong>Dennis Konkol</strong> • Software Engineer & Student<br>
|
||||||
|
<a href="https://dki.one" style="color: #f59e0b; text-decoration: none;">dki.one</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = (await request.json()) as {
|
||||||
|
to: string;
|
||||||
|
name: string;
|
||||||
|
template: 'welcome' | 'project' | 'quick';
|
||||||
|
originalMessage: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { to, name, template, originalMessage } = body;
|
||||||
|
|
||||||
|
console.log('📧 Email response request:', { to, name, template, messageLength: originalMessage.length });
|
||||||
|
|
||||||
|
// Validate input
|
||||||
|
if (!to || !name || !template || !originalMessage) {
|
||||||
|
console.error('❌ Validation failed: Missing required fields');
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Alle Felder sind erforderlich" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate email format
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!emailRegex.test(to)) {
|
||||||
|
console.error('❌ Validation failed: Invalid email format');
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Ungültige E-Mail-Adresse" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if template exists
|
||||||
|
if (!emailTemplates[template]) {
|
||||||
|
console.error('❌ Validation failed: Invalid template');
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Ungültiges Template" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = process.env.MY_EMAIL ?? "";
|
||||||
|
const pass = process.env.MY_PASSWORD ?? "";
|
||||||
|
|
||||||
|
if (!user || !pass) {
|
||||||
|
console.error("❌ Missing email/password environment variables");
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "E-Mail-Server nicht konfiguriert" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const transportOptions: SMTPTransport.Options = {
|
||||||
|
host: "mail.dk0.dev",
|
||||||
|
port: 587,
|
||||||
|
secure: false,
|
||||||
|
requireTLS: true,
|
||||||
|
auth: {
|
||||||
|
type: "login",
|
||||||
|
user,
|
||||||
|
pass,
|
||||||
|
},
|
||||||
|
connectionTimeout: 30000,
|
||||||
|
greetingTimeout: 30000,
|
||||||
|
socketTimeout: 60000,
|
||||||
|
tls: {
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
ciphers: 'SSLv3'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const transport = nodemailer.createTransporter(transportOptions);
|
||||||
|
|
||||||
|
// Verify transport configuration
|
||||||
|
try {
|
||||||
|
await transport.verify();
|
||||||
|
console.log('✅ SMTP connection verified successfully');
|
||||||
|
} catch (verifyError) {
|
||||||
|
console.error('❌ SMTP verification failed:', verifyError);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "E-Mail-Server-Verbindung fehlgeschlagen" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedTemplate = emailTemplates[template];
|
||||||
|
const mailOptions: Mail.Options = {
|
||||||
|
from: `"Dennis Konkol" <${user}>`,
|
||||||
|
to: to,
|
||||||
|
replyTo: "contact@dk0.dev",
|
||||||
|
subject: selectedTemplate.subject,
|
||||||
|
html: selectedTemplate.template(name, originalMessage),
|
||||||
|
text: `
|
||||||
|
Hallo ${name}!
|
||||||
|
|
||||||
|
Vielen Dank für deine Nachricht:
|
||||||
|
${originalMessage}
|
||||||
|
|
||||||
|
Ich werde mich so schnell wie möglich bei dir melden.
|
||||||
|
|
||||||
|
Beste Grüße,
|
||||||
|
Dennis Konkol
|
||||||
|
Software Engineer & Student
|
||||||
|
https://dki.one
|
||||||
|
contact@dk0.dev
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('📤 Sending templated email...');
|
||||||
|
|
||||||
|
const sendMailPromise = () =>
|
||||||
|
new Promise<string>((resolve, reject) => {
|
||||||
|
transport.sendMail(mailOptions, function (err, info) {
|
||||||
|
if (!err) {
|
||||||
|
console.log('✅ Templated email sent successfully:', info.response);
|
||||||
|
resolve(info.response);
|
||||||
|
} else {
|
||||||
|
console.error("❌ Error sending templated email:", err);
|
||||||
|
reject(err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await sendMailPromise();
|
||||||
|
console.log('🎉 Templated email process completed successfully');
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: "Template-E-Mail erfolgreich gesendet",
|
||||||
|
template: template,
|
||||||
|
messageId: result
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ Unexpected error in templated email API:", err);
|
||||||
|
return NextResponse.json({
|
||||||
|
error: "Fehler beim Senden der Template-E-Mail",
|
||||||
|
details: err instanceof Error ? err.message : 'Unbekannter Fehler'
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,19 +61,24 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const transportOptions: SMTPTransport.Options = {
|
const transportOptions: SMTPTransport.Options = {
|
||||||
host: "smtp.ionos.de",
|
host: "mail.dk0.dev",
|
||||||
port: 587,
|
port: 587,
|
||||||
secure: false,
|
secure: false, // Port 587 uses STARTTLS, not SSL/TLS
|
||||||
requireTLS: true,
|
requireTLS: true,
|
||||||
auth: {
|
auth: {
|
||||||
type: "login",
|
type: "login",
|
||||||
user,
|
user,
|
||||||
pass,
|
pass,
|
||||||
},
|
},
|
||||||
// Add timeout and debug options
|
// Increased timeout settings for better reliability
|
||||||
connectionTimeout: 10000,
|
connectionTimeout: 30000, // 30 seconds
|
||||||
greetingTimeout: 10000,
|
greetingTimeout: 30000, // 30 seconds
|
||||||
socketTimeout: 10000,
|
socketTimeout: 60000, // 60 seconds
|
||||||
|
// Additional TLS options for better compatibility
|
||||||
|
tls: {
|
||||||
|
rejectUnauthorized: false, // Allow self-signed certificates
|
||||||
|
ciphers: 'SSLv3'
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('🚀 Creating transport with options:', {
|
console.log('🚀 Creating transport with options:', {
|
||||||
@@ -85,44 +90,129 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const transport = nodemailer.createTransport(transportOptions);
|
const transport = nodemailer.createTransport(transportOptions);
|
||||||
|
|
||||||
// Verify transport configuration
|
// Verify transport configuration with retry logic
|
||||||
try {
|
let verificationAttempts = 0;
|
||||||
await transport.verify();
|
const maxVerificationAttempts = 3;
|
||||||
console.log('✅ SMTP connection verified successfully');
|
let verificationSuccess = false;
|
||||||
} catch (verifyError) {
|
|
||||||
console.error('❌ SMTP verification failed:', verifyError);
|
while (verificationAttempts < maxVerificationAttempts && !verificationSuccess) {
|
||||||
return NextResponse.json(
|
try {
|
||||||
{ error: "E-Mail-Server-Verbindung fehlgeschlagen" },
|
verificationAttempts++;
|
||||||
{ status: 500 },
|
console.log(`🔍 SMTP verification attempt ${verificationAttempts}/${maxVerificationAttempts}`);
|
||||||
);
|
await transport.verify();
|
||||||
|
console.log('✅ SMTP connection verified successfully');
|
||||||
|
verificationSuccess = true;
|
||||||
|
} catch (verifyError) {
|
||||||
|
console.error(`❌ SMTP verification attempt ${verificationAttempts} failed:`, verifyError);
|
||||||
|
|
||||||
|
if (verificationAttempts >= maxVerificationAttempts) {
|
||||||
|
console.error('❌ All SMTP verification attempts failed');
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "E-Mail-Server-Verbindung fehlgeschlagen" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait before retry
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const mailOptions: Mail.Options = {
|
const mailOptions: Mail.Options = {
|
||||||
from: `"Portfolio Contact" <${user}>`,
|
from: `"Portfolio Contact" <${user}>`,
|
||||||
to: "contact@dki.one", // Send to your contact email
|
to: "contact@dk0.dev", // Send to your contact email
|
||||||
replyTo: email,
|
replyTo: email,
|
||||||
subject: `Portfolio Kontakt: ${subject}`,
|
subject: `Portfolio Kontakt: ${subject}`,
|
||||||
html: `
|
html: `
|
||||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
<!DOCTYPE html>
|
||||||
<h2 style="color: #3b82f6;">Neue Kontaktanfrage von deinem Portfolio</h2>
|
<html lang="de">
|
||||||
|
<head>
|
||||||
<div style="background: #f8fafc; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
<meta charset="UTF-8">
|
||||||
<h3 style="color: #1e293b; margin-top: 0;">Nachricht von ${name}</h3>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<p style="color: #475569; margin: 8px 0;"><strong>E-Mail:</strong> ${email}</p>
|
<title>Neue Kontaktanfrage - Portfolio</title>
|
||||||
<p style="color: #475569; margin: 8px 0;"><strong>Betreff:</strong> ${subject}</p>
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; background-color: #f8fafc; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;">
|
||||||
|
<div style="max-width: 600px; margin: 0 auto; background-color: #ffffff; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 40px 30px; text-align: center;">
|
||||||
|
<h1 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: 600; letter-spacing: -0.5px;">
|
||||||
|
📧 Neue Kontaktanfrage
|
||||||
|
</h1>
|
||||||
|
<p style="color: #e2e8f0; margin: 8px 0 0 0; font-size: 16px; opacity: 0.9;">
|
||||||
|
Von deinem Portfolio
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div style="padding: 40px 30px;">
|
||||||
|
|
||||||
|
<!-- Contact Info Card -->
|
||||||
|
<div style="background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%); padding: 30px; border-radius: 12px; margin-bottom: 30px; border: 1px solid #e2e8f0;">
|
||||||
|
<div style="display: flex; align-items: center; margin-bottom: 20px;">
|
||||||
|
<div style="width: 50px; height: 50px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 50%; display: flex; align-items: center; justify-content: center; margin-right: 15px;">
|
||||||
|
<span style="color: #ffffff; font-size: 20px; font-weight: bold;">${name.charAt(0).toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 style="color: #1e293b; margin: 0; font-size: 24px; font-weight: 600;">${name}</h2>
|
||||||
|
<p style="color: #64748b; margin: 4px 0 0 0; font-size: 14px;">Kontaktanfrage</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-top: 20px;">
|
||||||
|
<div style="background: #ffffff; padding: 20px; border-radius: 8px; border-left: 4px solid #10b981;">
|
||||||
|
<h4 style="color: #059669; margin: 0 0 8px 0; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;">E-Mail</h4>
|
||||||
|
<p style="color: #374151; margin: 0; font-size: 16px; font-weight: 500;">${email}</p>
|
||||||
|
</div>
|
||||||
|
<div style="background: #ffffff; padding: 20px; border-radius: 8px; border-left: 4px solid #3b82f6;">
|
||||||
|
<h4 style="color: #2563eb; margin: 0 0 8px 0; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;">Betreff</h4>
|
||||||
|
<p style="color: #374151; margin: 0; font-size: 16px; font-weight: 500;">${subject}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Message Card -->
|
||||||
|
<div style="background: #ffffff; padding: 30px; border-radius: 12px; border: 1px solid #e2e8f0; box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);">
|
||||||
|
<div style="display: flex; align-items: center; margin-bottom: 20px;">
|
||||||
|
<div style="width: 8px; height: 8px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 50%; margin-right: 12px;"></div>
|
||||||
|
<h3 style="color: #1e293b; margin: 0; font-size: 18px; font-weight: 600;">Nachricht</h3>
|
||||||
|
</div>
|
||||||
|
<div style="background: #f8fafc; padding: 25px; border-radius: 8px; border-left: 4px solid #667eea;">
|
||||||
|
<p style="color: #374151; margin: 0; line-height: 1.7; font-size: 16px; white-space: pre-wrap;">${message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Button -->
|
||||||
|
<div style="text-align: center; margin-top: 30px;">
|
||||||
|
<a href="mailto:${email}?subject=Re: ${subject}" style="display: inline-block; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #ffffff; text-decoration: none; padding: 15px 30px; border-radius: 8px; font-weight: 600; font-size: 16px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); transition: all 0.2s;">
|
||||||
|
📬 Antworten
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div style="background: #f8fafc; padding: 30px; text-align: center; border-top: 1px solid #e2e8f0;">
|
||||||
|
<div style="margin-bottom: 15px;">
|
||||||
|
<span style="display: inline-block; width: 40px; height: 2px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 1px;"></span>
|
||||||
|
</div>
|
||||||
|
<p style="color: #64748b; margin: 0; font-size: 14px; line-height: 1.5;">
|
||||||
|
Diese E-Mail wurde automatisch von deinem Portfolio generiert.<br>
|
||||||
|
<strong>Dennis Konkol Portfolio</strong> • <a href="https://dki.one" style="color: #667eea; text-decoration: none;">dki.one</a>
|
||||||
|
</p>
|
||||||
|
<p style="color: #94a3b8; margin: 10px 0 0 0; font-size: 12px;">
|
||||||
|
${new Date().toLocaleString('de-DE', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
</body>
|
||||||
<div style="background: #ffffff; padding: 20px; border-radius: 8px; border-left: 4px solid #3b82f6;">
|
</html>
|
||||||
<h4 style="color: #1e293b; margin-top: 0;">Nachricht:</h4>
|
|
||||||
<p style="color: #374151; line-height: 1.6; white-space: pre-wrap;">${message}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="text-align: center; margin-top: 30px; padding: 20px; background: #f1f5f9; border-radius: 8px;">
|
|
||||||
<p style="color: #64748b; margin: 0; font-size: 14px;">
|
|
||||||
Diese E-Mail wurde automatisch von deinem Portfolio generiert.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`,
|
`,
|
||||||
text: `
|
text: `
|
||||||
Neue Kontaktanfrage von deinem Portfolio
|
Neue Kontaktanfrage von deinem Portfolio
|
||||||
@@ -140,21 +230,45 @@ Diese E-Mail wurde automatisch von deinem Portfolio generiert.
|
|||||||
|
|
||||||
console.log('📤 Sending email...');
|
console.log('📤 Sending email...');
|
||||||
|
|
||||||
const sendMailPromise = () =>
|
// Email sending with retry logic
|
||||||
new Promise<string>((resolve, reject) => {
|
let sendAttempts = 0;
|
||||||
transport.sendMail(mailOptions, function (err, info) {
|
const maxSendAttempts = 3;
|
||||||
if (!err) {
|
let sendSuccess = false;
|
||||||
console.log('✅ Email sent successfully:', info.response);
|
let result = '';
|
||||||
resolve(info.response);
|
|
||||||
} else {
|
|
||||||
console.error("❌ Error sending email:", err);
|
|
||||||
reject(err.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await sendMailPromise();
|
while (sendAttempts < maxSendAttempts && !sendSuccess) {
|
||||||
console.log('🎉 Email process completed successfully');
|
try {
|
||||||
|
sendAttempts++;
|
||||||
|
console.log(`📤 Email send attempt ${sendAttempts}/${maxSendAttempts}`);
|
||||||
|
|
||||||
|
const sendMailPromise = () =>
|
||||||
|
new Promise<string>((resolve, reject) => {
|
||||||
|
transport.sendMail(mailOptions, function (err, info) {
|
||||||
|
if (!err) {
|
||||||
|
console.log('✅ Email sent successfully:', info.response);
|
||||||
|
resolve(info.response);
|
||||||
|
} else {
|
||||||
|
console.error("❌ Error sending email:", err);
|
||||||
|
reject(err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
result = await sendMailPromise();
|
||||||
|
sendSuccess = true;
|
||||||
|
console.log('🎉 Email process completed successfully');
|
||||||
|
} catch (sendError) {
|
||||||
|
console.error(`❌ Email send attempt ${sendAttempts} failed:`, sendError);
|
||||||
|
|
||||||
|
if (sendAttempts >= maxSendAttempts) {
|
||||||
|
console.error('❌ All email send attempts failed');
|
||||||
|
throw new Error(`Failed to send email after ${maxSendAttempts} attempts: ${sendError}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait before retry
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
message: "E-Mail erfolgreich gesendet",
|
message: "E-Mail erfolgreich gesendet",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { Mail, Phone, MapPin, Send, Github, Linkedin, Twitter } from 'lucide-react';
|
import { Mail, Phone, MapPin, Send } from 'lucide-react';
|
||||||
import { useToast } from '@/components/Toast';
|
import { useToast } from '@/components/Toast';
|
||||||
|
|
||||||
const Contact = () => {
|
const Contact = () => {
|
||||||
@@ -66,28 +66,22 @@ const Contact = () => {
|
|||||||
{
|
{
|
||||||
icon: Mail,
|
icon: Mail,
|
||||||
title: 'Email',
|
title: 'Email',
|
||||||
value: 'contact@dki.one',
|
value: 'contact@dk0.dev',
|
||||||
href: 'mailto:contact@dki.one'
|
href: 'mailto:contact@dk0.dev'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Phone,
|
icon: Phone,
|
||||||
title: 'Phone',
|
title: 'Phone',
|
||||||
value: '+49 123 456 789',
|
value: '+49 176 12669990',
|
||||||
href: 'tel:+49123456789'
|
href: 'tel:+4917612669990'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: MapPin,
|
icon: MapPin,
|
||||||
title: 'Location',
|
title: 'Location',
|
||||||
value: 'Osnabrück, Germany',
|
value: 'Osnabrück, Germany',
|
||||||
href: '#'
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const socialLinks = [
|
|
||||||
{ icon: Github, href: 'https://github.com/Denshooter', label: 'GitHub' },
|
|
||||||
{ icon: Linkedin, href: 'https://linkedin.com/in/dkonkol', label: 'LinkedIn' },
|
|
||||||
{ icon: Twitter, href: 'https://twitter.com/dkonkol', label: 'Twitter' }
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return null;
|
return null;
|
||||||
@@ -155,25 +149,6 @@ const Contact = () => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Social Links */}
|
|
||||||
<div>
|
|
||||||
<h4 className="text-lg font-semibold text-white mb-4">Follow Me</h4>
|
|
||||||
<div className="flex space-x-4">
|
|
||||||
{socialLinks.map((social) => (
|
|
||||||
<motion.a
|
|
||||||
key={social.label}
|
|
||||||
href={social.href}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
whileHover={{ scale: 1.1, y: -2 }}
|
|
||||||
whileTap={{ scale: 0.95 }}
|
|
||||||
className="p-3 bg-gray-800/50 hover:bg-gray-700/50 rounded-lg text-gray-300 hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
<social.icon size={20} />
|
|
||||||
</motion.a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Contact Form */}
|
{/* Contact Form */}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { Github, Linkedin, Mail, Heart } from 'lucide-react';
|
import { Heart, Code } from 'lucide-react';
|
||||||
|
import { SiGithub, SiLinkedin } from 'react-icons/si';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
const Footer = () => {
|
const Footer = () => {
|
||||||
@@ -15,16 +16,8 @@ const Footer = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const socialLinks = [
|
const socialLinks = [
|
||||||
{ icon: Github, href: 'https://github.com/Denshooter', label: 'GitHub' },
|
{ icon: SiGithub, href: 'https://github.com/Denshooter', label: 'GitHub' },
|
||||||
{ icon: Linkedin, href: 'https://linkedin.com/in/dkonkol', label: 'LinkedIn' },
|
{ icon: SiLinkedin, href: 'https://linkedin.com/in/dkonkol', label: 'LinkedIn' }
|
||||||
{ icon: Mail, href: 'mailto:contact@dki.one', label: 'Email' }
|
|
||||||
];
|
|
||||||
|
|
||||||
const quickLinks = [
|
|
||||||
{ name: 'Home', href: '/' },
|
|
||||||
{ name: 'Projects', href: '/projects' },
|
|
||||||
{ name: 'About', href: '#about' },
|
|
||||||
{ name: 'Contact', href: '#contact' }
|
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
@@ -32,129 +25,95 @@ const Footer = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="relative py-16 px-4 border-t border-gray-800">
|
<footer className="relative py-12 px-4 bg-black border-t border-gray-800/50">
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-gray-900/50 to-transparent"></div>
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<div className="flex flex-col md:flex-row justify-between items-center space-y-6 md:space-y-0">
|
||||||
<div className="relative z-10 max-w-7xl mx-auto">
|
{/* Brand */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8 mb-12">
|
<motion.div
|
||||||
<div className="md:col-span-2">
|
initial={{ opacity: 0, y: 20 }}
|
||||||
<motion.div
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
initial={{ opacity: 0, y: 20 }}
|
viewport={{ once: true }}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
transition={{ duration: 0.6 }}
|
||||||
viewport={{ once: true }}
|
className="flex items-center space-x-3"
|
||||||
transition={{ duration: 0.6 }}
|
>
|
||||||
>
|
<div className="w-10 h-10 bg-gradient-to-r from-blue-500 to-purple-500 rounded-lg flex items-center justify-center">
|
||||||
<Link href="/" className="text-3xl font-bold gradient-text mb-4 inline-block">
|
<Code className="w-5 h-5 text-white" />
|
||||||
Dennis Konkol
|
</div>
|
||||||
|
<div>
|
||||||
|
<Link href="/" className="text-xl font-bold font-mono text-white">
|
||||||
|
dk<span className="text-red-500">0</span>
|
||||||
</Link>
|
</Link>
|
||||||
<p className="text-gray-400 mb-6 max-w-md leading-relaxed">
|
<p className="text-xs text-gray-500">Software Engineer</p>
|
||||||
A passionate software engineer and student based in Osnabrück, Germany.
|
</div>
|
||||||
Creating innovative solutions that make a difference in the digital world.
|
</motion.div>
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex space-x-4">
|
|
||||||
{socialLinks.map((social) => (
|
|
||||||
<motion.a
|
|
||||||
key={social.label}
|
|
||||||
href={social.href}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
whileHover={{ scale: 1.1, y: -2 }}
|
|
||||||
whileTap={{ scale: 0.95 }}
|
|
||||||
className="p-3 bg-gray-800/50 hover:bg-gray-700/50 rounded-lg text-gray-300 hover:text-white transition-all duration-200"
|
|
||||||
>
|
|
||||||
<social.icon size={20} />
|
|
||||||
</motion.a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
{/* Social Links */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
transition={{ duration: 0.6, delay: 0.1 }}
|
transition={{ duration: 0.6, delay: 0.1 }}
|
||||||
|
className="flex space-x-4"
|
||||||
>
|
>
|
||||||
<h3 className="text-lg font-semibold text-white mb-4">Quick Links</h3>
|
{socialLinks.map((social) => (
|
||||||
<ul className="space-y-2">
|
<motion.a
|
||||||
{quickLinks.map((link) => (
|
key={social.label}
|
||||||
<li key={link.name}>
|
href={social.href}
|
||||||
<Link
|
target="_blank"
|
||||||
href={link.href}
|
rel="noopener noreferrer"
|
||||||
className="text-gray-400 hover:text-white transition-colors duration-200"
|
whileHover={{ scale: 1.1, y: -2 }}
|
||||||
>
|
whileTap={{ scale: 0.95 }}
|
||||||
{link.name}
|
className="p-3 bg-gray-800/50 hover:bg-gray-700/50 rounded-lg text-gray-300 hover:text-white transition-all duration-200"
|
||||||
</Link>
|
>
|
||||||
</li>
|
<social.icon size={18} />
|
||||||
))}
|
</motion.a>
|
||||||
</ul>
|
))}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Copyright */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
transition={{ duration: 0.6, delay: 0.2 }}
|
transition={{ duration: 0.6, delay: 0.2 }}
|
||||||
|
className="flex items-center space-x-2 text-gray-400 text-sm"
|
||||||
>
|
>
|
||||||
<h3 className="text-lg font-semibold text-white mb-4">Legal</h3>
|
<span>© {currentYear}</span>
|
||||||
<ul className="space-y-2">
|
<motion.div
|
||||||
<li>
|
animate={{ scale: [1, 1.2, 1] }}
|
||||||
<Link
|
transition={{ duration: 1.5, repeat: Infinity }}
|
||||||
href="/legal-notice"
|
>
|
||||||
className="text-gray-400 hover:text-white transition-colors duration-200"
|
<Heart size={14} className="text-red-500" />
|
||||||
>
|
</motion.div>
|
||||||
Impressum
|
<span>Made in Germany</span>
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<Link
|
|
||||||
href="/privacy-policy"
|
|
||||||
className="text-gray-400 hover:text-white transition-colors duration-200"
|
|
||||||
>
|
|
||||||
Privacy Policy
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
|
||||||
viewport={{ once: true }}
|
|
||||||
transition={{ duration: 0.6, delay: 0.2 }}
|
|
||||||
>
|
|
||||||
<h3 className="text-lg font-semibold text-white mb-4">Contact</h3>
|
|
||||||
<div className="space-y-2 text-gray-400">
|
|
||||||
<p>Osnabrück, Germany</p>
|
|
||||||
<p>contact@dki.one</p>
|
|
||||||
<p>+49 123 456 789</p>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Legal Links */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
transition={{ duration: 0.6, delay: 0.3 }}
|
transition={{ duration: 0.6, delay: 0.3 }}
|
||||||
className="pt-8 border-t border-gray-800 text-center"
|
className="mt-8 pt-6 border-t border-gray-800/50 flex flex-col md:flex-row justify-between items-center space-y-4 md:space-y-0"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col md:flex-row justify-between items-center space-y-4 md:space-y-0">
|
<div className="flex space-x-6 text-sm">
|
||||||
<p className="text-gray-400">
|
<Link
|
||||||
© {currentYear} Dennis Konkol. All rights reserved.
|
href="/legal-notice"
|
||||||
</p>
|
className="text-gray-500 hover:text-gray-300 transition-colors duration-200"
|
||||||
|
>
|
||||||
<div className="flex items-center space-x-2 text-gray-400">
|
Impressum
|
||||||
<span>Made with</span>
|
</Link>
|
||||||
<motion.div
|
<Link
|
||||||
animate={{ scale: [1, 1.2, 1] }}
|
href="/privacy-policy"
|
||||||
transition={{ duration: 1.5, repeat: Infinity }}
|
className="text-gray-500 hover:text-gray-300 transition-colors duration-200"
|
||||||
>
|
>
|
||||||
<Heart size={16} className="text-red-500" />
|
Privacy Policy
|
||||||
</motion.div>
|
</Link>
|
||||||
<span>in Germany</span>
|
</div>
|
||||||
</div>
|
|
||||||
|
<div className="text-xs text-gray-600">
|
||||||
|
Built with Next.js, TypeScript & Tailwind CSS
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
export default function Footer_Back() {
|
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setTimeout(() => {
|
|
||||||
setIsVisible(true);
|
|
||||||
}, 450); // Delay to start the animation
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<footer
|
|
||||||
className={`p-3 bg-gradient-to-br from-white/60 to-white/30 backdrop-blur-lg rounded-2xl shadow-xl text-center text-gray-800 ${isVisible ? "animate-fly-in" : "opacity-0"}`}
|
|
||||||
>
|
|
||||||
<div className={`flex flex-col md:flex-row items-center justify-between`}>
|
|
||||||
<div className={`flex-col items-center`}>
|
|
||||||
<p className="md:mt-1 text-lg">
|
|
||||||
Connect with me on social platforms:
|
|
||||||
</p>
|
|
||||||
<div className="flex justify-center items-center space-x-4 mt-4">
|
|
||||||
<Link
|
|
||||||
aria-label={"Dennis Github"}
|
|
||||||
href="https://github.com/Denshooter"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="w-10 h-10"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.3 3.438 9.8 8.205 11.387.6.11.82-.26.82-.577v-2.17c-3.338.726-4.042-1.61-4.042-1.61-.546-1.387-1.333-1.757-1.333-1.757-1.09-.746.083-.73.083-.73 1.205.084 1.84 1.237 1.84 1.237 1.07 1.835 2.807 1.305 3.492.997.108-.774.42-1.305.763-1.605-2.665-.305-5.466-1.332-5.466-5.93 0-1.31.467-2.38 1.235-3.22-.123-.303-.535-1.527.117-3.18 0 0 1.008-.322 3.3 1.23.957-.266 1.98-.4 3-.405 1.02.005 2.043.14 3 .405 2.29-1.552 3.297-1.23 3.297-1.23.653 1.653.24 2.877.118 3.18.77.84 1.233 1.91 1.233 3.22 0 4.61-2.803 5.62-5.475 5.92.43.37.823 1.1.823 2.22v3.293c0 .32.218.694.825.577C20.565 21.8 24 17.3 24 12c0-6.63-5.37-12-12-12z" />
|
|
||||||
</svg>
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
aria-label={"Dennis Linked In"}
|
|
||||||
href="https://linkedin.com/in/dkonkol"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="w-10 h-10"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path d="M19 0h-14c-2.76 0-5 2.24-5 5v14c0 2.76 2.24 5 5 5h14c2.76 0 5-2.24 5-5v-14c0-2.76-2.24-5-5-5zm-11 19h-3v-10h3v10zm-1.5-11.5c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm13.5 11.5h-3v-5.5c0-1.38-1.12-2.5-2.5-2.5s-2.5 1.12-2.5 2.5v5.5h-3v-10h3v1.5c.83-1.17 2.17-1.5 3.5-1.5 2.48 0 4.5 2.02 4.5 4.5v5.5z" />
|
|
||||||
</svg>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 md:absolute md:left-1/2 md:transform md:-translate-x-1/2">
|
|
||||||
<Link
|
|
||||||
href={"/"}
|
|
||||||
className="p-4 mt-4 md:px-4 md:my-6 text-white bg-gradient-to-r from-blue-500 to-purple-500 rounded-2xl hover:from-blue-600 hover:to-purple-600 transition"
|
|
||||||
>
|
|
||||||
Back to main page
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<div className="flex-col">
|
|
||||||
<div className="mt-4">
|
|
||||||
<Link
|
|
||||||
href="/privacy-policy"
|
|
||||||
className="text-blue-800 transition-underline"
|
|
||||||
>
|
|
||||||
Privacy Policy
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/legal-notice"
|
|
||||||
className="ml-4 text-blue-800 transition-underline"
|
|
||||||
>
|
|
||||||
Legal Notice
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<p className="md:mt-4">© Dennis Konkol 2025</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { Menu, X, Github, Linkedin, Mail } from 'lucide-react';
|
import { Menu, X, Mail } from 'lucide-react';
|
||||||
|
import { SiGithub, SiLinkedin } from 'react-icons/si';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
const Header = () => {
|
const Header = () => {
|
||||||
@@ -31,9 +32,9 @@ const Header = () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const socialLinks = [
|
const socialLinks = [
|
||||||
{ icon: Github, href: 'https://github.com/Denshooter', label: 'GitHub' },
|
{ icon: SiGithub, href: 'https://github.com/Denshooter', label: 'GitHub' },
|
||||||
{ icon: Linkedin, href: 'https://linkedin.com/in/dkonkol', label: 'LinkedIn' },
|
{ icon: SiLinkedin, href: 'https://linkedin.com/in/dkonkol', label: 'LinkedIn' },
|
||||||
{ icon: Mail, href: 'mailto:contact@dki.one', label: 'Email' },
|
{ icon: Mail, href: 'mailto:contact@dk0.dev', label: 'Email' },
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
@@ -70,8 +71,8 @@ const Header = () => {
|
|||||||
whileHover={{ scale: 1.05 }}
|
whileHover={{ scale: 1.05 }}
|
||||||
className="flex items-center space-x-2"
|
className="flex items-center space-x-2"
|
||||||
>
|
>
|
||||||
<Link href="/" className="text-2xl font-bold gradient-text">
|
<Link href="/" className="text-2xl font-bold font-mono text-white">
|
||||||
DK
|
dk<span className="text-red-500">0</span>
|
||||||
</Link>
|
</Link>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const Hero = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="relative min-h-screen flex items-center justify-center overflow-hidden">
|
<section className="relative min-h-screen flex items-center justify-center overflow-hidden pt-20 pb-8">
|
||||||
{/* Animated Background */}
|
{/* Animated Background */}
|
||||||
<div className="absolute inset-0 animated-bg"></div>
|
<div className="absolute inset-0 animated-bg"></div>
|
||||||
|
|
||||||
@@ -71,17 +71,26 @@ const Hero = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative z-10 text-center px-4 max-w-4xl mx-auto">
|
<div className="relative z-10 text-center px-4 max-w-4xl mx-auto">
|
||||||
|
{/* Domain - über dem Profilbild */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.8, delay: 0.5 }}
|
||||||
|
className="mb-8"
|
||||||
|
>
|
||||||
|
<div className="domain-text text-white/95 text-center">
|
||||||
|
dk<span className="text-red-500">0</span>.dev
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
{/* Profile Image */}
|
{/* Profile Image */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, scale: 0.8, rotateY: -15 }}
|
initial={{ opacity: 0, scale: 0.8, rotateY: -15 }}
|
||||||
animate={{ opacity: 1, scale: 1, rotateY: 0 }}
|
animate={{ opacity: 1, scale: 1, rotateY: 0 }}
|
||||||
transition={{ duration: 1, delay: 0.1, ease: "easeOut" }}
|
transition={{ duration: 1, delay: 0.7, ease: "easeOut" }}
|
||||||
className="mb-8 flex justify-center"
|
className="mb-8 flex justify-center"
|
||||||
>
|
>
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
{/* Glowing border effect */}
|
|
||||||
<div className="absolute -inset-1 bg-gradient-to-r from-blue-600 via-purple-600 to-cyan-600 rounded-full blur opacity-75 group-hover:opacity-100 transition duration-1000 group-hover:duration-200 animate-pulse"></div>
|
|
||||||
|
|
||||||
{/* Profile image container */}
|
{/* Profile image container */}
|
||||||
<div className="relative bg-gray-900 rounded-full p-1">
|
<div className="relative bg-gray-900 rounded-full p-1">
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -137,7 +146,7 @@ const Hero = () => {
|
|||||||
initial={{ opacity: 0, y: 30 }}
|
initial={{ opacity: 0, y: 30 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.8, delay: 0.8 }}
|
transition={{ duration: 0.8, delay: 0.8 }}
|
||||||
className="text-5xl md:text-7xl font-bold mb-6"
|
className="text-5xl md:text-7xl font-bold mb-4"
|
||||||
>
|
>
|
||||||
<span className="gradient-text">Dennis Konkol</span>
|
<span className="gradient-text">Dennis Konkol</span>
|
||||||
</motion.h1>
|
</motion.h1>
|
||||||
@@ -146,7 +155,7 @@ const Hero = () => {
|
|||||||
<motion.p
|
<motion.p
|
||||||
initial={{ opacity: 0, y: 30 }}
|
initial={{ opacity: 0, y: 30 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.8, delay: 1.0 }}
|
transition={{ duration: 0.8, delay: 1.1 }}
|
||||||
className="text-xl md:text-2xl text-gray-300 mb-8 max-w-2xl mx-auto"
|
className="text-xl md:text-2xl text-gray-300 mb-8 max-w-2xl mx-auto"
|
||||||
>
|
>
|
||||||
Student & Software Engineer based in Osnabrück, Germany
|
Student & Software Engineer based in Osnabrück, Germany
|
||||||
@@ -216,15 +225,15 @@ const Hero = () => {
|
|||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
transition={{ duration: 1, delay: 1.5 }}
|
transition={{ duration: 1, delay: 1.5 }}
|
||||||
className="mt-16 text-center"
|
className="mt-12 md:mt-16 text-center relative z-20"
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
animate={{ y: [0, 10, 0] }}
|
animate={{ y: [0, 10, 0] }}
|
||||||
transition={{ duration: 2, repeat: Infinity }}
|
transition={{ duration: 2, repeat: Infinity }}
|
||||||
className="flex flex-col items-center text-gray-400"
|
className="flex flex-col items-center text-white/90 bg-black/30 backdrop-blur-md px-6 py-3 rounded-full border border-white/20 shadow-lg"
|
||||||
>
|
>
|
||||||
<span className="text-sm mb-2">Scroll Down</span>
|
<span className="text-sm md:text-base mb-2 font-medium">Scroll Down</span>
|
||||||
<ArrowDown className="w-5 h-5" />
|
<ArrowDown className="w-5 h-5 md:w-6 md:h-6" />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,15 @@
|
|||||||
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
|
||||||
|
|
||||||
|
/* Monaco Font for Domain */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Monaco';
|
||||||
|
src: url('https://fonts.gstatic.com/s/monaco/v1/Monaco-Regular.woff2') format('woff2');
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: #0a0a0a;
|
--background: #0a0a0a;
|
||||||
--foreground: #fafafa;
|
--foreground: #fafafa;
|
||||||
@@ -83,6 +92,27 @@ body {
|
|||||||
background-clip: text;
|
background-clip: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Domain Text with Monaco Font */
|
||||||
|
.domain-text {
|
||||||
|
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.domain-text {
|
||||||
|
font-size: 3.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.domain-text {
|
||||||
|
font-size: 4rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.gradient-text-blue {
|
.gradient-text-blue {
|
||||||
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
@@ -92,9 +122,64 @@ body {
|
|||||||
|
|
||||||
/* Animated Background */
|
/* Animated Background */
|
||||||
.animated-bg {
|
.animated-bg {
|
||||||
background: linear-gradient(-45deg, #0f0f0f, #1a1a1a, #0f0f0f, #1a1a1a);
|
background:
|
||||||
background-size: 400% 400%;
|
radial-gradient(circle at 20% 80%, rgba(59, 130, 246, 0.08) 0%, transparent 50%),
|
||||||
animation: gradientShift 15s ease infinite;
|
radial-gradient(circle at 80% 20%, rgba(139, 92, 246, 0.08) 0%, transparent 50%),
|
||||||
|
radial-gradient(circle at 40% 40%, rgba(236, 72, 153, 0.04) 0%, transparent 50%),
|
||||||
|
linear-gradient(-45deg, #0a0a0a, #111111, #0d0d0d, #151515);
|
||||||
|
background-size: 400% 400%, 400% 400%, 400% 400%, 400% 400%;
|
||||||
|
animation: gradientShift 25s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Film Grain / TV Noise Effect */
|
||||||
|
.animated-bg::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(circle at 2px 2px, rgba(255,255,255,0.08) 2px, transparent 0),
|
||||||
|
radial-gradient(circle at 4px 4px, rgba(0,0,0,0.04) 2px, transparent 0),
|
||||||
|
radial-gradient(circle at 6px 6px, rgba(255,255,255,0.06) 2px, transparent 0),
|
||||||
|
radial-gradient(circle at 8px 8px, rgba(0,0,0,0.03) 2px, transparent 0);
|
||||||
|
background-size: 4px 4px, 6px 6px, 8px 8px, 10px 10px;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes filmGrain {
|
||||||
|
0%, 100% {
|
||||||
|
background-position: 0px 0px, 0px 0px, 0px 0px;
|
||||||
|
}
|
||||||
|
10% {
|
||||||
|
background-position: -1px -1px, 1px 1px, -1px 1px;
|
||||||
|
}
|
||||||
|
20% {
|
||||||
|
background-position: 1px -1px, -1px 1px, 1px -1px;
|
||||||
|
}
|
||||||
|
30% {
|
||||||
|
background-position: -1px 1px, 1px -1px, -1px -1px;
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
background-position: 1px 1px, -1px -1px, 1px 1px;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
background-position: -1px -1px, 1px 1px, -1px 1px;
|
||||||
|
}
|
||||||
|
60% {
|
||||||
|
background-position: 1px -1px, -1px 1px, 1px -1px;
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
background-position: -1px 1px, 1px -1px, -1px -1px;
|
||||||
|
}
|
||||||
|
80% {
|
||||||
|
background-position: 1px 1px, -1px -1px, 1px 1px;
|
||||||
|
}
|
||||||
|
90% {
|
||||||
|
background-position: -1px -1px, 1px 1px, -1px 1px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes gradientShift {
|
@keyframes gradientShift {
|
||||||
|
|||||||
@@ -39,15 +39,15 @@ export const metadata: Metadata = {
|
|||||||
title: "Dennis Konkol | Portfolio",
|
title: "Dennis Konkol | Portfolio",
|
||||||
description: "Portfolio of Dennis Konkol, a student and software engineer based in Osnabrück, Germany. Passionate about technology, coding, and solving real-world problems.",
|
description: "Portfolio of Dennis Konkol, a student and software engineer based in Osnabrück, Germany. Passionate about technology, coding, and solving real-world problems.",
|
||||||
keywords: ["Dennis Konkol", "Software Engineer", "Portfolio", "Student"],
|
keywords: ["Dennis Konkol", "Software Engineer", "Portfolio", "Student"],
|
||||||
authors: [{name: "Dennis Konkol", url: "https://dki.one"}],
|
authors: [{name: "Dennis Konkol", url: "https://dk0.dev"}],
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: "Dennis Konkol | Portfolio",
|
title: "Dennis Konkol | Portfolio",
|
||||||
description: "Explore my projects and get in touch!",
|
description: "Explore my projects and get in touch!",
|
||||||
url: "https://dki.one",
|
url: "https://dk0.dev",
|
||||||
siteName: "Dennis Konkol Portfolio",
|
siteName: "Dennis Konkol Portfolio",
|
||||||
images: [
|
images: [
|
||||||
{
|
{
|
||||||
url: "https://dki.one/api/og",
|
url: "https://dk0.dev/api/og",
|
||||||
width: 1200,
|
width: 1200,
|
||||||
height: 630,
|
height: 630,
|
||||||
alt: "Dennis Konkol Portfolio",
|
alt: "Dennis Konkol Portfolio",
|
||||||
@@ -59,6 +59,6 @@ export const metadata: Metadata = {
|
|||||||
card: "summary_large_image",
|
card: "summary_large_image",
|
||||||
title: "Dennis Konkol | Portfolio",
|
title: "Dennis Konkol | Portfolio",
|
||||||
description: "Student & Software Engineer based in Osnabrück, Germany.",
|
description: "Student & Software Engineer based in Osnabrück, Germany.",
|
||||||
images: ["https://dki.one/api/og"],
|
images: ["https://dk0.dev/api/og"],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
10
app/page.tsx
10
app/page.tsx
@@ -9,7 +9,7 @@ import Script from "next/script";
|
|||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen animated-bg">
|
<div className="min-h-screen">
|
||||||
<Script
|
<Script
|
||||||
id={"structured-data"}
|
id={"structured-data"}
|
||||||
type="application/ld+json"
|
type="application/ld+json"
|
||||||
@@ -18,7 +18,7 @@ export default function Home() {
|
|||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "Person",
|
"@type": "Person",
|
||||||
name: "Dennis Konkol",
|
name: "Dennis Konkol",
|
||||||
url: "https://dki.one",
|
url: "https://dk0.dev",
|
||||||
jobTitle: "Software Engineer",
|
jobTitle: "Software Engineer",
|
||||||
address: {
|
address: {
|
||||||
"@type": "PostalAddress",
|
"@type": "PostalAddress",
|
||||||
@@ -35,8 +35,10 @@ export default function Home() {
|
|||||||
<Header />
|
<Header />
|
||||||
<main>
|
<main>
|
||||||
<Hero />
|
<Hero />
|
||||||
<Projects />
|
<div className="bg-gradient-to-b from-gray-900 to-black">
|
||||||
<Contact />
|
<Projects />
|
||||||
|
<Contact />
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,577 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import {
|
|
||||||
Database,
|
|
||||||
Search,
|
|
||||||
BarChart3,
|
|
||||||
Download,
|
|
||||||
Upload,
|
|
||||||
Edit,
|
|
||||||
Eye,
|
|
||||||
Plus,
|
|
||||||
TrendingUp,
|
|
||||||
Users,
|
|
||||||
Star,
|
|
||||||
Tag,
|
|
||||||
FolderOpen,
|
|
||||||
Calendar,
|
|
||||||
Activity
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { projectService } from '@/lib/prisma';
|
|
||||||
import { useToast } from './Toast';
|
|
||||||
|
|
||||||
interface Project {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
content: string;
|
|
||||||
imageUrl?: string | null;
|
|
||||||
github?: string | null;
|
|
||||||
liveUrl?: string | null;
|
|
||||||
tags: string[];
|
|
||||||
category: string;
|
|
||||||
difficulty: string;
|
|
||||||
featured: boolean;
|
|
||||||
published: boolean;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
_count?: {
|
|
||||||
pageViews: number;
|
|
||||||
userInteractions: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AdminDashboardProps {
|
|
||||||
onProjectSelect: (project: Project) => void;
|
|
||||||
onNewProject: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AdminDashboard({ onProjectSelect, onNewProject }: AdminDashboardProps) {
|
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const [selectedCategory, setSelectedCategory] = useState<string>('');
|
|
||||||
const [sortBy, setSortBy] = useState<'date' | 'title' | 'difficulty' | 'views'>('date');
|
|
||||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
|
||||||
const [selectedProjects, setSelectedProjects] = useState<Set<number>>(new Set());
|
|
||||||
const [showStats, setShowStats] = useState(false);
|
|
||||||
const { showImportSuccess, showImportError, showError } = useToast();
|
|
||||||
|
|
||||||
// Load projects from database
|
|
||||||
useEffect(() => {
|
|
||||||
loadProjects();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadProjects = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const data = await projectService.getAllProjects();
|
|
||||||
setProjects(data.projects);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading projects:', error);
|
|
||||||
// Fallback to localStorage if database fails
|
|
||||||
const savedProjects = localStorage.getItem('portfolio-projects');
|
|
||||||
if (savedProjects) {
|
|
||||||
setProjects(JSON.parse(savedProjects));
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Filter and sort projects
|
|
||||||
const filteredProjects = projects
|
|
||||||
.filter(project => {
|
|
||||||
const matchesSearch = project.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
project.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
project.tags.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase()));
|
|
||||||
const matchesCategory = !selectedCategory || project.category === selectedCategory;
|
|
||||||
return matchesSearch && matchesCategory;
|
|
||||||
})
|
|
||||||
.sort((a, b) => {
|
|
||||||
let aValue: string | number | Date, bValue: string | number | Date;
|
|
||||||
|
|
||||||
switch (sortBy) {
|
|
||||||
case 'date':
|
|
||||||
aValue = new Date(a.createdAt);
|
|
||||||
bValue = new Date(b.createdAt);
|
|
||||||
break;
|
|
||||||
case 'title':
|
|
||||||
aValue = a.title.toLowerCase();
|
|
||||||
bValue = b.title.toLowerCase();
|
|
||||||
break;
|
|
||||||
case 'difficulty':
|
|
||||||
const difficultyOrder = { 'Beginner': 1, 'Intermediate': 2, 'Advanced': 3, 'Expert': 4 };
|
|
||||||
aValue = difficultyOrder[a.difficulty as keyof typeof difficultyOrder];
|
|
||||||
bValue = difficultyOrder[b.difficulty as keyof typeof difficultyOrder];
|
|
||||||
break;
|
|
||||||
case 'views':
|
|
||||||
aValue = a._count?.pageViews || 0;
|
|
||||||
bValue = b._count?.pageViews || 0;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
aValue = a.createdAt;
|
|
||||||
bValue = b.createdAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sortOrder === 'asc') {
|
|
||||||
return aValue > bValue ? 1 : -1;
|
|
||||||
} else {
|
|
||||||
return aValue < bValue ? 1 : -1;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Statistics
|
|
||||||
const stats = {
|
|
||||||
total: projects.length,
|
|
||||||
published: projects.filter(p => p.published).length,
|
|
||||||
featured: projects.filter(p => p.featured).length,
|
|
||||||
categories: new Set(projects.map(p => p.category)).size,
|
|
||||||
totalViews: projects.reduce((sum, p) => sum + (p._count?.pageViews || 0), 0),
|
|
||||||
totalLikes: projects.reduce((sum, p) => sum + (p._count?.userInteractions || 0), 0),
|
|
||||||
avgLighthouse: 0
|
|
||||||
};
|
|
||||||
|
|
||||||
// Bulk operations
|
|
||||||
const handleBulkDelete = async () => {
|
|
||||||
if (selectedProjects.size === 0) return;
|
|
||||||
|
|
||||||
if (confirm(`Are you sure you want to delete ${selectedProjects.size} projects?`)) {
|
|
||||||
try {
|
|
||||||
for (const id of selectedProjects) {
|
|
||||||
await projectService.deleteProject(id);
|
|
||||||
}
|
|
||||||
setSelectedProjects(new Set());
|
|
||||||
await loadProjects();
|
|
||||||
showImportSuccess(selectedProjects.size); // Reuse for success message
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error deleting projects:', error);
|
|
||||||
showError('Fehler beim Löschen', 'Einige Projekte konnten nicht gelöscht werden.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBulkPublish = async (published: boolean) => {
|
|
||||||
if (selectedProjects.size === 0) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
for (const id of selectedProjects) {
|
|
||||||
await projectService.updateProject(id, { published });
|
|
||||||
}
|
|
||||||
setSelectedProjects(new Set());
|
|
||||||
await loadProjects();
|
|
||||||
showImportSuccess(selectedProjects.size); // Reuse for success message
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating projects:', error);
|
|
||||||
showError('Fehler beim Aktualisieren', 'Einige Projekte konnten nicht aktualisiert werden.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Export/Import
|
|
||||||
const exportProjects = () => {
|
|
||||||
const dataStr = JSON.stringify(projects, null, 2);
|
|
||||||
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
|
||||||
const url = URL.createObjectURL(dataBlob);
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = url;
|
|
||||||
link.download = `portfolio-projects-${new Date().toISOString().split('T')[0]}.json`;
|
|
||||||
link.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
const importProjects = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = event.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = async (e) => {
|
|
||||||
try {
|
|
||||||
const importedProjects = JSON.parse(e.target?.result as string);
|
|
||||||
// Validate and import projects
|
|
||||||
let importedCount = 0;
|
|
||||||
for (const project of importedProjects) {
|
|
||||||
if (project.id) delete project.id; // Remove ID for new import
|
|
||||||
await projectService.createProject(project);
|
|
||||||
importedCount++;
|
|
||||||
}
|
|
||||||
await loadProjects();
|
|
||||||
showImportSuccess(importedCount);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error importing projects:', error);
|
|
||||||
showImportError('Bitte überprüfe das Dateiformat und versuche es erneut.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
reader.readAsText(file);
|
|
||||||
};
|
|
||||||
|
|
||||||
const categories = Array.from(new Set(projects.map(p => p.category)));
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-blue-500"></div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header with Stats */}
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
className="glass-card p-6 rounded-2xl"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<h2 className="text-2xl font-bold text-white flex items-center">
|
|
||||||
<Database className="mr-3 text-blue-400" />
|
|
||||||
Project Database
|
|
||||||
</h2>
|
|
||||||
<div className="flex space-x-3">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowStats(!showStats)}
|
|
||||||
className={`px-4 py-2 rounded-lg transition-colors flex items-center space-x-2 ${
|
|
||||||
showStats ? 'bg-blue-600 text-white' : 'bg-gray-700 text-gray-300 hover:bg-gray-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<BarChart3 size={20} />
|
|
||||||
<span>Stats</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={exportProjects}
|
|
||||||
className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors flex items-center space-x-2"
|
|
||||||
>
|
|
||||||
<Download size={20} />
|
|
||||||
<span>Export</span>
|
|
||||||
</button>
|
|
||||||
<label className="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors flex items-center space-x-2 cursor-pointer">
|
|
||||||
<Upload size={20} />
|
|
||||||
<span>Import</span>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept=".json"
|
|
||||||
onChange={importProjects}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick Stats */}
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
||||||
<div className="bg-gradient-to-br from-blue-500/20 to-blue-600/20 p-4 rounded-xl border border-blue-500/30">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-blue-300">Total Projects</p>
|
|
||||||
<p className="text-2xl font-bold text-white">{stats.total}</p>
|
|
||||||
</div>
|
|
||||||
<FolderOpen className="text-blue-400" size={24} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-gradient-to-br from-green-500/20 to-green-600/20 p-4 rounded-xl border border-green-500/30">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-green-300">Published</p>
|
|
||||||
<p className="text-2xl font-bold text-white">{stats.published}</p>
|
|
||||||
</div>
|
|
||||||
<Eye className="text-green-400" size={24} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-gradient-to-br from-yellow-500/20 to-yellow-600/20 p-4 rounded-xl border border-yellow-500/30">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-yellow-300">Featured</p>
|
|
||||||
<p className="text-2xl font-bold text-white">{stats.featured}</p>
|
|
||||||
</div>
|
|
||||||
<Star className="text-yellow-400" size={24} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-gradient-to-br from-purple-500/20 to-purple-600/20 p-4 rounded-xl border border-purple-500/30">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-purple-300">Categories</p>
|
|
||||||
<p className="text-2xl font-bold text-white">{stats.categories}</p>
|
|
||||||
</div>
|
|
||||||
<Tag className="text-purple-400" size={24} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Extended Stats */}
|
|
||||||
{showStats && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, height: 0 }}
|
|
||||||
animate={{ opacity: 1, height: 'auto' }}
|
|
||||||
exit={{ opacity: 0, height: 0 }}
|
|
||||||
className="mt-6 grid grid-cols-1 md:grid-cols-3 gap-4"
|
|
||||||
>
|
|
||||||
<div className="bg-gradient-to-br from-indigo-500/20 to-indigo-600/20 p-4 rounded-xl border border-indigo-500/30">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-indigo-300">Total Views</p>
|
|
||||||
<p className="text-xl font-bold text-white">{stats.totalViews.toLocaleString()}</p>
|
|
||||||
</div>
|
|
||||||
<TrendingUp className="text-indigo-400" size={20} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-gradient-to-br from-pink-500/20 to-pink-600/20 p-4 rounded-xl border border-pink-500/30">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-pink-300">Total Likes</p>
|
|
||||||
<p className="text-xl font-bold text-white">{stats.totalLikes.toLocaleString()}</p>
|
|
||||||
</div>
|
|
||||||
<Users className="text-pink-400" size={20} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-gradient-to-br from-orange-500/20 to-orange-600/20 p-4 rounded-xl border border-orange-500/30">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-orange-300">Avg Lighthouse</p>
|
|
||||||
<p className="text-xl font-bold text-white">{stats.avgLighthouse}/100</p>
|
|
||||||
</div>
|
|
||||||
<Activity className="text-orange-400" size={20} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Controls */}
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: 0.1 }}
|
|
||||||
className="glass-card p-6 rounded-2xl"
|
|
||||||
>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
|
|
||||||
{/* Search */}
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search projects..."
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
className="w-full pl-10 pr-4 py-2 bg-gray-800/50 border border-gray-700 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Category Filter */}
|
|
||||||
<div>
|
|
||||||
<select
|
|
||||||
value={selectedCategory}
|
|
||||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
|
||||||
className="w-full px-4 py-2 bg-gray-800/50 border border-gray-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
>
|
|
||||||
<option value="">All Categories</option>
|
|
||||||
{categories.map(cat => (
|
|
||||||
<option key={cat} value={cat}>{cat}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sort By */}
|
|
||||||
<div>
|
|
||||||
<select
|
|
||||||
value={sortBy}
|
|
||||||
onChange={(e) => setSortBy(e.target.value as 'date' | 'title' | 'difficulty' | 'views')}
|
|
||||||
className="w-full px-4 py-2 bg-gray-800/50 border border-gray-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
>
|
|
||||||
<option value="date">Sort by Date</option>
|
|
||||||
<option value="title">Sort by Title</option>
|
|
||||||
<option value="difficulty">Sort by Difficulty</option>
|
|
||||||
<option value="views">Sort by Views</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sort Order */}
|
|
||||||
<div>
|
|
||||||
<select
|
|
||||||
value={sortOrder}
|
|
||||||
onChange={(e) => setSortOrder(e.target.value as 'asc' | 'desc')}
|
|
||||||
className="w-full px-4 py-2 bg-gray-800/50 border border-gray-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
>
|
|
||||||
<option value="desc">Descending</option>
|
|
||||||
<option value="asc">Ascending</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bulk Actions */}
|
|
||||||
{selectedProjects.size > 0 && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: -10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
className="flex items-center space-x-4 p-4 bg-blue-500/10 border border-blue-500/30 rounded-lg"
|
|
||||||
>
|
|
||||||
<span className="text-blue-300 font-medium">
|
|
||||||
{selectedProjects.size} project(s) selected
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => handleBulkPublish(true)}
|
|
||||||
className="px-3 py-1 bg-green-600 hover:bg-green-700 text-white rounded text-sm transition-colors"
|
|
||||||
>
|
|
||||||
Publish All
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleBulkPublish(false)}
|
|
||||||
className="px-3 py-1 bg-yellow-600 hover:bg-yellow-700 text-white rounded text-sm transition-colors"
|
|
||||||
>
|
|
||||||
Unpublish All
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleBulkDelete}
|
|
||||||
className="px-3 py-1 bg-red-600 hover:bg-red-700 text-white rounded text-sm transition-colors"
|
|
||||||
>
|
|
||||||
Delete All
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setSelectedProjects(new Set())}
|
|
||||||
className="px-3 py-1 bg-gray-600 hover:bg-gray-700 text-white rounded text-sm transition-colors"
|
|
||||||
>
|
|
||||||
Clear Selection
|
|
||||||
</button>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Projects List */}
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: 0.2 }}
|
|
||||||
className="glass-card p-6 rounded-2xl"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<h3 className="text-xl font-bold text-white">
|
|
||||||
Projects ({filteredProjects.length})
|
|
||||||
</h3>
|
|
||||||
<button
|
|
||||||
onClick={onNewProject}
|
|
||||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors flex items-center space-x-2"
|
|
||||||
>
|
|
||||||
<Plus size={20} />
|
|
||||||
<span>New Project</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
{filteredProjects.map((project) => (
|
|
||||||
<motion.div
|
|
||||||
key={project.id}
|
|
||||||
layout
|
|
||||||
initial={{ opacity: 0, x: -20 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
exit={{ opacity: 0, x: 20 }}
|
|
||||||
className={`p-4 rounded-lg cursor-pointer transition-all border ${
|
|
||||||
selectedProjects.has(project.id)
|
|
||||||
? 'bg-blue-600/20 border-blue-500/50'
|
|
||||||
: 'bg-gray-800/30 hover:bg-gray-700/30 border-gray-700/50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center space-x-4 flex-1">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={selectedProjects.has(project.id)}
|
|
||||||
onChange={(e) => {
|
|
||||||
const newSelected = new Set(selectedProjects);
|
|
||||||
if (e.target.checked) {
|
|
||||||
newSelected.add(project.id);
|
|
||||||
} else {
|
|
||||||
newSelected.delete(project.id);
|
|
||||||
}
|
|
||||||
setSelectedProjects(newSelected);
|
|
||||||
}}
|
|
||||||
className="w-4 h-4 text-blue-600 bg-gray-800 border-gray-700 rounded focus:ring-blue-500 focus:ring-2"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center space-x-3 mb-2">
|
|
||||||
<h4 className="font-medium text-white">{project.title}</h4>
|
|
||||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
|
||||||
project.difficulty === 'Beginner' ? 'bg-green-500/20 text-green-400' :
|
|
||||||
project.difficulty === 'Intermediate' ? 'bg-yellow-500/20 text-yellow-400' :
|
|
||||||
project.difficulty === 'Advanced' ? 'bg-orange-500/20 text-orange-400' :
|
|
||||||
'bg-red-500/20 text-red-400'
|
|
||||||
}`}>
|
|
||||||
{project.difficulty}
|
|
||||||
</span>
|
|
||||||
{project.featured && (
|
|
||||||
<span className="px-2 py-1 bg-yellow-500/20 text-yellow-400 rounded text-xs font-medium">
|
|
||||||
Featured
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{project.published ? (
|
|
||||||
<span className="px-2 py-1 bg-green-500/20 text-green-400 rounded text-xs font-medium">
|
|
||||||
Published
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="px-2 py-1 bg-gray-500/20 text-gray-400 rounded text-xs font-medium">
|
|
||||||
Draft
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-sm text-gray-400 mb-2">{project.description}</p>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-4 text-xs text-gray-500">
|
|
||||||
<span className="flex items-center">
|
|
||||||
<Tag className="mr-1" size={14} />
|
|
||||||
{project.category}
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center">
|
|
||||||
<Calendar className="mr-1" size={14} />
|
|
||||||
{new Date(project.createdAt).toLocaleDateString()}
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center">
|
|
||||||
<Eye className="mr-1" size={14} />
|
|
||||||
{project._count?.pageViews || 0} views
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center">
|
|
||||||
<Activity className="mr-1" size={14} />
|
|
||||||
N/A
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<button
|
|
||||||
onClick={() => onProjectSelect(project)}
|
|
||||||
className="p-2 text-gray-400 hover:text-blue-400 transition-colors"
|
|
||||||
title="Edit Project"
|
|
||||||
>
|
|
||||||
<Edit size={16} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => window.open(`/projects/${project.id}`, '_blank')}
|
|
||||||
className="p-2 text-gray-400 hover:text-green-400 transition-colors"
|
|
||||||
title="View Project"
|
|
||||||
>
|
|
||||||
<Eye size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{filteredProjects.length === 0 && (
|
|
||||||
<div className="text-center py-12 text-gray-500">
|
|
||||||
<FolderOpen className="mx-auto mb-4" size={48} />
|
|
||||||
<p className="text-lg font-medium">No projects found</p>
|
|
||||||
<p className="text-sm">Try adjusting your search or filters</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
250
components/EmailManager.tsx
Normal file
250
components/EmailManager.tsx
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { EmailResponder } from './EmailResponder';
|
||||||
|
|
||||||
|
interface ContactMessage {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
subject: string;
|
||||||
|
message: string;
|
||||||
|
timestamp: string;
|
||||||
|
responded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EmailManager: React.FC = () => {
|
||||||
|
const [messages, setMessages] = useState<ContactMessage[]>([]);
|
||||||
|
const [selectedMessage, setSelectedMessage] = useState<ContactMessage | null>(null);
|
||||||
|
const [showResponder, setShowResponder] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [filter, setFilter] = useState<'all' | 'unread' | 'responded'>('all');
|
||||||
|
|
||||||
|
// Mock data for demonstration - in real app, fetch from API
|
||||||
|
useEffect(() => {
|
||||||
|
const mockMessages: ContactMessage[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Max Mustermann',
|
||||||
|
email: 'max@example.com',
|
||||||
|
subject: 'Projekt-Anfrage',
|
||||||
|
message: 'Hallo Dennis,\n\nich interessiere mich für eine Zusammenarbeit an einem Web-Projekt. Können wir uns mal unterhalten?\n\nViele Grüße\nMax',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
responded: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'Anna Schmidt',
|
||||||
|
email: 'anna@example.com',
|
||||||
|
subject: 'Frage zu deinem Portfolio',
|
||||||
|
message: 'Hi Dennis,\n\nsehr cooles Portfolio! Wie lange hast du an dem Design gearbeitet?\n\nLG Anna',
|
||||||
|
timestamp: new Date(Date.now() - 86400000).toISOString(),
|
||||||
|
responded: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
name: 'Tom Weber',
|
||||||
|
email: 'tom@example.com',
|
||||||
|
subject: 'Job-Anfrage',
|
||||||
|
message: 'Hallo,\n\nwir suchen einen Full-Stack Developer. Bist du interessiert?\n\nTom',
|
||||||
|
timestamp: new Date(Date.now() - 172800000).toISOString(),
|
||||||
|
responded: false
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setMessages(mockMessages);
|
||||||
|
setIsLoading(false);
|
||||||
|
}, 1000);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filteredMessages = messages.filter(message => {
|
||||||
|
switch (filter) {
|
||||||
|
case 'unread':
|
||||||
|
return !message.responded;
|
||||||
|
case 'responded':
|
||||||
|
return message.responded;
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleRespond = (message: ContactMessage) => {
|
||||||
|
setSelectedMessage(message);
|
||||||
|
setShowResponder(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResponseSent = () => {
|
||||||
|
if (selectedMessage) {
|
||||||
|
setMessages(prev => prev.map(msg =>
|
||||||
|
msg.id === selectedMessage.id
|
||||||
|
? { ...msg, responded: true }
|
||||||
|
: msg
|
||||||
|
));
|
||||||
|
}
|
||||||
|
setShowResponder(false);
|
||||||
|
setSelectedMessage(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (timestamp: string) => {
|
||||||
|
return new Date(timestamp).toLocaleString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMessagePreview = (message: string) => {
|
||||||
|
return message.length > 100 ? message.substring(0, 100) + '...' : message;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900">📧 E-Mail Manager</h2>
|
||||||
|
<p className="text-gray-600 mt-1">Verwalte Kontaktanfragen und sende schöne Antworten</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
{filteredMessages.length} von {messages.length} Nachrichten
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border p-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setFilter('all')}
|
||||||
|
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||||
|
filter === 'all'
|
||||||
|
? 'bg-blue-100 text-blue-700 border border-blue-200'
|
||||||
|
: 'text-gray-600 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Alle ({messages.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilter('unread')}
|
||||||
|
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||||
|
filter === 'unread'
|
||||||
|
? 'bg-red-100 text-red-700 border border-red-200'
|
||||||
|
: 'text-gray-600 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Ungelesen ({messages.filter(m => !m.responded).length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilter('responded')}
|
||||||
|
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||||
|
filter === 'responded'
|
||||||
|
? 'bg-green-100 text-green-700 border border-green-200'
|
||||||
|
: 'text-gray-600 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Beantwortet ({messages.filter(m => m.responded).length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages List */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{filteredMessages.length === 0 ? (
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border p-12 text-center">
|
||||||
|
<div className="text-6xl mb-4">📭</div>
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 mb-2">Keine Nachrichten</h3>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
{filter === 'unread' && 'Alle Nachrichten wurden beantwortet!'}
|
||||||
|
{filter === 'responded' && 'Noch keine Nachrichten beantwortet.'}
|
||||||
|
{filter === 'all' && 'Noch keine Kontaktanfragen eingegangen.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredMessages.map((message) => (
|
||||||
|
<div
|
||||||
|
key={message.id}
|
||||||
|
className={`bg-white rounded-xl shadow-sm border p-6 transition-all hover:shadow-md ${
|
||||||
|
!message.responded ? 'border-l-4 border-l-red-500' : 'border-l-4 border-l-green-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<div className={`w-3 h-3 rounded-full ${
|
||||||
|
message.responded ? 'bg-green-500' : 'bg-red-500'
|
||||||
|
}`}></div>
|
||||||
|
<h3 className="font-semibold text-gray-900">{message.name}</h3>
|
||||||
|
<span className="text-sm text-gray-500">{message.email}</span>
|
||||||
|
{!message.responded && (
|
||||||
|
<span className="px-2 py-1 bg-red-100 text-red-700 text-xs rounded-full font-medium">
|
||||||
|
Neu
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 className="font-medium text-gray-800 mb-2">{message.subject}</h4>
|
||||||
|
|
||||||
|
<p className="text-gray-600 text-sm mb-3 whitespace-pre-wrap">
|
||||||
|
{getMessagePreview(message.message)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 text-xs text-gray-500">
|
||||||
|
<span>📅 {formatDate(message.timestamp)}</span>
|
||||||
|
{message.responded && (
|
||||||
|
<span className="text-green-600 font-medium">✅ Beantwortet</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 ml-4">
|
||||||
|
{!message.responded && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleRespond(message)}
|
||||||
|
className="px-4 py-2 bg-gradient-to-r from-blue-600 to-purple-600 text-white rounded-lg hover:from-blue-700 hover:to-purple-700 transition-all font-medium text-sm flex items-center gap-2"
|
||||||
|
>
|
||||||
|
📧 Antworten
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedMessage(message);
|
||||||
|
// Show full message modal
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors font-medium text-sm"
|
||||||
|
>
|
||||||
|
👁️ Ansehen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email Responder Modal */}
|
||||||
|
{showResponder && selectedMessage && (
|
||||||
|
<EmailResponder
|
||||||
|
contactEmail={selectedMessage.email}
|
||||||
|
contactName={selectedMessage.name}
|
||||||
|
originalMessage={selectedMessage.message}
|
||||||
|
onClose={handleResponseSent}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
234
components/EmailResponder.tsx
Normal file
234
components/EmailResponder.tsx
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Toast } from './Toast';
|
||||||
|
|
||||||
|
interface EmailResponderProps {
|
||||||
|
contactEmail: string;
|
||||||
|
contactName: string;
|
||||||
|
originalMessage: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EmailResponder: React.FC<EmailResponderProps> = ({
|
||||||
|
contactEmail,
|
||||||
|
contactName,
|
||||||
|
originalMessage,
|
||||||
|
onClose
|
||||||
|
}) => {
|
||||||
|
const [selectedTemplate, setSelectedTemplate] = useState<'welcome' | 'project' | 'quick'>('welcome');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [showToast, setShowToast] = useState(false);
|
||||||
|
const [toastMessage, setToastMessage] = useState('');
|
||||||
|
const [toastType, setToastType] = useState<'success' | 'error'>('success');
|
||||||
|
|
||||||
|
const templates = {
|
||||||
|
welcome: {
|
||||||
|
name: 'Willkommen',
|
||||||
|
description: 'Freundliche Begrüßung mit Portfolio-Links',
|
||||||
|
icon: '👋',
|
||||||
|
color: 'from-green-500 to-emerald-600'
|
||||||
|
},
|
||||||
|
project: {
|
||||||
|
name: 'Projekt-Anfrage',
|
||||||
|
description: 'Professionelle Antwort für Projekt-Diskussionen',
|
||||||
|
icon: '🚀',
|
||||||
|
color: 'from-purple-500 to-violet-600'
|
||||||
|
},
|
||||||
|
quick: {
|
||||||
|
name: 'Schnelle Antwort',
|
||||||
|
description: 'Kurze, schnelle Bestätigung',
|
||||||
|
icon: '⚡',
|
||||||
|
color: 'from-amber-500 to-orange-600'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendEmail = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/email/respond', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
to: contactEmail,
|
||||||
|
name: contactName,
|
||||||
|
template: selectedTemplate,
|
||||||
|
originalMessage: originalMessage
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setToastMessage(`✅ ${data.message}`);
|
||||||
|
setToastType('success');
|
||||||
|
setShowToast(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
onClose();
|
||||||
|
}, 2000);
|
||||||
|
} else {
|
||||||
|
setToastMessage(`❌ ${data.error}`);
|
||||||
|
setToastType('error');
|
||||||
|
setShowToast(true);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setToastMessage('❌ Fehler beim Senden der E-Mail');
|
||||||
|
setToastType('error');
|
||||||
|
setShowToast(true);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-gradient-to-r from-blue-600 to-purple-600 text-white p-6 rounded-t-2xl">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold">📧 E-Mail Antwort senden</h2>
|
||||||
|
<p className="text-blue-100 mt-1">Wähle ein schönes Template für deine Antwort</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-white hover:text-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6">
|
||||||
|
|
||||||
|
{/* Contact Info */}
|
||||||
|
<div className="bg-gray-50 rounded-xl p-4 mb-6">
|
||||||
|
<h3 className="font-semibold text-gray-800 mb-2">📬 Kontakt-Informationen</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-gray-600">Name:</span>
|
||||||
|
<p className="font-medium text-gray-900">{contactName}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-gray-600">E-Mail:</span>
|
||||||
|
<p className="font-medium text-gray-900">{contactEmail}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Original Message Preview */}
|
||||||
|
<div className="bg-blue-50 rounded-xl p-4 mb-6">
|
||||||
|
<h3 className="font-semibold text-blue-800 mb-2">💬 Ursprüngliche Nachricht</h3>
|
||||||
|
<div className="bg-white rounded-lg p-3 border-l-4 border-blue-500">
|
||||||
|
<p className="text-gray-700 text-sm whitespace-pre-wrap">{originalMessage}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Template Selection */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="font-semibold text-gray-800 mb-4">🎨 Template auswählen</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
{Object.entries(templates).map(([key, template]) => (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className={`relative cursor-pointer rounded-xl border-2 transition-all duration-200 ${
|
||||||
|
selectedTemplate === key
|
||||||
|
? 'border-blue-500 bg-blue-50 shadow-lg scale-105'
|
||||||
|
: 'border-gray-200 hover:border-gray-300 hover:shadow-md'
|
||||||
|
}`}
|
||||||
|
onClick={() => setSelectedTemplate(key as any)}
|
||||||
|
>
|
||||||
|
<div className={`bg-gradient-to-r ${template.color} text-white p-4 rounded-t-xl`}>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-3xl mb-2">{template.icon}</div>
|
||||||
|
<h4 className="font-bold text-lg">{template.name}</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
<p className="text-sm text-gray-600 text-center">{template.description}</p>
|
||||||
|
</div>
|
||||||
|
{selectedTemplate === key && (
|
||||||
|
<div className="absolute top-2 right-2">
|
||||||
|
<div className="w-6 h-6 bg-blue-500 rounded-full flex items-center justify-center">
|
||||||
|
<svg className="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="font-semibold text-gray-800 mb-4">👀 Vorschau</h3>
|
||||||
|
<div className="bg-gray-100 rounded-xl p-4">
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border">
|
||||||
|
<div className={`bg-gradient-to-r ${templates[selectedTemplate].color} text-white p-4 rounded-t-lg`}>
|
||||||
|
<h4 className="font-bold text-lg">{templates[selectedTemplate].icon} {templates[selectedTemplate].name}</h4>
|
||||||
|
<p className="text-sm opacity-90">An: {contactName}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
{selectedTemplate === 'welcome' && 'Freundliche Begrüßung mit Portfolio-Links und nächsten Schritten'}
|
||||||
|
{selectedTemplate === 'project' && 'Professionelle Projekt-Antwort mit Arbeitsprozess und CTA'}
|
||||||
|
{selectedTemplate === 'quick' && 'Schnelle, kurze Bestätigung der Nachricht'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1 px-6 py-3 border border-gray-300 text-gray-700 rounded-xl hover:bg-gray-50 transition-colors font-medium"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSendEmail}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex-1 px-6 py-3 bg-gradient-to-r from-blue-600 to-purple-600 text-white rounded-xl hover:from-blue-700 hover:to-purple-700 transition-all font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<svg className="animate-spin w-5 h-5" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
Sende...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
📧 E-Mail senden
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toast */}
|
||||||
|
{showToast && (
|
||||||
|
<Toast
|
||||||
|
message={toastMessage}
|
||||||
|
type={toastType}
|
||||||
|
onClose={() => setShowToast(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
545
components/ModernAdminDashboard.tsx
Normal file
545
components/ModernAdminDashboard.tsx
Normal file
@@ -0,0 +1,545 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
Mail,
|
||||||
|
Users,
|
||||||
|
BarChart3,
|
||||||
|
Database,
|
||||||
|
Zap,
|
||||||
|
Globe,
|
||||||
|
Shield,
|
||||||
|
Bell,
|
||||||
|
Settings,
|
||||||
|
FileText,
|
||||||
|
TrendingUp,
|
||||||
|
ArrowLeft,
|
||||||
|
Plus,
|
||||||
|
Edit,
|
||||||
|
Trash2,
|
||||||
|
Eye,
|
||||||
|
Save,
|
||||||
|
Upload,
|
||||||
|
Bold,
|
||||||
|
Italic,
|
||||||
|
List,
|
||||||
|
Link as LinkIcon,
|
||||||
|
Image as ImageIcon,
|
||||||
|
Code,
|
||||||
|
Quote,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
Palette,
|
||||||
|
Smile
|
||||||
|
} from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import { EmailManager } from './EmailManager';
|
||||||
|
import { AnalyticsDashboard } from './AnalyticsDashboard';
|
||||||
|
import ImportExport from './ImportExport';
|
||||||
|
|
||||||
|
interface Project {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
content: string;
|
||||||
|
tags: string[];
|
||||||
|
featured: boolean;
|
||||||
|
category: string;
|
||||||
|
date: string;
|
||||||
|
github?: string;
|
||||||
|
live?: string;
|
||||||
|
published: boolean;
|
||||||
|
imageUrl?: string;
|
||||||
|
metaDescription?: string;
|
||||||
|
keywords?: string;
|
||||||
|
ogImage?: string;
|
||||||
|
schema?: Record<string, unknown>;
|
||||||
|
difficulty: 'Beginner' | 'Intermediate' | 'Advanced' | 'Expert';
|
||||||
|
timeToComplete?: string;
|
||||||
|
technologies: string[];
|
||||||
|
challenges: string[];
|
||||||
|
lessonsLearned: string[];
|
||||||
|
futureImprovements: string[];
|
||||||
|
demoVideo?: string;
|
||||||
|
screenshots: string[];
|
||||||
|
colorScheme: string;
|
||||||
|
accessibility: boolean;
|
||||||
|
performance: {
|
||||||
|
lighthouse: number;
|
||||||
|
bundleSize: string;
|
||||||
|
loadTime: string;
|
||||||
|
};
|
||||||
|
analytics: {
|
||||||
|
views: number;
|
||||||
|
likes: number;
|
||||||
|
shares: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModernAdminDashboard: React.FC = () => {
|
||||||
|
const [activeTab, setActiveTab] = useState<'overview' | 'projects' | 'emails' | 'analytics' | 'settings'>('overview');
|
||||||
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [showProjectEditor, setShowProjectEditor] = useState(false);
|
||||||
|
const [isPreview, setIsPreview] = useState(false);
|
||||||
|
const [markdownContent, setMarkdownContent] = useState('');
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
content: '',
|
||||||
|
tags: '',
|
||||||
|
category: '',
|
||||||
|
featured: false,
|
||||||
|
github: '',
|
||||||
|
live: '',
|
||||||
|
published: true,
|
||||||
|
imageUrl: '',
|
||||||
|
difficulty: 'Intermediate' as 'Beginner' | 'Intermediate' | 'Advanced' | 'Expert',
|
||||||
|
timeToComplete: '',
|
||||||
|
technologies: '',
|
||||||
|
challenges: '',
|
||||||
|
lessonsLearned: '',
|
||||||
|
futureImprovements: '',
|
||||||
|
demoVideo: '',
|
||||||
|
screenshots: '',
|
||||||
|
colorScheme: 'Dark',
|
||||||
|
accessibility: true,
|
||||||
|
performance: {
|
||||||
|
lighthouse: 90,
|
||||||
|
bundleSize: '50KB',
|
||||||
|
loadTime: '1.5s'
|
||||||
|
},
|
||||||
|
analytics: {
|
||||||
|
views: 0,
|
||||||
|
likes: 0,
|
||||||
|
shares: 0
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock stats for overview
|
||||||
|
const stats = {
|
||||||
|
totalProjects: projects.length,
|
||||||
|
publishedProjects: projects.filter(p => p.published).length,
|
||||||
|
totalViews: projects.reduce((sum, p) => sum + p.analytics.views, 0),
|
||||||
|
unreadEmails: 3, // This would come from your email API
|
||||||
|
avgPerformance: Math.round(projects.reduce((sum, p) => sum + p.performance.lighthouse, 0) / projects.length) || 90
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadProjects();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadProjects = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const response = await fetch('/api/projects');
|
||||||
|
const data = await response.json();
|
||||||
|
setProjects(data.projects || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading projects:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (project: Project) => {
|
||||||
|
setSelectedProject(project);
|
||||||
|
setFormData({
|
||||||
|
title: project.title,
|
||||||
|
description: project.description,
|
||||||
|
content: project.content,
|
||||||
|
tags: project.tags.join(', '),
|
||||||
|
category: project.category,
|
||||||
|
featured: project.featured,
|
||||||
|
github: project.github || '',
|
||||||
|
live: project.live || '',
|
||||||
|
published: project.published,
|
||||||
|
imageUrl: project.imageUrl || '',
|
||||||
|
difficulty: project.difficulty,
|
||||||
|
timeToComplete: project.timeToComplete || '',
|
||||||
|
technologies: project.technologies.join(', '),
|
||||||
|
challenges: project.challenges.join(', '),
|
||||||
|
lessonsLearned: project.lessonsLearned.join(', '),
|
||||||
|
futureImprovements: project.futureImprovements.join(', '),
|
||||||
|
demoVideo: project.demoVideo || '',
|
||||||
|
screenshots: project.screenshots.join(', '),
|
||||||
|
colorScheme: project.colorScheme,
|
||||||
|
accessibility: project.accessibility,
|
||||||
|
performance: project.performance,
|
||||||
|
analytics: project.analytics
|
||||||
|
});
|
||||||
|
setMarkdownContent(project.content);
|
||||||
|
setShowProjectEditor(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
// Save logic here
|
||||||
|
console.log('Saving project...');
|
||||||
|
await loadProjects();
|
||||||
|
setShowProjectEditor(false);
|
||||||
|
setSelectedProject(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (projectId: number) => {
|
||||||
|
if (confirm('Are you sure you want to delete this project?')) {
|
||||||
|
try {
|
||||||
|
await fetch(`/api/projects/${projectId}`, { method: 'DELETE' });
|
||||||
|
await loadProjects();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting project:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setSelectedProject(null);
|
||||||
|
setFormData({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
content: '',
|
||||||
|
tags: '',
|
||||||
|
category: '',
|
||||||
|
featured: false,
|
||||||
|
github: '',
|
||||||
|
live: '',
|
||||||
|
published: true,
|
||||||
|
imageUrl: '',
|
||||||
|
difficulty: 'Intermediate' as 'Beginner' | 'Intermediate' | 'Advanced' | 'Expert',
|
||||||
|
timeToComplete: '',
|
||||||
|
technologies: '',
|
||||||
|
challenges: '',
|
||||||
|
lessonsLearned: '',
|
||||||
|
futureImprovements: '',
|
||||||
|
demoVideo: '',
|
||||||
|
screenshots: '',
|
||||||
|
colorScheme: 'Dark',
|
||||||
|
accessibility: true,
|
||||||
|
performance: {
|
||||||
|
lighthouse: 90,
|
||||||
|
bundleSize: '50KB',
|
||||||
|
loadTime: '1.5s'
|
||||||
|
},
|
||||||
|
analytics: {
|
||||||
|
views: 0,
|
||||||
|
likes: 0,
|
||||||
|
shares: 0
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setMarkdownContent('');
|
||||||
|
setShowProjectEditor(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'overview', label: 'Overview', icon: BarChart3, color: 'blue' },
|
||||||
|
{ id: 'projects', label: 'Projects', icon: FileText, color: 'green' },
|
||||||
|
{ id: 'emails', label: 'Emails', icon: Mail, color: 'purple' },
|
||||||
|
{ id: 'analytics', label: 'Analytics', icon: TrendingUp, color: 'orange' },
|
||||||
|
{ id: 'settings', label: 'Settings', icon: Settings, color: 'gray' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const categories = [
|
||||||
|
"Web Development", "Full-Stack", "Web Application", "Mobile App",
|
||||||
|
"Desktop App", "API Development", "Database Design", "DevOps",
|
||||||
|
"UI/UX Design", "Game Development", "Machine Learning", "Data Science",
|
||||||
|
"Blockchain", "IoT", "Cybersecurity"
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-white/5 backdrop-blur-md border-b border-white/10 sticky top-0 z-50">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex items-center justify-between h-16">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="flex items-center space-x-2 text-white/80 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
<span>Back to Portfolio</span>
|
||||||
|
</Link>
|
||||||
|
<div className="h-6 w-px bg-white/20" />
|
||||||
|
<h1 className="text-xl font-bold text-white">Admin Dashboard</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="flex items-center space-x-2 text-sm text-white/60">
|
||||||
|
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||||
|
<span>Live</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-white/60 font-mono">
|
||||||
|
dk<span className="text-red-500">0</span>.dev
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6">
|
||||||
|
<nav className="space-y-2">
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const Icon = tab.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id as any)}
|
||||||
|
className={`w-full flex items-center space-x-3 px-4 py-3 rounded-xl transition-all duration-200 ${
|
||||||
|
activeTab === tab.id
|
||||||
|
? `bg-${tab.color}-500/20 text-${tab.color}-400 border border-${tab.color}-500/30`
|
||||||
|
: 'text-white/60 hover:text-white hover:bg-white/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={20} />
|
||||||
|
<span className="font-medium">{tab.label}</span>
|
||||||
|
{tab.id === 'emails' && stats.unreadEmails > 0 && (
|
||||||
|
<span className="ml-auto bg-red-500 text-white text-xs px-2 py-1 rounded-full">
|
||||||
|
{stats.unreadEmails}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="lg:col-span-3">
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{activeTab === 'overview' && (
|
||||||
|
<motion.div
|
||||||
|
key="overview"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -20 }}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
{/* Stats Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-white/60 text-sm">Total Projects</p>
|
||||||
|
<p className="text-2xl font-bold text-white">{stats.totalProjects}</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-blue-500/20 rounded-xl flex items-center justify-center">
|
||||||
|
<FileText className="w-6 h-6 text-blue-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-white/60 text-sm">Published</p>
|
||||||
|
<p className="text-2xl font-bold text-white">{stats.publishedProjects}</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-green-500/20 rounded-xl flex items-center justify-center">
|
||||||
|
<Globe className="w-6 h-6 text-green-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-white/60 text-sm">Total Views</p>
|
||||||
|
<p className="text-2xl font-bold text-white">{stats.totalViews.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-purple-500/20 rounded-xl flex items-center justify-center">
|
||||||
|
<Eye className="w-6 h-6 text-purple-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-white/60 text-sm">Avg Performance</p>
|
||||||
|
<p className="text-2xl font-bold text-white">{stats.avgPerformance}</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-orange-500/20 rounded-xl flex items-center justify-center">
|
||||||
|
<Zap className="w-6 h-6 text-orange-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Projects */}
|
||||||
|
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="text-xl font-bold text-white">Recent Projects</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('projects')}
|
||||||
|
className="text-blue-400 hover:text-blue-300 text-sm font-medium"
|
||||||
|
>
|
||||||
|
View All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{projects.slice(0, 3).map((project) => (
|
||||||
|
<div
|
||||||
|
key={project.id}
|
||||||
|
className="flex items-center space-x-4 p-4 bg-white/5 rounded-xl border border-white/10 hover:bg-white/10 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="w-12 h-12 bg-gradient-to-br from-blue-500 to-purple-500 rounded-xl flex items-center justify-center">
|
||||||
|
<span className="text-white font-bold text-lg">
|
||||||
|
{project.title.charAt(0)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-semibold text-white">{project.title}</h3>
|
||||||
|
<p className="text-white/60 text-sm">{project.category}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||||
|
project.published
|
||||||
|
? 'bg-green-500/20 text-green-400'
|
||||||
|
: 'bg-gray-500/20 text-gray-400'
|
||||||
|
}`}>
|
||||||
|
{project.published ? 'Published' : 'Draft'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleEdit(project)}
|
||||||
|
className="p-2 text-white/60 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Edit size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'projects' && (
|
||||||
|
<motion.div
|
||||||
|
key="projects"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -20 }}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-2xl font-bold text-white">Projects</h2>
|
||||||
|
<button
|
||||||
|
onClick={resetForm}
|
||||||
|
className="flex items-center space-x-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={20} />
|
||||||
|
<span>New Project</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{projects.map((project) => (
|
||||||
|
<div
|
||||||
|
key={project.id}
|
||||||
|
className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6 hover:bg-white/10 transition-all duration-200"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="w-12 h-12 bg-gradient-to-br from-blue-500 to-purple-500 rounded-xl flex items-center justify-center">
|
||||||
|
<span className="text-white font-bold text-lg">
|
||||||
|
{project.title.charAt(0)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleEdit(project)}
|
||||||
|
className="p-2 text-white/60 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Edit size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(project.id)}
|
||||||
|
className="p-2 text-white/60 hover:text-red-400 hover:bg-red-500/10 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="font-semibold text-white mb-2">{project.title}</h3>
|
||||||
|
<p className="text-white/60 text-sm mb-4 line-clamp-2">{project.description}</p>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="px-2 py-1 bg-blue-500/20 text-blue-400 text-xs rounded-full">
|
||||||
|
{project.category}
|
||||||
|
</span>
|
||||||
|
<span className={`px-2 py-1 text-xs rounded-full ${
|
||||||
|
project.published
|
||||||
|
? 'bg-green-500/20 text-green-400'
|
||||||
|
: 'bg-gray-500/20 text-gray-400'
|
||||||
|
}`}>
|
||||||
|
{project.published ? 'Published' : 'Draft'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'emails' && (
|
||||||
|
<motion.div
|
||||||
|
key="emails"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -20 }}
|
||||||
|
>
|
||||||
|
<EmailManager />
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'analytics' && (
|
||||||
|
<motion.div
|
||||||
|
key="analytics"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -20 }}
|
||||||
|
>
|
||||||
|
<AnalyticsDashboard />
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'settings' && (
|
||||||
|
<motion.div
|
||||||
|
key="settings"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -20 }}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
<h2 className="text-2xl font-bold text-white">Settings</h2>
|
||||||
|
|
||||||
|
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-4">Import/Export</h3>
|
||||||
|
<ImportExport />
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModernAdminDashboard;
|
||||||
48
docker-compose.dev.minimal.yml
Normal file
48
docker-compose.dev.minimal.yml
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
services:
|
||||||
|
# PostgreSQL Database (ARM64 optimized)
|
||||||
|
postgres:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
platform: linux/arm64
|
||||||
|
container_name: portfolio_postgres_dev
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: portfolio_dev
|
||||||
|
POSTGRES_USER: portfolio_user
|
||||||
|
POSTGRES_PASSWORD: portfolio_dev_pass
|
||||||
|
POSTGRES_HOST_AUTH_METHOD: trust
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_dev_data:/var/lib/postgresql/data
|
||||||
|
- ./scripts/init-db.sql:/docker-entrypoint-initdb.d/init-db.sql
|
||||||
|
networks:
|
||||||
|
- portfolio_dev
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U portfolio_user -d portfolio_dev"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# Redis for caching (ARM64 optimized)
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
platform: linux/arm64
|
||||||
|
container_name: portfolio_redis_dev
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
volumes:
|
||||||
|
- redis_dev_data:/data
|
||||||
|
networks:
|
||||||
|
- portfolio_dev
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
networks:
|
||||||
|
portfolio_dev:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_dev_data:
|
||||||
|
redis_dev_data:
|
||||||
@@ -20,7 +20,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- portfolio_data:/app/.next/cache
|
- portfolio_data:/app/.next/cache
|
||||||
networks:
|
networks:
|
||||||
- portfolio-network
|
- portfolio_net
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -52,7 +52,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
networks:
|
networks:
|
||||||
- portfolio-network
|
- portfolio_net
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U portfolio_user -d portfolio_db"]
|
test: ["CMD-SHELL", "pg_isready -U portfolio_user -d portfolio_db"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -68,31 +68,6 @@ services:
|
|||||||
memory: 128M
|
memory: 128M
|
||||||
cpus: '0.1'
|
cpus: '0.1'
|
||||||
|
|
||||||
redis:
|
|
||||||
image: redis:7-alpine
|
|
||||||
container_name: portfolio-redis
|
|
||||||
restart: unless-stopped
|
|
||||||
command: redis-server --appendonly yes --requirepass portfolio_redis_pass
|
|
||||||
volumes:
|
|
||||||
- redis_data:/data
|
|
||||||
networks:
|
|
||||||
- portfolio-network
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 3s
|
|
||||||
retries: 5
|
|
||||||
start_period: 30s
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
memory: 128M
|
|
||||||
cpus: '0.1'
|
|
||||||
reservations:
|
|
||||||
memory: 64M
|
|
||||||
cpus: '0.05'
|
|
||||||
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
portfolio_data:
|
portfolio_data:
|
||||||
driver: local
|
driver: local
|
||||||
@@ -102,5 +77,6 @@ volumes:
|
|||||||
driver: local
|
driver: local
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
portfolio-network:
|
portfolio_net:
|
||||||
driver: bridge
|
external: true
|
||||||
|
|
||||||
|
|||||||
@@ -3,15 +3,15 @@
|
|||||||
|
|
||||||
# Application
|
# Application
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
NEXT_PUBLIC_BASE_URL=https://dki.one
|
NEXT_PUBLIC_BASE_URL=https://dk0.dev
|
||||||
|
|
||||||
# Ghost CMS (removed - using built-in project management)
|
# Ghost CMS (removed - using built-in project management)
|
||||||
# GHOST_API_URL=https://your-ghost-instance.com
|
# GHOST_API_URL=https://your-ghost-instance.com
|
||||||
# GHOST_API_KEY=your-ghost-api-key
|
# GHOST_API_KEY=your-ghost-api-key
|
||||||
|
|
||||||
# Email Configuration (optional - for contact form)
|
# Email Configuration (optional - for contact form)
|
||||||
MY_EMAIL=your-email@example.com
|
MY_EMAIL=contact@dk0.dev
|
||||||
MY_INFO_EMAIL=your-info-email@example.com
|
MY_INFO_EMAIL=info@dk0.dev
|
||||||
MY_PASSWORD=your-email-password
|
MY_PASSWORD=your-email-password
|
||||||
MY_INFO_PASSWORD=your-info-email-password
|
MY_INFO_PASSWORD=your-info-email-password
|
||||||
|
|
||||||
|
|||||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -23,6 +23,7 @@
|
|||||||
"prisma": "^5.7.1",
|
"prisma": "^5.7.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"react-icons": "^5.5.0",
|
||||||
"react-markdown": "^9.0.1",
|
"react-markdown": "^9.0.1",
|
||||||
"react-responsive-masonry": "^2.7.1",
|
"react-responsive-masonry": "^2.7.1",
|
||||||
"redis": "^5.8.2",
|
"redis": "^5.8.2",
|
||||||
@@ -10399,6 +10400,15 @@
|
|||||||
"react": "^19.0.0"
|
"react": "^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-icons": {
|
||||||
|
"version": "5.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
|
||||||
|
"integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-is": {
|
"node_modules/react-is": {
|
||||||
"version": "17.0.2",
|
"version": "17.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||||
|
|||||||
12
package.json
12
package.json
@@ -3,27 +3,32 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "node scripts/dev-minimal.js",
|
||||||
|
"dev:simple": "node scripts/dev-simple.js",
|
||||||
|
"dev:next": "next dev",
|
||||||
|
"db:setup": "node scripts/setup-database.js",
|
||||||
|
"db:seed": "tsx prisma/seed.ts",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"lint:fix": "eslint . --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
"pre-push": "./scripts/pre-push.sh",
|
"pre-push": "./scripts/pre-push.sh",
|
||||||
"pre-push:full": "./scripts/pre-push-full.sh",
|
"pre-push:full": "./scripts/pre-push-full.sh",
|
||||||
|
"pre-push:quick": "./scripts/pre-push-quick.sh",
|
||||||
"buildAnalyze": "cross-env ANALYZE=true next build",
|
"buildAnalyze": "cross-env ANALYZE=true next build",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:coverage": "jest --coverage",
|
"test:coverage": "jest --coverage",
|
||||||
"db:setup": "chmod +x scripts/setup-db.sh && ./scripts/setup-db.sh",
|
|
||||||
"db:generate": "prisma generate",
|
"db:generate": "prisma generate",
|
||||||
"db:push": "prisma db push",
|
"db:push": "prisma db push",
|
||||||
"db:seed": "tsx prisma/seed.ts",
|
|
||||||
"db:studio": "prisma studio",
|
"db:studio": "prisma studio",
|
||||||
"db:reset": "prisma db push --force-reset",
|
"db:reset": "prisma db push --force-reset",
|
||||||
"docker:build": "docker build -t portfolio-app .",
|
"docker:build": "docker build -t portfolio-app .",
|
||||||
"docker:run": "docker run -p 3000:3000 portfolio-app",
|
"docker:run": "docker run -p 3000:3000 portfolio-app",
|
||||||
"docker:compose": "docker compose -f docker-compose.prod.yml up -d",
|
"docker:compose": "docker compose -f docker-compose.prod.yml up -d",
|
||||||
"docker:down": "docker compose -f docker-compose.prod.yml down",
|
"docker:down": "docker compose -f docker-compose.prod.yml down",
|
||||||
|
"docker:dev": "docker compose -f docker-compose.dev.minimal.yml up -d",
|
||||||
|
"docker:dev:down": "docker compose -f docker-compose.dev.minimal.yml down",
|
||||||
"deploy": "./scripts/deploy.sh",
|
"deploy": "./scripts/deploy.sh",
|
||||||
"auto-deploy": "./scripts/auto-deploy.sh",
|
"auto-deploy": "./scripts/auto-deploy.sh",
|
||||||
"quick-deploy": "./scripts/quick-deploy.sh",
|
"quick-deploy": "./scripts/quick-deploy.sh",
|
||||||
@@ -49,6 +54,7 @@
|
|||||||
"prisma": "^5.7.1",
|
"prisma": "^5.7.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"react-icons": "^5.5.0",
|
||||||
"react-markdown": "^9.0.1",
|
"react-markdown": "^9.0.1",
|
||||||
"react-responsive-masonry": "^2.7.1",
|
"react-responsive-masonry": "^2.7.1",
|
||||||
"redis": "^5.8.2",
|
"redis": "^5.8.2",
|
||||||
|
|||||||
@@ -101,3 +101,20 @@ enum InteractionType {
|
|||||||
BOOKMARK
|
BOOKMARK
|
||||||
COMMENT
|
COMMENT
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Contact form submissions
|
||||||
|
model Contact {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
name String @db.VarChar(255)
|
||||||
|
email String @db.VarChar(255)
|
||||||
|
subject String @db.VarChar(500)
|
||||||
|
message String @db.Text
|
||||||
|
responded Boolean @default(false)
|
||||||
|
responseTemplate String? @db.VarChar(50) @map("response_template")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@index([email])
|
||||||
|
@@index([responded])
|
||||||
|
@@index([createdAt])
|
||||||
|
}
|
||||||
|
|||||||
88
scripts/dev-minimal.js
Normal file
88
scripts/dev-minimal.js
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const { spawn, exec } = require('child_process');
|
||||||
|
const os = require('os');
|
||||||
|
const isWindows = process.platform === 'win32';
|
||||||
|
|
||||||
|
console.log('🚀 Starting minimal development environment...');
|
||||||
|
|
||||||
|
// Detect architecture
|
||||||
|
const arch = os.arch();
|
||||||
|
const isAppleSilicon = arch === 'arm64' && process.platform === 'darwin';
|
||||||
|
|
||||||
|
console.log(`🖥️ Detected architecture: ${arch}`);
|
||||||
|
console.log(`🍎 Apple Silicon: ${isAppleSilicon ? 'Yes' : 'No'}`);
|
||||||
|
|
||||||
|
// Use minimal compose file (only PostgreSQL and Redis)
|
||||||
|
const composeFile = 'docker-compose.dev.minimal.yml';
|
||||||
|
console.log(`📦 Using minimal Docker Compose file: ${composeFile}`);
|
||||||
|
|
||||||
|
// Check if docker-compose is available
|
||||||
|
exec('docker-compose --version', (error) => {
|
||||||
|
if (error) {
|
||||||
|
console.error('❌ docker-compose not found');
|
||||||
|
console.error('💡 Please install Docker Desktop or use: npm run dev:simple');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ docker-compose found, starting services...');
|
||||||
|
|
||||||
|
// Start Docker services
|
||||||
|
const dockerProcess = spawn('docker-compose', ['-f', composeFile, 'up', '-d'], {
|
||||||
|
stdio: 'inherit',
|
||||||
|
shell: isWindows
|
||||||
|
});
|
||||||
|
|
||||||
|
dockerProcess.on('close', (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
console.log('✅ Docker services started');
|
||||||
|
console.log('🗄️ PostgreSQL: localhost:5432');
|
||||||
|
console.log('📦 Redis: localhost:6379');
|
||||||
|
console.log('📧 Note: Mailhog not included (use npm run dev:simple for email testing)');
|
||||||
|
|
||||||
|
// Wait a bit for services to be ready
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log('🚀 Starting Next.js development server...');
|
||||||
|
|
||||||
|
// Start Next.js dev server
|
||||||
|
const nextProcess = spawn('npm', ['run', 'dev:next'], {
|
||||||
|
stdio: 'inherit',
|
||||||
|
shell: isWindows,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
DATABASE_URL: 'postgresql://portfolio_user:portfolio_dev_pass@localhost:5432/portfolio_dev?schema=public',
|
||||||
|
REDIS_URL: 'redis://localhost:6379',
|
||||||
|
NODE_ENV: 'development'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
nextProcess.on('close', (code) => {
|
||||||
|
console.log(`Next.js dev server exited with code ${code}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle process signals
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
console.log('\n🛑 Stopping development environment...');
|
||||||
|
nextProcess.kill('SIGTERM');
|
||||||
|
|
||||||
|
// Stop Docker services
|
||||||
|
const stopProcess = spawn('docker-compose', ['-f', composeFile, 'down'], {
|
||||||
|
stdio: 'inherit',
|
||||||
|
shell: isWindows
|
||||||
|
});
|
||||||
|
|
||||||
|
stopProcess.on('close', () => {
|
||||||
|
console.log('✅ Development environment stopped');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
}, 5000); // Wait 5 seconds for services to be ready
|
||||||
|
|
||||||
|
} else {
|
||||||
|
console.error('❌ Failed to start Docker services');
|
||||||
|
console.error('💡 Try using: npm run dev:simple');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
40
scripts/dev-simple.js
Normal file
40
scripts/dev-simple.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
const isWindows = process.platform === 'win32';
|
||||||
|
|
||||||
|
console.log('🚀 Starting Next.js development server...');
|
||||||
|
console.log('📝 Note: This is a simplified dev mode without Docker services');
|
||||||
|
console.log('💡 For full development environment with DB, use: npm run dev:full');
|
||||||
|
|
||||||
|
// Set development environment variables
|
||||||
|
const env = {
|
||||||
|
...process.env,
|
||||||
|
NODE_ENV: 'development',
|
||||||
|
DATABASE_URL: 'postgresql://portfolio_user:portfolio_dev_pass@localhost:5432/portfolio_dev?schema=public',
|
||||||
|
REDIS_URL: 'redis://localhost:6379',
|
||||||
|
NEXT_PUBLIC_BASE_URL: 'http://localhost:3000'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start Next.js dev server
|
||||||
|
const nextProcess = spawn('npm', ['run', 'dev:next'], {
|
||||||
|
stdio: 'inherit',
|
||||||
|
shell: isWindows,
|
||||||
|
env
|
||||||
|
});
|
||||||
|
|
||||||
|
nextProcess.on('close', (code) => {
|
||||||
|
console.log(`Next.js dev server exited with code ${code}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle process signals
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
console.log('\n🛑 Stopping development server...');
|
||||||
|
nextProcess.kill('SIGTERM');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
nextProcess.kill('SIGTERM');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
23
scripts/init-db.sql
Normal file
23
scripts/init-db.sql
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
-- Initialize database for development
|
||||||
|
-- This script runs when the PostgreSQL container starts
|
||||||
|
|
||||||
|
-- Create database if it doesn't exist (this is handled by POSTGRES_DB env var)
|
||||||
|
-- The database 'portfolio_dev' is created automatically
|
||||||
|
|
||||||
|
-- Create user if it doesn't exist (this is handled by POSTGRES_USER env var)
|
||||||
|
-- The user 'portfolio_user' is created automatically
|
||||||
|
|
||||||
|
-- Grant permissions
|
||||||
|
GRANT ALL PRIVILEGES ON DATABASE portfolio_dev TO portfolio_user;
|
||||||
|
|
||||||
|
-- Create schema if it doesn't exist
|
||||||
|
CREATE SCHEMA IF NOT EXISTS public;
|
||||||
|
|
||||||
|
-- Grant schema permissions
|
||||||
|
GRANT ALL ON SCHEMA public TO portfolio_user;
|
||||||
|
GRANT ALL ON ALL TABLES IN SCHEMA public TO portfolio_user;
|
||||||
|
GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO portfolio_user;
|
||||||
|
|
||||||
|
-- Set default privileges for future tables
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO portfolio_user;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO portfolio_user;
|
||||||
97
scripts/pre-push-quick.sh
Executable file
97
scripts/pre-push-quick.sh
Executable file
@@ -0,0 +1,97 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Quick Pre-Push Hook Script
|
||||||
|
# Minimal checks for quick fixes and small changes
|
||||||
|
# Use this for: styling, text changes, minor bug fixes
|
||||||
|
|
||||||
|
set -e # Exit on any error
|
||||||
|
|
||||||
|
echo "⚡ Running QUICK Pre-Push Checks..."
|
||||||
|
echo "==================================="
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Function to print colored output
|
||||||
|
print_status() {
|
||||||
|
echo -e "${BLUE}[INFO]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_success() {
|
||||||
|
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_warning() {
|
||||||
|
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_error() {
|
||||||
|
echo -e "${RED}[ERROR]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if we're in a git repository
|
||||||
|
if ! git rev-parse --git-dir > /dev/null 2>&1; then
|
||||||
|
print_error "Not in a git repository!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get current branch
|
||||||
|
CURRENT_BRANCH=$(git branch --show-current)
|
||||||
|
print_status "Current branch: $CURRENT_BRANCH"
|
||||||
|
|
||||||
|
# Check if there are uncommitted changes
|
||||||
|
if ! git diff-index --quiet HEAD --; then
|
||||||
|
print_error "You have uncommitted changes. Please commit or stash them first."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 1. Quick ESLint check (only on changed files)
|
||||||
|
print_status "Running ESLint on changed files..."
|
||||||
|
CHANGED_FILES=$(git diff --name-only --cached | grep -E '\.(ts|tsx|js|jsx)$' || true)
|
||||||
|
if [ -n "$CHANGED_FILES" ]; then
|
||||||
|
if ! npx eslint $CHANGED_FILES; then
|
||||||
|
print_error "ESLint failed on changed files! Please fix the errors."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
print_success "ESLint passed on changed files"
|
||||||
|
else
|
||||||
|
print_status "No TypeScript/JavaScript files changed, skipping ESLint"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Quick Type Check (only on changed files)
|
||||||
|
print_status "Running TypeScript type check on changed files..."
|
||||||
|
if [ -n "$CHANGED_FILES" ]; then
|
||||||
|
if ! npx tsc --noEmit --skipLibCheck; then
|
||||||
|
print_error "TypeScript type check failed!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
print_success "TypeScript type check passed"
|
||||||
|
else
|
||||||
|
print_status "No TypeScript files changed, skipping type check"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. Check for obvious syntax errors (very fast)
|
||||||
|
print_status "Checking for syntax errors..."
|
||||||
|
if ! node -c package.json 2>/dev/null; then
|
||||||
|
print_error "Package.json syntax error!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "==================================="
|
||||||
|
print_success "Quick pre-push checks passed! ⚡"
|
||||||
|
print_status "Ready to push to $CURRENT_BRANCH"
|
||||||
|
print_warning "Note: Full tests and build will run in CI/CD"
|
||||||
|
echo "==================================="
|
||||||
|
|
||||||
|
# Show what will be pushed
|
||||||
|
echo ""
|
||||||
|
print_status "Files to be pushed:"
|
||||||
|
git diff --name-only origin/$CURRENT_BRANCH..HEAD 2>/dev/null || git diff --name-only HEAD~1..HEAD
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
print_status "Proceeding with quick push..."
|
||||||
52
scripts/setup-database.js
Normal file
52
scripts/setup-database.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const { exec } = require('child_process');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
console.log('🗄️ Setting up database...');
|
||||||
|
|
||||||
|
// Set environment variables for development
|
||||||
|
process.env.DATABASE_URL = 'postgresql://portfolio_user:portfolio_dev_pass@localhost:5432/portfolio_dev?schema=public';
|
||||||
|
|
||||||
|
// Function to run command and return promise
|
||||||
|
function runCommand(command) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
console.log(`Running: ${command}`);
|
||||||
|
exec(command, (error, stdout, stderr) => {
|
||||||
|
if (error) {
|
||||||
|
console.error(`Error: ${error.message}`);
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (stderr) {
|
||||||
|
console.log(`Stderr: ${stderr}`);
|
||||||
|
}
|
||||||
|
console.log(`Output: ${stdout}`);
|
||||||
|
resolve(stdout);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupDatabase() {
|
||||||
|
try {
|
||||||
|
console.log('📦 Generating Prisma client...');
|
||||||
|
await runCommand('npx prisma generate');
|
||||||
|
|
||||||
|
console.log('🔄 Pushing database schema...');
|
||||||
|
await runCommand('npx prisma db push');
|
||||||
|
|
||||||
|
console.log('🌱 Seeding database...');
|
||||||
|
await runCommand('npx prisma db seed');
|
||||||
|
|
||||||
|
console.log('✅ Database setup complete!');
|
||||||
|
console.log('🚀 You can now run: npm run dev');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Database setup failed:', error.message);
|
||||||
|
console.log('💡 Make sure PostgreSQL is running on localhost:5432');
|
||||||
|
console.log('💡 Try: docker-compose -f docker-compose.dev.minimal.yml up -d');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setupDatabase();
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
echo "🚀 Setting up local PostgreSQL database for Portfolio..."
|
|
||||||
|
|
||||||
# Check if PostgreSQL is installed
|
|
||||||
if ! command -v psql &> /dev/null; then
|
|
||||||
echo "📦 PostgreSQL not found. Installing..."
|
|
||||||
|
|
||||||
# Detect OS and install PostgreSQL
|
|
||||||
if [[ "$OSTYPE" == "linux-gnu"* ]]; then
|
|
||||||
# Ubuntu/Debian
|
|
||||||
if command -v apt-get &> /dev/null; then
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y postgresql postgresql-contrib
|
|
||||||
# CentOS/RHEL
|
|
||||||
elif command -v yum &> /dev/null; then
|
|
||||||
sudo yum install -y postgresql postgresql-server postgresql-contrib
|
|
||||||
sudo postgresql-setup initdb
|
|
||||||
sudo systemctl enable postgresql
|
|
||||||
sudo systemctl start postgresql
|
|
||||||
# Arch Linux
|
|
||||||
elif command -v pacman &> /dev/null; then
|
|
||||||
sudo pacman -S postgresql
|
|
||||||
sudo -u postgres initdb -D /var/lib/postgres/data
|
|
||||||
sudo systemctl enable postgresql
|
|
||||||
sudo systemctl start postgresql
|
|
||||||
else
|
|
||||||
echo "❌ Unsupported Linux distribution. Please install PostgreSQL manually."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
elif [[ "$OSTYPE" == "darwin"* ]]; then
|
|
||||||
# macOS
|
|
||||||
if command -v brew &> /dev/null; then
|
|
||||||
brew install postgresql
|
|
||||||
brew services start postgresql
|
|
||||||
else
|
|
||||||
echo "❌ Homebrew not found. Please install Homebrew first: https://brew.sh/"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "❌ Unsupported OS. Please install PostgreSQL manually."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "✅ PostgreSQL already installed"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Start PostgreSQL service
|
|
||||||
if [[ "$OSTYPE" == "linux-gnu"* ]]; then
|
|
||||||
sudo systemctl start postgresql
|
|
||||||
elif [[ "$OSTYPE" == "darwin"* ]]; then
|
|
||||||
brew services start postgresql
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create database and user
|
|
||||||
echo "🔧 Setting up database..."
|
|
||||||
sudo -u postgres psql -c "CREATE DATABASE portfolio_db;" 2>/dev/null || echo "Database already exists"
|
|
||||||
sudo -u postgres psql -c "CREATE USER portfolio_user WITH PASSWORD 'portfolio_pass';" 2>/dev/null || echo "User already exists"
|
|
||||||
sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE portfolio_db TO portfolio_user;" 2>/dev/null || echo "Privileges already granted"
|
|
||||||
sudo -u postgres psql -c "ALTER USER portfolio_user WITH SUPERUSER;" 2>/dev/null || echo "Superuser already granted"
|
|
||||||
|
|
||||||
# Create .env.local file
|
|
||||||
echo "📝 Creating environment file..."
|
|
||||||
cat > .env.local << EOF
|
|
||||||
# Database Configuration
|
|
||||||
DATABASE_URL="postgresql://portfolio_user:portfolio_pass@localhost:5432/portfolio_db?schema=public"
|
|
||||||
|
|
||||||
# Next.js Configuration
|
|
||||||
NEXTAUTH_SECRET="$(openssl rand -base64 32)"
|
|
||||||
NEXTAUTH_URL="http://localhost:3000"
|
|
||||||
|
|
||||||
# Optional: Analytics
|
|
||||||
GOOGLE_ANALYTICS_ID=""
|
|
||||||
GOOGLE_TAG_MANAGER_ID=""
|
|
||||||
EOF
|
|
||||||
|
|
||||||
echo "✅ Environment file created: .env.local"
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
echo "📦 Installing dependencies..."
|
|
||||||
npm install
|
|
||||||
|
|
||||||
# Generate Prisma client
|
|
||||||
echo "🔧 Generating Prisma client..."
|
|
||||||
npx prisma generate
|
|
||||||
|
|
||||||
# Run database migrations
|
|
||||||
echo "🗄️ Running database migrations..."
|
|
||||||
npx prisma db push
|
|
||||||
|
|
||||||
# Seed database with sample data
|
|
||||||
echo "🌱 Seeding database with sample data..."
|
|
||||||
npx prisma db seed
|
|
||||||
|
|
||||||
echo "🎉 Database setup complete!"
|
|
||||||
echo ""
|
|
||||||
echo "📋 Next steps:"
|
|
||||||
echo "1. Start your development server: npm run dev"
|
|
||||||
echo "2. Visit http://localhost:3000/admin to manage projects"
|
|
||||||
echo "3. Your database is running at localhost:5432"
|
|
||||||
echo ""
|
|
||||||
echo "🔧 Database commands:"
|
|
||||||
echo "- View database: npx prisma studio"
|
|
||||||
echo "- Reset database: npx prisma db push --force-reset"
|
|
||||||
echo "- Generate client: npx prisma generate"
|
|
||||||
Reference in New Issue
Block a user