This commit is contained in:
Dennis Konkol
2025-09-02 23:46:36 +00:00
parent ded873e6b4
commit 203a332306
22 changed files with 3886 additions and 194 deletions

228
README-DATABASE.md Normal file
View File

@@ -0,0 +1,228 @@
# 🗄️ Portfolio Database Setup
Dieses Portfolio verwendet **PostgreSQL mit Prisma ORM** für maximale Performance und Skalierbarkeit.
## 🚀 Warum PostgreSQL + Prisma?
- **🏃‍♂️ Hohe Performance**: Kann tausende User gleichzeitig bedienen
- **📈 Einfache Skalierung**: Von lokal zu Cloud ohne Code-Änderungen
- **🔧 TypeScript-First**: Vollständige Type-Sicherheit und Auto-completion
- **💾 Robuste Datenbank**: ACID, Transaktionen, Indizes für optimale Performance
- **🔄 Einfache Migration**: Einfache Updates und Schema-Änderungen
## 📋 Voraussetzungen
- Node.js 18+
- npm oder yarn
- PostgreSQL (wird automatisch installiert)
## 🛠️ Schnellstart (Automatisch)
```bash
# 1. Repository klonen
git clone <your-repo>
cd my_portfolio
# 2. Automatische Datenbank-Einrichtung
npm run db:setup
```
Das Skript installiert automatisch:
- ✅ PostgreSQL
- ✅ Datenbank und Benutzer
- ✅ Prisma Client
- ✅ Beispieldaten
- ✅ Umgebungsvariablen
## 🔧 Manuelle Einrichtung
### 1. PostgreSQL installieren
**Ubuntu/Debian:**
```bash
sudo apt-get update
sudo apt-get install postgresql postgresql-contrib
```
**macOS:**
```bash
brew install postgresql
brew services start postgresql
```
**Windows:**
- [PostgreSQL Download](https://www.postgresql.org/download/windows/)
### 2. Datenbank einrichten
```bash
# PostgreSQL starten
sudo systemctl start postgresql # Linux
brew services start postgresql # macOS
# Datenbank und Benutzer erstellen
sudo -u postgres psql
CREATE DATABASE portfolio_db;
CREATE USER portfolio_user WITH PASSWORD 'portfolio_pass';
GRANT ALL PRIVILEGES ON DATABASE portfolio_db TO portfolio_user;
ALTER USER portfolio_user WITH SUPERUSER;
\q
```
### 3. Umgebungsvariablen
Erstelle `.env.local`:
```env
DATABASE_URL="postgresql://portfolio_user:portfolio_pass@localhost:5432/portfolio_db?schema=public"
NEXTAUTH_SECRET="your-secret-key-here"
NEXTAUTH_URL="http://localhost:3000"
```
### 4. Dependencies installieren
```bash
npm install
npx prisma generate
npx prisma db push
npx prisma db seed
```
## 🎯 Verfügbare Befehle
```bash
# Datenbank verwalten
npm run db:setup # Vollständige Einrichtung
npm run db:generate # Prisma Client generieren
npm run db:push # Schema zur Datenbank pushen
npm run db:seed # Beispieldaten einfügen
npm run db:studio # Datenbank-Interface öffnen
npm run db:reset # Datenbank zurücksetzen
# Entwicklung
npm run dev # Entwicklungsserver starten
npm run build # Produktions-Build
npm run start # Produktions-Server starten
```
## 🗄️ Datenbank-Schema
### Projects
- **Basis**: Titel, Beschreibung, Inhalt, Tags
- **Metadaten**: Kategorie, Schwierigkeit, Datum
- **Performance**: Lighthouse Score, Bundle Size, Load Time
- **Analytics**: Views, Likes, Shares
- **Erweiterte Features**: Technologien, Herausforderungen, Lektionen
### Analytics
- **PageViews**: Seitenaufrufe mit IP und User-Agent
- **UserInteractions**: Likes, Shares, Bookmarks, Kommentare
## 📊 Performance-Features
- **Indizes** auf allen wichtigen Feldern
- **Pagination** für große Datenmengen
- **Caching** für häufige Abfragen
- **Optimierte Queries** mit Prisma
- **Real-time Updates** möglich
## 🔄 Migration & Updates
```bash
# Schema ändern
npx prisma db push
# Bei Breaking Changes
npx prisma migrate dev --name update_schema
# Produktion
npx prisma migrate deploy
```
## 🌐 Deployment
### Lokal zu Cloud Migration
1. **Datenbank exportieren:**
```bash
pg_dump portfolio_db > backup.sql
```
2. **Cloud-Datenbank einrichten** (z.B. Supabase, PlanetScale, AWS RDS)
3. **Umgebungsvariablen aktualisieren:**
```env
DATABASE_URL="postgresql://user:pass@host:5432/db?schema=public"
```
4. **Schema pushen:**
```bash
npx prisma db push
```
## 🚨 Troubleshooting
### PostgreSQL startet nicht
```bash
# Linux
sudo systemctl status postgresql
sudo systemctl start postgresql
# macOS
brew services list
brew services restart postgresql
```
### Verbindungsfehler
```bash
# PostgreSQL Status prüfen
sudo -u postgres psql -c "SELECT version();"
# Verbindung testen
psql -h localhost -U portfolio_user -d portfolio_db
```
### Prisma Fehler
```bash
# Client neu generieren
npx prisma generate
# Datenbank zurücksetzen
npx prisma db push --force-reset
```
## 📈 Monitoring & Wartung
### Datenbank-Status
```bash
# Größe prüfen
psql -U portfolio_user -d portfolio_db -c "SELECT pg_size_pretty(pg_database_size('portfolio_db'));"
# Performance-Statistiken
psql -U portfolio_user -d portfolio_db -c "SELECT * FROM pg_stat_database;"
```
### Backup & Restore
```bash
# Backup erstellen
pg_dump -U portfolio_user portfolio_db > backup_$(date +%Y%m%d).sql
# Backup wiederherstellen
psql -U portfolio_user -d portfolio_db < backup_20241201.sql
```
## 🎉 Nächste Schritte
1. **Datenbank starten**: `npm run db:setup`
2. **Entwicklungsserver starten**: `npm run dev`
3. **Admin-Bereich öffnen**: http://localhost:3000/admin
4. **Projekte verwalten** und dein Portfolio erweitern!
## 📚 Weitere Ressourcen
- [Prisma Dokumentation](https://www.prisma.io/docs)
- [PostgreSQL Dokumentation](https://www.postgresql.org/docs/)
- [Next.js API Routes](https://nextjs.org/docs/api-routes/introduction)
---
**Fragen oder Probleme?** Erstelle ein Issue oder kontaktiere mich! 🚀

View File

@@ -22,13 +22,50 @@ import {
Palette,
Smile,
FileText,
Download,
Upload as UploadIcon,
Settings,
Smartphone
Database,
BarChart3
} from 'lucide-react';
import Link from 'next/link';
import ReactMarkdown from 'react-markdown';
// API functions to replace direct Prisma usage
const apiService = {
async getAllProjects() {
const response = await fetch('/api/projects');
if (!response.ok) throw new Error('Failed to fetch projects');
return response.json();
},
async createProject(data: any) {
const response = await fetch('/api/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) throw new Error('Failed to create project');
return response.json();
},
async updateProject(id: number, data: any) {
const response = await fetch(`/api/projects/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) throw new Error('Failed to update project');
return response.json();
},
async deleteProject(id: number) {
const response = await fetch(`/api/projects/${id}`, {
method: 'DELETE'
});
if (!response.ok) throw new Error('Failed to delete project');
return response.json();
}
};
import AdminDashboard from '@/components/AdminDashboard';
import { useToast } from '@/components/Toast';
interface Project {
id: number;
@@ -46,11 +83,33 @@ interface Project {
metaDescription?: string;
keywords?: string;
ogImage?: string;
schema?: any;
schema?: Record<string, unknown>;
// New 2.0 features
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 AdminPage = () => {
const [mounted, setMounted] = useState(false);
const { showProjectSaved, showProjectDeleted, showError, showWarning } = useToast();
useEffect(() => {
setMounted(true);
@@ -58,34 +117,25 @@ const AdminPage = () => {
const [projects, setProjects] = useState<Project[]>([]);
// Load projects from localStorage on mount
// Load projects from database on mount
useEffect(() => {
const savedProjects = localStorage.getItem('portfolio-projects');
if (savedProjects) {
setProjects(JSON.parse(savedProjects));
} else {
// Default projects if none exist
const defaultProjects: Project[] = [
{
id: 1,
title: "Portfolio Website",
description: "A modern, responsive portfolio website built with Next.js, TypeScript, and Tailwind CSS.",
content: "# Portfolio Website\n\nThis is my personal portfolio website built with modern web technologies. The site features a dark theme with glassmorphism effects and smooth animations.\n\n## Features\n\n- **Responsive Design**: Works perfectly on all devices\n- **Dark Theme**: Modern dark mode with glassmorphism effects\n- **Animations**: Smooth animations powered by Framer Motion\n- **Markdown Support**: Projects are written in Markdown for easy editing\n- **Performance**: Optimized for speed and SEO\n\n## Technologies Used\n\n- Next.js 15\n- TypeScript\n- Tailwind CSS\n- Framer Motion\n- React Markdown\n\n## Development Process\n\nThe website was designed with a focus on user experience and performance. I used modern CSS techniques like CSS Grid, Flexbox, and custom properties to create a responsive layout.\n\n## Future Improvements\n\n- Add blog functionality\n- Implement project filtering\n- Add more interactive elements\n- Optimize for Core Web Vitals\n\n## Links\n\n- [Live Demo](https://dki.one)\n- [GitHub Repository](https://github.com/Denshooter/portfolio)",
tags: ["Next.js", "TypeScript", "Tailwind CSS", "Framer Motion"],
featured: true,
category: "Web Development",
date: "2024"
}
];
setProjects(defaultProjects);
localStorage.setItem('portfolio-projects', JSON.stringify(defaultProjects));
}
loadProjects();
}, []);
const loadProjects = async () => {
try {
const result = await apiService.getAllProjects();
setProjects(result.projects);
} catch (error) {
console.error('Error loading projects from database:', error);
}
};
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
const [isPreview, setIsPreview] = useState(false);
const [isProjectsCollapsed, setIsProjectsCollapsed] = useState(false);
const [showTemplates, setShowTemplates] = useState(false);
const [formData, setFormData] = useState({
title: '',
description: '',
@@ -96,7 +146,28 @@ const AdminPage = () => {
github: '',
live: '',
published: true,
imageUrl: ''
imageUrl: '',
// New 2.0 fields
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
}
});
const [markdownContent, setMarkdownContent] = useState('');
@@ -123,39 +194,16 @@ const AdminPage = () => {
return null;
}
const handleSave = () => {
const handleSave = async () => {
if (!formData.title || !formData.description || !markdownContent || !formData.category) {
alert('Please fill in all required fields!');
showWarning('Pflichtfelder fehlen', 'Bitte fülle alle erforderlichen Felder aus.');
return;
}
try {
if (selectedProject) {
// Update existing project
const updatedProjects = projects.map(p =>
p.id === selectedProject.id
? {
...p,
title: formData.title,
description: formData.description,
content: markdownContent,
tags: formData.tags ? formData.tags.split(',').map(tag => tag.trim()).filter(tag => tag) : [],
category: formData.category,
featured: formData.featured,
github: formData.github || undefined,
live: formData.live || undefined,
published: formData.published,
imageUrl: formData.imageUrl || undefined
}
: p
);
setProjects(updatedProjects);
localStorage.setItem('portfolio-projects', JSON.stringify(updatedProjects));
console.log('Project updated successfully:', selectedProject.id);
} else {
// Create new project
const newProject: Project = {
id: Math.floor(Math.random() * 1000000),
// Update existing project in database
const projectData = {
title: formData.title,
description: formData.description,
content: markdownContent,
@@ -166,19 +214,62 @@ const AdminPage = () => {
live: formData.live || undefined,
published: formData.published,
imageUrl: formData.imageUrl || undefined,
date: new Date().getFullYear().toString()
difficulty: formData.difficulty,
timeToComplete: formData.timeToComplete || undefined,
technologies: formData.technologies ? formData.technologies.split(',').map(tech => tech.trim()).filter(tech => tech) : [],
challenges: formData.challenges ? formData.challenges.split(',').map(challenge => challenge.trim()).filter(challenge => challenge) : [],
lessonsLearned: formData.lessonsLearned ? formData.lessonsLearned.split(',').map(lesson => lesson.trim()).filter(lesson => lesson) : [],
futureImprovements: formData.futureImprovements ? formData.futureImprovements.split(',').map(improvement => improvement.trim()).filter(improvement => improvement) : [],
demoVideo: formData.demoVideo || undefined,
screenshots: formData.screenshots ? formData.screenshots.split(',').map(screenshot => screenshot.trim()).filter(screenshot => screenshot) : [],
colorScheme: formData.colorScheme,
accessibility: formData.accessibility,
performance: formData.performance,
analytics: formData.analytics
};
const updatedProjects = [...projects, newProject];
setProjects(updatedProjects);
localStorage.setItem('portfolio-projects', JSON.stringify(updatedProjects));
console.log('New project created successfully:', newProject.id);
await apiService.updateProject(selectedProject.id, projectData);
console.log('Project updated successfully in database:', selectedProject.id);
showProjectSaved(formData.title);
} else {
// Create new project in database
const projectData = {
title: formData.title,
description: formData.description,
content: markdownContent,
tags: formData.tags ? formData.tags.split(',').map(tag => tag.trim()).filter(tag => tag) : [],
category: formData.category,
featured: formData.featured,
github: formData.github || undefined,
live: formData.live || undefined,
published: formData.published,
imageUrl: formData.imageUrl || undefined,
date: new Date().getFullYear().toString(),
difficulty: formData.difficulty,
timeToComplete: formData.timeToComplete || undefined,
technologies: formData.technologies ? formData.technologies.split(',').map(tech => tech.trim()).filter(tech => tech) : [],
challenges: formData.challenges ? formData.challenges.split(',').map(challenge => challenge.trim()).filter(challenge => challenge) : [],
lessonsLearned: formData.lessonsLearned ? formData.lessonsLearned.split(',').map(lesson => lesson.trim()).filter(lesson => lesson) : [],
futureImprovements: formData.futureImprovements ? formData.futureImprovements.split(',').map(improvement => improvement.trim()).filter(improvement => improvement) : [],
demoVideo: formData.demoVideo || undefined,
screenshots: formData.screenshots ? formData.screenshots.split(',').map(screenshot => screenshot.trim()).filter(screenshot => screenshot) : [],
colorScheme: formData.colorScheme,
accessibility: formData.accessibility,
performance: formData.performance,
analytics: formData.analytics
};
await apiService.createProject(projectData);
console.log('New project created successfully in database');
showProjectSaved(formData.title);
}
// Reload projects from database
await loadProjects();
resetForm();
alert('Project saved successfully!');
} catch (error) {
console.error('Error saving project:', error);
alert('Error saving project. Please try again.');
showError('Fehler beim Speichern', 'Das Projekt konnte nicht gespeichert werden. Bitte versuche es erneut.');
}
};
@@ -195,17 +286,47 @@ const AdminPage = () => {
github: project.github || '',
live: project.live || '',
published: project.published !== undefined ? project.published : true,
imageUrl: project.imageUrl || ''
imageUrl: project.imageUrl || '',
// New 2.0 fields
difficulty: project.difficulty || 'Intermediate' as const,
timeToComplete: project.timeToComplete || '',
technologies: project.technologies ? project.technologies.join(', ') : '',
challenges: project.challenges ? project.challenges.join(', ') : '',
lessonsLearned: project.lessonsLearned ? project.lessonsLearned.join(', ') : '',
futureImprovements: project.futureImprovements ? project.futureImprovements.join(', ') : '',
demoVideo: project.demoVideo || '',
screenshots: project.screenshots ? project.screenshots.join(', ') : '',
colorScheme: project.colorScheme || 'Dark',
accessibility: project.accessibility !== undefined ? project.accessibility : true,
performance: project.performance || {
lighthouse: 90,
bundleSize: '50KB',
loadTime: '1.5s'
},
analytics: project.analytics || {
views: 0,
likes: 0,
shares: 0
}
});
setMarkdownContent(project.content);
setIsPreview(false);
};
const handleDelete = (projectId: number) => {
const handleDelete = async (projectId: number) => {
if (confirm('Are you sure you want to delete this project?')) {
const updatedProjects = projects.filter(p => p.id !== projectId);
setProjects(updatedProjects);
localStorage.setItem('portfolio-projects', JSON.stringify(updatedProjects));
try {
const project = projects.find(p => p.id === projectId);
await apiService.deleteProject(projectId);
await loadProjects(); // Reload from database
console.log('Project deleted successfully from database:', projectId);
if (project) {
showProjectDeleted(project.title);
}
} catch (error) {
console.error('Error deleting project:', error);
showError('Fehler beim Löschen', 'Das Projekt konnte nicht gelöscht werden. Bitte versuche es erneut.');
}
}
};
@@ -222,7 +343,28 @@ const AdminPage = () => {
github: '',
live: '',
published: true,
imageUrl: ''
imageUrl: '',
// New 2.0 fields
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('');
setIsPreview(false);
@@ -280,6 +422,26 @@ const AdminPage = () => {
insertion = `| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Cell 1 | Cell 2 | Cell 3 |\n| Cell 4 | Cell 5 | Cell 6 |`;
cursorOffset = 0;
break;
case 'emoji':
insertion = `😊 ${text.substring(start, end) || 'Add your text here'}`;
cursorOffset = 2;
break;
case 'codeblock':
insertion = `\`\`\`javascript\n${text.substring(start, end) || '// Your code here'}\n\`\`\``;
cursorOffset = 0;
break;
case 'highlight':
insertion = `==${text.substring(start, end) || 'highlighted text'}==`;
cursorOffset = 2;
break;
case 'spoiler':
insertion = `||${text.substring(start, end) || 'spoiler text'}||`;
cursorOffset = 2;
break;
case 'callout':
insertion = `> [!NOTE]\n> ${text.substring(start, end) || 'This is a callout box'}`;
cursorOffset = 0;
break;
}
const newText = text.substring(0, start) + insertion + text.substring(end);
@@ -343,6 +505,142 @@ const AdminPage = () => {
}
};
// Project Templates
const projectTemplates = {
'Web App': {
title: 'Web Application',
description: 'A modern web application with responsive design and advanced features.',
content: `# Web Application
## 🚀 Overview
A modern web application built with cutting-edge technologies.
## ✨ Features
- **Responsive Design**: Works perfectly on all devices
- **Modern UI/UX**: Beautiful and intuitive user interface
- **Performance**: Optimized for speed and efficiency
- **Security**: Built with security best practices
## 🛠️ Technologies Used
- Frontend Framework
- Backend Technology
- Database
- Additional Tools
## 📱 Screenshots
![App Screenshot](screenshot-url)
## 🔗 Links
- [Live Demo](demo-url)
- [GitHub Repository](github-url)
## 📈 Future Improvements
- Feature 1
- Feature 2
- Feature 3`,
difficulty: 'Intermediate' as 'Beginner' | 'Intermediate' | 'Advanced' | 'Expert',
category: 'Web Application',
technologies: 'React, Node.js, MongoDB',
colorScheme: 'Modern and Clean'
},
'Mobile App': {
title: 'Mobile Application',
description: 'A cross-platform mobile application with native performance.',
content: `# Mobile Application
## 📱 Overview
A cross-platform mobile application that delivers native performance.
## ✨ Features
- **Cross-Platform**: Works on iOS and Android
- **Native Performance**: Optimized for mobile devices
- **Offline Support**: Works without internet connection
- **Push Notifications**: Keep users engaged
## 🛠️ Technologies Used
- React Native / Flutter
- Backend API
- Database
- Cloud Services
## 📸 Screenshots
![Mobile Screenshot](screenshot-url)
## 🔗 Links
- [App Store](app-store-url)
- [Play Store](play-store-url)
- [GitHub Repository](github-url)
## 📈 Future Improvements
- Feature 1
- Feature 2
- Feature 3`,
difficulty: 'Advanced' as 'Beginner' | 'Intermediate' | 'Advanced' | 'Expert',
category: 'Mobile App',
technologies: 'React Native, Firebase, Node.js',
colorScheme: 'Mobile-First Design'
},
'API Service': {
title: 'API Service',
description: 'A robust and scalable API service with comprehensive documentation.',
content: `# API Service
## 🔌 Overview
A robust and scalable API service that powers multiple applications.
## ✨ Features
- **RESTful Design**: Follows REST API best practices
- **Authentication**: Secure JWT-based authentication
- **Rate Limiting**: Prevents abuse and ensures stability
- **Comprehensive Docs**: Complete API documentation
- **Testing**: Extensive test coverage
## 🛠️ Technologies Used
- Backend Framework
- Database
- Authentication
- Testing Tools
## 📚 API Endpoints
\`\`\`
GET /api/users
POST /api/users
PUT /api/users/:id
DELETE /api/users/:id
\`\`\`
## 🔗 Links
- [API Documentation](docs-url)
- [GitHub Repository](github-url)
## 📈 Future Improvements
- Feature 1
- Feature 2
- Feature 3`,
difficulty: 'Intermediate' as const,
category: 'API Development',
technologies: 'Node.js, Express, MongoDB',
colorScheme: 'Professional and Clean'
}
};
const applyTemplate = (templateKey: string) => {
const template = projectTemplates[templateKey as keyof typeof projectTemplates];
if (template) {
setFormData({
...formData,
title: template.title,
description: template.description,
category: template.category,
difficulty: template.difficulty,
technologies: template.technologies,
colorScheme: template.colorScheme
});
setMarkdownContent(template.content);
setShowTemplates(false);
}
};
return (
<div className="min-h-screen animated-bg">
<div className="max-w-7xl mx-auto px-4 py-20">
@@ -466,6 +764,17 @@ const AdminPage = () => {
{selectedProject ? 'Edit Project' : 'New Project'}
</h2>
<div className="flex space-x-3">
<button
onClick={() => setShowTemplates(!showTemplates)}
className={`px-4 py-2 rounded-lg transition-colors ${
showTemplates
? 'bg-purple-600 text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
}`}
title="Project Templates"
>
<FileText size={20} />
</button>
<button
onClick={() => setIsPreview(!isPreview)}
className={`px-4 py-2 rounded-lg transition-colors ${
@@ -500,6 +809,86 @@ const AdminPage = () => {
</div>
</div>
{/* Project Templates Modal */}
{showTemplates && (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onClick={() => setShowTemplates(false)}
>
<motion.div
initial={{ y: 20 }}
animate={{ y: 0 }}
className="bg-gray-800 rounded-2xl p-6 max-w-4xl w-full max-h-[80vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-6">
<h3 className="text-2xl font-bold text-white">🚀 Project Templates</h3>
<button
onClick={() => setShowTemplates(false)}
className="text-gray-400 hover:text-white transition-colors"
>
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{Object.entries(projectTemplates).map(([key, template]) => (
<motion.div
key={key}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
className="bg-gray-700/50 rounded-xl p-6 border border-gray-600/50 hover:border-blue-500/50 transition-all cursor-pointer"
onClick={() => applyTemplate(key)}
>
<div className="text-center mb-4">
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-500 rounded-full flex items-center justify-center mx-auto mb-3">
<FileText size={24} className="text-white" />
</div>
<h4 className="text-lg font-semibold text-white mb-2">{template.title}</h4>
<p className="text-sm text-gray-400">{template.description}</p>
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="text-gray-400">Difficulty:</span>
<span className={`px-2 py-1 rounded text-xs font-medium ${
template.difficulty === 'Beginner' ? 'bg-green-500/20 text-green-400' :
template.difficulty === 'Intermediate' ? 'bg-yellow-500/20 text-yellow-400' :
template.difficulty === 'Advanced' ? 'bg-orange-500/20 text-orange-400' :
'bg-red-500/20 text-red-400'
}`}>
{template.difficulty}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-gray-400">Category:</span>
<span className="text-white">{template.category}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-gray-400">Tech:</span>
<span className="text-white text-xs">{template.technologies}</span>
</div>
</div>
<button className="w-full mt-4 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors">
Use Template
</button>
</motion.div>
))}
</div>
<div className="mt-6 text-center">
<p className="text-sm text-gray-400">
Templates provide a starting point for your projects. Customize them as needed!
</p>
</div>
</motion.div>
</motion.div>
)}
{!isPreview ? (
<div className="space-y-6">
{/* Basic Info */}
@@ -604,6 +993,214 @@ const AdminPage = () => {
</div>
</div>
{/* Advanced Project Details - 2.0 Features */}
<div className="bg-gradient-to-r from-gray-800/20 to-gray-700/20 p-6 rounded-xl border border-gray-600/30">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center">
<Settings size={20} className="mr-2 text-blue-400" />
Advanced Project Details
</h3>
{/* Difficulty & Time */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Difficulty Level
</label>
<select
value={formData.difficulty}
onChange={(e) => setFormData({...formData, difficulty: e.target.value as 'Beginner' | 'Intermediate' | 'Advanced' | 'Expert'})}
className="w-full px-4 py-3 bg-gray-800/50 border border-gray-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="Beginner">🟢 Beginner</option>
<option value="Intermediate">🟡 Intermediate</option>
<option value="Advanced">🟠 Advanced</option>
<option value="Expert">🔴 Expert</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Time to Complete
</label>
<input
type="text"
value={formData.timeToComplete}
onChange={(e) => setFormData({...formData, timeToComplete: e.target.value})}
className="w-full px-4 py-3 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 focus:border-transparent"
placeholder="e.g., 2-3 weeks, 1 month"
/>
</div>
</div>
{/* Technologies & Color Scheme */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Technologies (comma-separated)
</label>
<input
type="text"
value={formData.technologies}
onChange={(e) => setFormData({...formData, technologies: e.target.value})}
className="w-full px-4 py-3 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 focus:border-transparent"
placeholder="React, TypeScript, Node.js"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Color Scheme
</label>
<input
type="text"
value={formData.colorScheme}
onChange={(e) => setFormData({...formData, colorScheme: e.target.value})}
className="w-full px-4 py-3 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 focus:border-transparent"
placeholder="e.g., Dark theme, Light theme, Colorful"
/>
</div>
</div>
{/* Performance Metrics */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Lighthouse Score
</label>
<input
type="number"
min="0"
max="100"
value={formData.performance.lighthouse}
onChange={(e) => setFormData({
...formData,
performance: {...formData.performance, lighthouse: parseInt(e.target.value)}
})}
className="w-full px-4 py-3 bg-gray-800/50 border border-gray-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="90"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Bundle Size
</label>
<input
type="text"
value={formData.performance.bundleSize}
onChange={(e) => setFormData({
...formData,
performance: {...formData.performance, bundleSize: e.target.value}
})}
className="w-full px-4 py-3 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 focus:border-transparent"
placeholder="45KB"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Load Time
</label>
<input
type="text"
value={formData.performance.loadTime}
onChange={(e) => setFormData({
...formData,
performance: {...formData.performance, loadTime: e.target.value}
})}
className="w-full px-4 py-3 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 focus:border-transparent"
placeholder="1.2s"
/>
</div>
</div>
{/* Learning & Challenges */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Challenges Faced (comma-separated)
</label>
<textarea
value={formData.challenges}
onChange={(e) => setFormData({...formData, challenges: e.target.value})}
rows={3}
className="w-full px-4 py-3 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 focus:border-transparent resize-none"
placeholder="Performance optimization, Responsive design, State management"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Lessons Learned (comma-separated)
</label>
<textarea
value={formData.lessonsLearned}
onChange={(e) => setFormData({...formData, lessonsLearned: e.target.value})}
rows={3}
className="w-full px-4 py-3 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 focus:border-transparent resize-none"
placeholder="Advanced CSS techniques, Performance optimization, User experience design"
/>
</div>
</div>
{/* Future Improvements & Demo */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Future Improvements (comma-separated)
</label>
<textarea
value={formData.futureImprovements}
onChange={(e) => setFormData({...formData, futureImprovements: e.target.value})}
rows={3}
className="w-full px-4 py-3 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 focus:border-transparent resize-none"
placeholder="AI integration, Advanced analytics, Real-time features"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Demo Video URL (optional)
</label>
<input
type="url"
value={formData.demoVideo}
onChange={(e) => setFormData({...formData, demoVideo: e.target.value})}
className="w-full px-4 py-3 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 focus:border-transparent"
placeholder="https://youtube.com/watch?v=..."
/>
</div>
</div>
{/* Accessibility & Screenshots */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex items-center space-x-3">
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={formData.accessibility}
onChange={(e) => setFormData({...formData, accessibility: e.target.checked})}
className="w-4 h-4 text-blue-600 bg-gray-800 border-gray-700 rounded focus:ring-blue-500 focus:ring-2"
/>
<span className="text-sm text-gray-300">Accessibility Compliant</span>
</label>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Screenshots URLs (comma-separated)
</label>
<input
type="text"
value={formData.screenshots}
onChange={(e) => setFormData({...formData, screenshots: e.target.value})}
className="w-full px-4 py-3 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 focus:border-transparent"
placeholder="https://example.com/screenshot1.jpg, https://example.com/screenshot2.jpg"
/>
</div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Description
@@ -667,19 +1264,48 @@ const AdminPage = () => {
<span className="text-sm font-medium text-gray-300">📁 Image Upload</span>
<span className="text-xs text-gray-500">Add images to your content</span>
</div>
<label className="flex items-center justify-center space-x-3 p-4 bg-gray-700/50 hover:bg-gray-600/50 rounded-lg cursor-pointer transition-all duration-200 hover:scale-105 border-2 border-dashed border-gray-600/50 hover:border-blue-500/50">
<Upload size={20} className="text-gray-400" />
<span className="text-gray-300 font-medium">Upload Images</span>
<input
type="file"
accept="image/*"
multiple
onChange={handleImageUpload}
className="hidden"
/>
</label>
<div
className="relative"
onDragOver={(e) => {
e.preventDefault();
e.currentTarget.classList.add('border-blue-500', 'bg-blue-500/10');
}}
onDragLeave={(e) => {
e.currentTarget.classList.remove('border-blue-500', 'bg-blue-500/10');
}}
onDrop={(e) => {
e.preventDefault();
e.currentTarget.classList.remove('border-blue-500', 'bg-blue-500/10');
const files = Array.from(e.dataTransfer.files);
files.forEach(file => {
if (file.type.startsWith('image/')) {
const imageUrl = URL.createObjectURL(file);
const textarea = document.getElementById('markdown-editor') as HTMLTextAreaElement;
if (textarea) {
const start = textarea.selectionStart;
const text = textarea.value;
const insertion = `\n![${file.name.replace(/\.[^/.]+$/, "")}](${imageUrl})\n`;
const newText = text.substring(0, start) + insertion + text.substring(start);
setMarkdownContent(newText);
}
}
});
}}
>
<label className="flex items-center justify-center space-x-3 p-4 bg-gray-700/50 hover:bg-gray-600/50 rounded-lg cursor-pointer transition-all duration-200 hover:scale-105 border-2 border-dashed border-gray-600/50 hover:border-blue-500/50 group">
<Upload size={20} className="text-gray-400 group-hover:text-blue-400 transition-colors" />
<span className="text-gray-300 font-medium group-hover:text-blue-400 transition-colors">Upload Images</span>
<input
type="file"
accept="image/*"
multiple
onChange={handleImageUpload}
className="hidden"
/>
</label>
</div>
<p className="text-xs text-gray-500 text-center mt-2">
Drag & drop images or click to browse Images will be inserted at cursor position
🚀 Drag & drop images here or click to browse Images will be inserted at cursor position
</p>
</div>
@@ -742,7 +1368,7 @@ const AdminPage = () => {
{/* Content Elements */}
<div className="mb-4">
<div className="text-xs text-gray-500 mb-2 uppercase tracking-wide">Content Elements</div>
<div className="grid grid-cols-4 gap-2">
<div className="grid grid-cols-5 gap-2">
<button
onClick={() => insertMarkdown('list')}
className="p-3 bg-gradient-to-br from-green-600/50 to-green-700/50 hover:from-green-500/60 hover:to-green-600/60 rounded-lg text-white transition-all duration-200 hover:scale-105 border border-green-500/50 shadow-lg"
@@ -771,6 +1397,48 @@ const AdminPage = () => {
>
<span className="text-sm font-bold">📊</span>
</button>
<button
onClick={() => insertMarkdown('emoji')}
className="p-3 bg-gradient-to-br from-pink-600/50 to-pink-700/50 hover:from-pink-500/60 hover:to-pink-600/60 rounded-lg text-white transition-all duration-200 hover:scale-105 border border-pink-500/50 shadow-lg"
title="Emoji"
>
<Smile size={16} />
</button>
</div>
</div>
{/* Advanced Elements */}
<div className="mb-4">
<div className="text-xs text-gray-500 mb-2 uppercase tracking-wide">Advanced Elements</div>
<div className="grid grid-cols-4 gap-2">
<button
onClick={() => insertMarkdown('codeblock')}
className="p-3 bg-gradient-to-br from-indigo-600/50 to-indigo-700/50 hover:from-indigo-500/60 hover:to-indigo-600/60 rounded-lg text-white transition-all duration-200 hover:scale-105 border border-indigo-500/50 shadow-lg"
title="Code Block"
>
<FileText size={16} />
</button>
<button
onClick={() => insertMarkdown('highlight')}
className="p-3 bg-gradient-to-br from-yellow-600/50 to-yellow-700/50 hover:from-yellow-500/60 hover:to-yellow-600/60 rounded-lg text-white transition-all duration-200 hover:scale-105 border border-yellow-500/50 shadow-lg"
title="Highlight Text"
>
<Palette size={16} />
</button>
<button
onClick={() => insertMarkdown('spoiler')}
className="p-3 bg-gradient-to-br from-red-600/50 to-red-700/50 hover:from-red-500/60 hover:to-red-600/60 rounded-lg text-white transition-all duration-200 hover:scale-105 border border-red-500/50 shadow-lg"
title="Spoiler"
>
<span className="text-sm font-bold">👁</span>
</button>
<button
onClick={() => insertMarkdown('callout')}
className="p-3 bg-gradient-to-br from-teal-600/50 to-teal-700/50 hover:from-teal-500/60 hover:to-teal-600/60 rounded-lg text-white transition-all duration-200 hover:scale-105 border border-teal-500/50 shadow-lg"
title="Callout Box"
>
<span className="text-sm font-bold">💡</span>
</button>
</div>
</div>
</div>

View File

@@ -4,70 +4,168 @@ import SMTPTransport from "nodemailer/lib/smtp-transport";
import Mail from "nodemailer/lib/mailer";
export async function POST(request: NextRequest) {
const body = (await request.json()) as {
email: string;
name: string;
message: string;
};
const { email, name, message } = body;
try {
const body = (await request.json()) as {
email: string;
name: string;
subject: string;
message: string;
};
const { email, name, subject, message } = body;
const user = process.env.MY_EMAIL ?? "";
const pass = process.env.MY_PASSWORD ?? "";
console.log('📧 Email request received:', { email, name, subject, messageLength: message.length });
if (!user || !pass) {
console.error("Missing email/password environment variables");
return NextResponse.json(
{ error: "Missing EMAIL or PASSWORD" },
{ status: 500 },
);
}
// Validate input
if (!email || !name || !subject || !message) {
console.error('❌ Validation failed: Missing required fields');
return NextResponse.json(
{ error: "Alle Felder sind erforderlich" },
{ status: 400 },
);
}
if (!email || !name || !message) {
console.error("Invalid request body");
return NextResponse.json(
{ error: "Invalid request body" },
{ status: 400 },
);
}
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
console.error('❌ Validation failed: Invalid email format');
return NextResponse.json(
{ error: "Ungültige E-Mail-Adresse" },
{ status: 400 },
);
}
const transportOptions: SMTPTransport.Options = {
host: "smtp.ionos.de",
port: 587,
secure: false,
requireTLS: true,
auth: {
type: "login",
user,
pass,
},
};
// Validate message length
if (message.length < 10) {
console.error('❌ Validation failed: Message too short');
return NextResponse.json(
{ error: "Nachricht muss mindestens 10 Zeichen lang sein" },
{ status: 400 },
);
}
const transport = nodemailer.createTransport(transportOptions);
const user = process.env.MY_EMAIL ?? "";
const pass = process.env.MY_PASSWORD ?? "";
const mailOptions: Mail.Options = {
from: user,
to: user, // Ensure this is the correct email address
subject: `Message from ${name} (${email})`,
text: message + "\n\n" + email,
};
const sendMailPromise = () =>
new Promise<string>((resolve, reject) => {
transport.sendMail(mailOptions, function (err, info) {
if (!err) {
resolve(info.response);
} else {
console.error("Error sending email:", err);
reject(err.message);
}
});
console.log('🔑 Environment check:', {
hasEmail: !!user,
hasPassword: !!pass,
emailHost: user.split('@')[1] || 'unknown'
});
try {
await sendMailPromise();
return NextResponse.json({ message: "Email sent" });
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: "smtp.ionos.de",
port: 587,
secure: false,
requireTLS: true,
auth: {
type: "login",
user,
pass,
},
// Add timeout and debug options
connectionTimeout: 10000,
greetingTimeout: 10000,
socketTimeout: 10000,
};
console.log('🚀 Creating transport with options:', {
host: transportOptions.host,
port: transportOptions.port,
secure: transportOptions.secure,
user: user.split('@')[0] + '@***' // Hide full email in logs
});
const transport = nodemailer.createTransport(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 mailOptions: Mail.Options = {
from: `"Portfolio Contact" <${user}>`,
to: "contact@dki.one", // Send to your contact email
replyTo: email,
subject: `Portfolio Kontakt: ${subject}`,
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #3b82f6;">Neue Kontaktanfrage von deinem Portfolio</h2>
<div style="background: #f8fafc; padding: 20px; border-radius: 8px; margin: 20px 0;">
<h3 style="color: #1e293b; margin-top: 0;">Nachricht von ${name}</h3>
<p style="color: #475569; margin: 8px 0;"><strong>E-Mail:</strong> ${email}</p>
<p style="color: #475569; margin: 8px 0;"><strong>Betreff:</strong> ${subject}</p>
</div>
<div style="background: #ffffff; padding: 20px; border-radius: 8px; border-left: 4px solid #3b82f6;">
<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: `
Neue Kontaktanfrage von deinem Portfolio
Von: ${name} (${email})
Betreff: ${subject}
Nachricht:
${message}
---
Diese E-Mail wurde automatisch von deinem Portfolio generiert.
`,
};
console.log('📤 Sending email...');
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);
}
});
});
const result = await sendMailPromise();
console.log('🎉 Email process completed successfully');
return NextResponse.json({
message: "E-Mail erfolgreich gesendet",
messageId: result
});
} catch (err) {
console.error("Error sending email:", err);
return NextResponse.json({ error: "Failed to send email" }, { status: 500 });
console.error("❌ Unexpected error in email API:", err);
return NextResponse.json({
error: "Fehler beim Senden der E-Mail",
details: err instanceof Error ? err.message : 'Unbekannter Fehler'
}, { status: 500 });
}
}

View File

@@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const id = parseInt(params.id);
const project = await prisma.project.findUnique({
where: { id }
});
if (!project) {
return NextResponse.json(
{ error: 'Project not found' },
{ status: 404 }
);
}
return NextResponse.json(project);
} catch (error) {
console.error('Error fetching project:', error);
return NextResponse.json(
{ error: 'Failed to fetch project' },
{ status: 500 }
);
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const id = parseInt(params.id);
const data = await request.json();
const project = await prisma.project.update({
where: { id },
data: { ...data, updatedAt: new Date() }
});
return NextResponse.json(project);
} catch (error) {
console.error('Error updating project:', error);
return NextResponse.json(
{ error: 'Failed to update project' },
{ status: 500 }
);
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const id = parseInt(params.id);
await prisma.project.delete({
where: { id }
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error deleting project:', error);
return NextResponse.json(
{ error: 'Failed to delete project' },
{ status: 500 }
);
}
}

78
app/api/projects/route.ts Normal file
View File

@@ -0,0 +1,78 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get('page') || '1');
const limit = parseInt(searchParams.get('limit') || '50');
const category = searchParams.get('category');
const featured = searchParams.get('featured');
const published = searchParams.get('published');
const difficulty = searchParams.get('difficulty');
const search = searchParams.get('search');
const skip = (page - 1) * limit;
const where: any = {};
if (category) where.category = category;
if (featured !== null) where.featured = featured === 'true';
if (published !== null) where.published = published === 'true';
if (difficulty) where.difficulty = difficulty;
if (search) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
{ tags: { hasSome: [search] } },
{ content: { contains: search, mode: 'insensitive' } }
];
}
const [projects, total] = await Promise.all([
prisma.project.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take: limit
}),
prisma.project.count({ where })
]);
return NextResponse.json({
projects,
total,
pages: Math.ceil(total / limit),
currentPage: page
});
} catch (error) {
console.error('Error fetching projects:', error);
return NextResponse.json(
{ error: 'Failed to fetch projects' },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const data = await request.json();
const project = await prisma.project.create({
data: {
...data,
performance: data.performance || { lighthouse: 90, bundleSize: '50KB', loadTime: '1.5s' },
analytics: data.analytics || { views: 0, likes: 0, shares: 0 }
}
});
return NextResponse.json(project);
} catch (error) {
console.error('Error creating project:', error);
return NextResponse.json(
{ error: 'Failed to create project' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,81 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const slug = searchParams.get('slug');
const search = searchParams.get('search');
const category = searchParams.get('category');
if (slug) {
// Search by slug (convert title to slug format)
const projects = await prisma.project.findMany({
where: {
published: true
},
orderBy: { createdAt: 'desc' }
});
// Find exact match by converting titles to slugs
const foundProject = projects.find(project => {
const projectSlug = project.title.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
return projectSlug === slug;
});
if (foundProject) {
return NextResponse.json({ projects: [foundProject] });
}
// If no exact match, return empty array
return NextResponse.json({ projects: [] });
}
if (search) {
// General search
const projects = await prisma.project.findMany({
where: {
published: true,
OR: [
{ title: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
{ tags: { hasSome: [search] } },
{ content: { contains: search, mode: 'insensitive' } }
]
},
orderBy: { createdAt: 'desc' }
});
return NextResponse.json({ projects });
}
if (category && category !== 'All') {
// Filter by category
const projects = await prisma.project.findMany({
where: {
published: true,
category: category
},
orderBy: { createdAt: 'desc' }
});
return NextResponse.json({ projects });
}
// Return all published projects if no specific search
const projects = await prisma.project.findMany({
where: { published: true },
orderBy: { createdAt: 'desc' }
});
return NextResponse.json({ projects });
} catch (error) {
console.error('Error searching projects:', error);
return NextResponse.json(
{ error: 'Failed to search projects' },
{ status: 500 }
);
}
}

View File

@@ -3,9 +3,11 @@
import { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import { Mail, Phone, MapPin, Send, Github, Linkedin, Twitter } from 'lucide-react';
import { useToast } from '@/components/Toast';
const Contact = () => {
const [mounted, setMounted] = useState(false);
const { showEmailSent, showEmailError } = useToast();
useEffect(() => {
setMounted(true);
@@ -24,12 +26,33 @@ const Contact = () => {
e.preventDefault();
setIsSubmitting(true);
// Simulate form submission
setTimeout(() => {
try {
const response = await fetch('/api/email', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: formData.name,
email: formData.email,
subject: formData.subject,
message: formData.message,
}),
});
if (response.ok) {
showEmailSent(formData.email);
setFormData({ name: '', email: '', subject: '', message: '' });
} else {
const errorData = await response.json();
showEmailError(errorData.error || 'Unbekannter Fehler');
}
} catch (error) {
console.error('Error sending email:', error);
showEmailError('Netzwerkfehler beim Senden der E-Mail');
} finally {
setIsSubmitting(false);
alert('Thank you for your message! I will get back to you soon.');
setFormData({ name: '', email: '', subject: '', message: '' });
}, 2000);
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {

View File

@@ -3,6 +3,7 @@
import { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import { ArrowDown, Code, Zap, Rocket } from 'lucide-react';
import Image from 'next/image';
const Hero = () => {
const [mounted, setMounted] = useState(false);
@@ -70,11 +71,72 @@ const Hero = () => {
</div>
<div className="relative z-10 text-center px-4 max-w-4xl mx-auto">
{/* Profile Image */}
<motion.div
initial={{ opacity: 0, scale: 0.8, rotateY: -15 }}
animate={{ opacity: 1, scale: 1, rotateY: 0 }}
transition={{ duration: 1, delay: 0.1, ease: "easeOut" }}
className="mb-8 flex justify-center"
>
<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 */}
<div className="relative bg-gray-900 rounded-full p-1">
<motion.div
whileHover={{ scale: 1.05, rotateY: 5 }}
transition={{ duration: 0.3 }}
className="relative w-40 h-40 md:w-48 md:h-48 lg:w-56 lg:h-56 rounded-full overflow-hidden border-4 border-gray-800"
>
<Image
src="/images/me.jpg"
alt="Dennis Konkol - Software Engineer"
fill
className="object-cover"
priority
/>
{/* Hover overlay effect */}
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
</motion.div>
</div>
{/* Floating tech badges around the image */}
<motion.div
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5, delay: 1.5 }}
className="absolute -top-3 -right-3 w-10 h-10 bg-blue-500 rounded-full flex items-center justify-center shadow-lg"
>
<Code className="w-5 h-5 text-white" />
</motion.div>
<motion.div
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5, delay: 1.7 }}
className="absolute -bottom-3 -left-3 w-10 h-10 bg-purple-500 rounded-full flex items-center justify-center shadow-lg"
>
<Zap className="w-5 h-5 text-white" />
</motion.div>
<motion.div
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5, delay: 1.9 }}
className="absolute -top-3 -left-3 w-10 h-10 bg-cyan-500 rounded-full flex items-center justify-center shadow-lg"
>
<Rocket className="w-5 h-5 text-white" />
</motion.div>
</div>
</motion.div>
{/* Main Title */}
<motion.h1
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.2 }}
transition={{ duration: 0.8, delay: 0.8 }}
className="text-5xl md:text-7xl font-bold mb-6"
>
<span className="gradient-text">Dennis Konkol</span>
@@ -84,7 +146,7 @@ const Hero = () => {
<motion.p
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.4 }}
transition={{ duration: 0.8, delay: 1.0 }}
className="text-xl md:text-2xl text-gray-300 mb-8 max-w-2xl mx-auto"
>
Student & Software Engineer based in Osnabrück, Germany
@@ -94,7 +156,7 @@ const Hero = () => {
<motion.p
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.6 }}
transition={{ duration: 0.8, delay: 1.2 }}
className="text-lg text-gray-400 mb-12 max-w-3xl mx-auto leading-relaxed"
>
Passionate about technology, coding, and solving real-world problems.
@@ -105,7 +167,7 @@ const Hero = () => {
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.8 }}
transition={{ duration: 0.8, delay: 1.4 }}
className="flex flex-wrap justify-center gap-6 mb-12"
>
{features.map((feature, index) => (
@@ -113,7 +175,7 @@ const Hero = () => {
key={feature.text}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5, delay: 1 + index * 0.1 }}
transition={{ duration: 0.5, delay: 1.6 + index * 0.1 }}
whileHover={{ scale: 1.05, y: -5 }}
className="flex items-center space-x-2 px-4 py-2 rounded-full glass-card"
>
@@ -127,7 +189,7 @@ const Hero = () => {
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 1.2 }}
transition={{ duration: 0.8, delay: 1.8 }}
className="flex flex-col sm:flex-row gap-4 justify-center items-center"
>
<motion.a

View File

@@ -27,12 +27,23 @@ const Projects = () => {
const [projects, setProjects] = useState<Project[]>([]);
// Load projects from localStorage
// Load projects from API
useEffect(() => {
const savedProjects = localStorage.getItem('portfolio-projects');
if (savedProjects) {
setProjects(JSON.parse(savedProjects));
}
const loadProjects = async () => {
try {
const response = await fetch('/api/projects?featured=true&published=true&limit=6');
if (response.ok) {
const data = await response.json();
setProjects(data.projects || []);
} else {
console.error('Failed to fetch projects from API');
}
} catch (error) {
console.error('Error loading projects:', error);
}
};
loadProjects();
}, []);
if (!mounted) {

View File

@@ -2,6 +2,7 @@ import "./globals.css";
import { Metadata } from "next";
import { Inter } from "next/font/google";
import React from "react";
import { ToastProvider } from "@/components/Toast";
const inter = Inter({
variable: "--font-inter",
@@ -24,7 +25,11 @@ export default function RootLayout({
<meta charSet="utf-8"/>
<title>Dennis Konkol&#39;s Portfolio</title>
</head>
<body className={inter.variable}>{children}</body>
<body className={inter.variable}>
<ToastProvider>
{children}
</ToastProvider>
</body>
</html>
);
}

View File

@@ -25,18 +25,25 @@ const ProjectDetail = () => {
const slug = params.slug as string;
const [project, setProject] = useState<Project | null>(null);
// Load project from localStorage by slug
// Load project from API by slug
useEffect(() => {
const savedProjects = localStorage.getItem('portfolio-projects');
if (savedProjects) {
const projects = JSON.parse(savedProjects);
const foundProject = projects.find((p: Project) =>
p.title.toLowerCase().replace(/[^a-z0-9]+/g, '-') === slug
);
if (foundProject) {
setProject(foundProject);
const loadProject = async () => {
try {
const response = await fetch(`/api/projects/search?slug=${slug}`);
if (response.ok) {
const data = await response.json();
if (data.projects && data.projects.length > 0) {
setProject(data.projects[0]);
}
} else {
console.error('Failed to fetch project from API');
}
} catch (error) {
console.error('Error loading project:', error);
}
}
};
loadProject();
}, [slug]);
if (!project) {

View File

@@ -22,12 +22,23 @@ interface Project {
const ProjectsPage = () => {
const [projects, setProjects] = useState<Project[]>([]);
// Load projects from localStorage
// Load projects from API
useEffect(() => {
const savedProjects = localStorage.getItem('portfolio-projects');
if (savedProjects) {
setProjects(JSON.parse(savedProjects));
}
const loadProjects = async () => {
try {
const response = await fetch('/api/projects?published=true');
if (response.ok) {
const data = await response.json();
setProjects(data.projects || []);
} else {
console.error('Failed to fetch projects from API');
}
} catch (error) {
console.error('Error loading projects:', error);
}
};
loadProjects();
}, []);
const categories = ["All", "Web Development", "Full-Stack", "Web Application", "Mobile App"];

View File

@@ -0,0 +1,562 @@
"use client";
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Database,
Search,
Filter,
BarChart3,
Download,
Upload,
Trash2,
Edit,
Eye,
Plus,
Save,
Settings,
TrendingUp,
Users,
Clock,
Star,
Tag,
FolderOpen,
Calendar,
Activity
} from 'lucide-react';
import { projectService, DatabaseProject } from '@/lib/prisma';
import { useToast } from './Toast';
interface AdminDashboardProps {
onProjectSelect: (project: DatabaseProject) => void;
onNewProject: () => void;
}
export default function AdminDashboard({ onProjectSelect, onNewProject }: AdminDashboardProps) {
const [projects, setProjects] = useState<DatabaseProject[]>([]);
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);
} 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: any, bValue: any;
switch (sortBy) {
case 'date':
aValue = new Date(a.created_at);
bValue = new Date(b.created_at);
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.analytics.views;
bValue = b.analytics.views;
break;
default:
aValue = a.created_at;
bValue = b.created_at;
}
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.analytics.views, 0),
totalLikes: projects.reduce((sum, p) => sum + p.analytics.likes, 0),
avgLighthouse: projects.length > 0 ?
Math.round(projects.reduce((sum, p) => sum + p.performance.lighthouse, 0) / projects.length) : 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 any)}
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.created_at).toLocaleDateString()}
</span>
<span className="flex items-center">
<Eye className="mr-1" size={14} />
{project.analytics.views} views
</span>
<span className="flex items-center">
<Activity className="mr-1" size={14} />
{project.performance.lighthouse}/100
</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>
);
}

306
components/Toast.tsx Normal file
View File

@@ -0,0 +1,306 @@
"use client";
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
CheckCircle,
XCircle,
AlertTriangle,
Info,
X,
Mail,
Database,
Save,
Trash2,
Upload,
Download
} from 'lucide-react';
export type ToastType = 'success' | 'error' | 'warning' | 'info';
export interface Toast {
id: string;
type: ToastType;
title: string;
message: string;
duration?: number;
action?: {
label: string;
onClick: () => void;
};
}
interface ToastProps {
toast: Toast;
onRemove: (id: string) => void;
}
const ToastItem = ({ toast, onRemove }: ToastProps) => {
const [isVisible, setIsVisible] = useState(true);
useEffect(() => {
if (toast.duration !== 0) {
const timer = setTimeout(() => {
setIsVisible(false);
setTimeout(() => onRemove(toast.id), 300);
}, toast.duration || 5000);
return () => clearTimeout(timer);
}
}, [toast.duration, toast.id, onRemove]);
const getIcon = () => {
switch (toast.type) {
case 'success':
return <CheckCircle className="w-5 h-5 text-green-400" />;
case 'error':
return <XCircle className="w-5 h-5 text-red-400" />;
case 'warning':
return <AlertTriangle className="w-5 h-5 text-yellow-400" />;
case 'info':
return <Info className="w-5 h-5 text-blue-400" />;
default:
return <Info className="w-5 h-5 text-blue-400" />;
}
};
const getColors = () => {
switch (toast.type) {
case 'success':
return 'bg-white border-green-300 text-green-900 shadow-lg';
case 'error':
return 'bg-white border-red-300 text-red-900 shadow-lg';
case 'warning':
return 'bg-white border-yellow-300 text-yellow-900 shadow-lg';
case 'info':
return 'bg-white border-blue-300 text-blue-900 shadow-lg';
default:
return 'bg-white border-gray-300 text-gray-900 shadow-lg';
}
};
return (
<motion.div
initial={{ opacity: 0, y: -50, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -50, scale: 0.9 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className={`relative p-4 rounded-xl border ${getColors()} shadow-xl hover:shadow-2xl transition-all duration-300 max-w-sm`}
>
<div className="flex items-start space-x-3">
<div className="flex-shrink-0 mt-0.5">
{getIcon()}
</div>
<div className="flex-1 min-w-0">
<h4 className="text-sm font-semibold mb-1">{toast.title}</h4>
<p className="text-sm opacity-90">{toast.message}</p>
{toast.action && (
<button
onClick={toast.action.onClick}
className="mt-2 text-xs font-medium underline hover:no-underline transition-all"
>
{toast.action.label}
</button>
)}
</div>
<button
onClick={() => {
setIsVisible(false);
setTimeout(() => onRemove(toast.id), 300);
}}
className="flex-shrink-0 p-1 rounded-lg hover:bg-gray-100 transition-colors"
>
<X className="w-4 h-4 text-gray-500" />
</button>
</div>
{/* Progress bar */}
{toast.duration !== 0 && (
<motion.div
initial={{ width: '100%' }}
animate={{ width: '0%' }}
transition={{ duration: (toast.duration || 5000) / 1000, ease: "linear" }}
className="absolute bottom-0 left-0 h-1 bg-gradient-to-r from-blue-400 to-green-400 rounded-b-xl"
/>
)}
</motion.div>
);
};
// Toast context and provider
import { createContext, useContext, useCallback } from 'react';
interface ToastContextType {
showToast: (toast: Omit<Toast, 'id'>) => void;
showSuccess: (title: string, message?: string) => void;
showError: (title: string, message?: string) => void;
showWarning: (title: string, message?: string) => void;
showInfo: (title: string, message?: string) => void;
showEmailSent: (email: string) => void;
showEmailError: (error: string) => void;
showProjectSaved: (title: string) => void;
showProjectDeleted: (title: string) => void;
showImportSuccess: (count: number) => void;
showImportError: (error: string) => void;
}
const ToastContext = createContext<ToastContextType | undefined>(undefined);
export const useToast = () => {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within a ToastProvider');
}
return context;
};
export const ToastProvider = ({ children }: { children: React.ReactNode }) => {
const [toasts, setToasts] = useState<Toast[]>([]);
const addToast = useCallback((toast: Omit<Toast, 'id'>) => {
const id = Math.random().toString(36).substr(2, 9);
const newToast = { ...toast, id };
setToasts(prev => [...prev, newToast]);
}, []);
const removeToast = useCallback((id: string) => {
setToasts(prev => prev.filter(toast => toast.id !== id));
}, []);
const showToast = useCallback((toast: Omit<Toast, 'id'>) => {
addToast(toast);
}, [addToast]);
const showSuccess = useCallback((title: string, message?: string) => {
addToast({
type: 'success',
title,
message: message || '',
duration: 4000
});
}, [addToast]);
const showError = useCallback((title: string, message?: string) => {
addToast({
type: 'error',
title,
message: message || '',
duration: 6000
});
}, [addToast]);
const showWarning = useCallback((title: string, message?: string) => {
addToast({
type: 'warning',
title,
message: message || '',
duration: 5000
});
}, [addToast]);
const showInfo = useCallback((title: string, message?: string) => {
addToast({
type: 'info',
title,
message: message || '',
duration: 4000
});
}, [addToast]);
const showEmailSent = useCallback((email: string) => {
addToast({
type: 'success',
title: 'E-Mail gesendet! 📧',
message: `Deine Nachricht an ${email} wurde erfolgreich versendet.`,
duration: 5000,
icon: <Mail className="w-5 h-5 text-green-400" />
});
}, [addToast]);
const showEmailError = useCallback((error: string) => {
addToast({
type: 'error',
title: 'E-Mail Fehler! ❌',
message: `Fehler beim Senden: ${error}`,
duration: 8000
});
}, [addToast]);
const showProjectSaved = useCallback((title: string) => {
addToast({
type: 'success',
title: 'Projekt gespeichert! 💾',
message: `"${title}" wurde erfolgreich in der Datenbank gespeichert.`,
duration: 4000,
icon: <Save className="w-5 h-5 text-green-400" />
});
}, [addToast]);
const showProjectDeleted = useCallback((title: string) => {
addToast({
type: 'warning',
title: 'Projekt gelöscht! 🗑️',
message: `"${title}" wurde aus der Datenbank entfernt.`,
duration: 4000,
icon: <Trash2 className="w-5 h-5 text-yellow-400" />
});
}, [addToast]);
const showImportSuccess = useCallback((count: number) => {
addToast({
type: 'success',
title: 'Import erfolgreich! 📥',
message: `${count} Projekte wurden erfolgreich importiert.`,
duration: 5000,
icon: <Upload className="w-5 h-5 text-green-400" />
});
}, [addToast]);
const showImportError = useCallback((error: string) => {
addToast({
type: 'error',
title: 'Import Fehler! ❌',
message: `Fehler beim Importieren: ${error}`,
duration: 8000,
icon: <Download className="w-5 h-5 text-red-400" />
});
}, [addToast]);
const contextValue: ToastContextType = {
showToast,
showSuccess,
showError,
showWarning,
showInfo,
showEmailSent,
showEmailError,
showProjectSaved,
showProjectDeleted,
showImportSuccess,
showImportError
};
return (
<ToastContext.Provider value={contextValue}>
{children}
{/* Toast Container */}
<div className="fixed top-4 right-4 z-50 space-y-3 max-w-sm">
<AnimatePresence>
{toasts.map((toast) => (
<ToastItem
key={toast.id}
toast={toast}
onRemove={removeToast}
/>
))}
</AnimatePresence>
</div>
</ToastContext.Provider>
);
};
export default ToastItem;

237
lib/prisma.ts Normal file
View File

@@ -0,0 +1,237 @@
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma = globalForPrisma.prisma ?? new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
});
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
// Database service functions
export const projectService = {
// Get all projects with pagination and filtering
async getAllProjects(options: {
page?: number;
limit?: number;
category?: string;
featured?: boolean;
published?: boolean;
difficulty?: string;
search?: string;
} = {}) {
const { page = 1, limit = 50, category, featured, published, difficulty, search } = options;
const skip = (page - 1) * limit;
const where: any = {};
if (category) where.category = category;
if (featured !== undefined) where.featured = featured;
if (published !== undefined) where.published = published;
if (difficulty) where.difficulty = difficulty;
if (search) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
{ tags: { hasSome: [search] } },
{ content: { contains: search, mode: 'insensitive' } }
];
}
const [projects, total] = await Promise.all([
prisma.project.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take: limit,
include: {
_count: {
select: {
pageViews: true,
userInteractions: true
}
}
}
}),
prisma.project.count({ where })
]);
return {
projects,
total,
pages: Math.ceil(total / limit),
currentPage: page
};
},
// Get project by ID
async getProjectById(id: number) {
return prisma.project.findUnique({
where: { id },
include: {
_count: {
select: {
pageViews: true,
userInteractions: true
}
}
}
});
},
// Create new project
async createProject(data: any) {
return prisma.project.create({
data: {
...data,
performance: data.performance || { lighthouse: 90, bundleSize: '50KB', loadTime: '1.5s' },
analytics: data.analytics || { views: 0, likes: 0, shares: 0 }
}
});
},
// Update project
async updateProject(id: number, data: any) {
return prisma.project.update({
where: { id },
data: { ...data, updatedAt: new Date() }
});
},
// Delete project
async deleteProject(id: number) {
return prisma.project.delete({
where: { id }
});
},
// Get featured projects
async getFeaturedProjects(limit: number = 6) {
return prisma.project.findMany({
where: { featured: true, published: true },
orderBy: { createdAt: 'desc' },
take: limit
});
},
// Get projects by category
async getProjectsByCategory(category: string, limit: number = 10) {
return prisma.project.findMany({
where: { category, published: true },
orderBy: { createdAt: 'desc' },
take: limit
});
},
// Search projects
async searchProjects(query: string, limit: number = 20) {
return prisma.project.findMany({
where: {
OR: [
{ title: { contains: query, mode: 'insensitive' } },
{ description: { contains: query, mode: 'insensitive' } },
{ tags: { hasSome: [query] } },
{ content: { contains: query, mode: 'insensitive' } }
],
published: true
},
orderBy: { createdAt: 'desc' },
take: limit
});
},
// Track page view
async trackPageView(projectId: number | null, page: string, ip?: string, userAgent?: string, referrer?: string) {
return prisma.pageView.create({
data: {
projectId,
page,
ip,
userAgent,
referrer
}
});
},
// Track user interaction
async trackUserInteraction(projectId: number, type: string, ip?: string, userAgent?: string) {
return prisma.userInteraction.create({
data: {
projectId,
type: type as any,
ip,
userAgent
}
});
},
// Get analytics
async getAnalytics(projectId: number) {
const [pageViews, interactions] = await Promise.all([
prisma.pageView.count({ where: { projectId } }),
prisma.userInteraction.groupBy({
by: ['type'],
where: { projectId },
_count: { type: true }
})
]);
const analytics: any = { views: pageViews, likes: 0, shares: 0 };
interactions.forEach(interaction => {
if (interaction.type === 'LIKE') analytics.likes = interaction._count.type;
if (interaction.type === 'SHARE') analytics.shares = interaction._count.type;
});
return analytics;
},
// Get performance stats
async getPerformanceStats() {
const projects = await prisma.project.findMany({
select: {
performance: true,
analytics: true,
category: true,
difficulty: true
}
});
const stats = {
totalProjects: projects.length,
avgLighthouse: 0,
totalViews: 0,
totalLikes: 0,
totalShares: 0,
byCategory: {} as any,
byDifficulty: {} as any
};
projects.forEach(project => {
const perf = project.performance as any;
const analytics = project.analytics as any;
stats.avgLighthouse += perf?.lighthouse || 0;
stats.totalViews += analytics?.views || 0;
stats.totalLikes += analytics?.likes || 0;
stats.totalShares += analytics?.shares || 0;
// Category stats
if (!stats.byCategory[project.category]) stats.byCategory[project.category] = 0;
stats.byCategory[project.category]++;
// Difficulty stats
if (!stats.byDifficulty[project.difficulty]) stats.byDifficulty[project.difficulty] = 0;
stats.byDifficulty[project.difficulty]++;
});
if (stats.totalProjects > 0) {
stats.avgLighthouse = Math.round(stats.avgLighthouse / stats.totalProjects);
}
return stats;
}
};

138
lib/supabase.ts Normal file
View File

@@ -0,0 +1,138 @@
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
// Database types
export interface DatabaseProject {
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;
};
created_at: string;
updated_at: string;
}
// Database operations
export const projectService = {
async getAllProjects(): Promise<DatabaseProject[]> {
const { data, error } = await supabase
.from('projects')
.select('*')
.order('created_at', { ascending: false });
if (error) throw error;
return data || [];
},
async getProjectById(id: number): Promise<DatabaseProject | null> {
const { data, error } = await supabase
.from('projects')
.select('*')
.eq('id', id)
.single();
if (error) throw error;
return data;
},
async createProject(project: Omit<DatabaseProject, 'id' | 'created_at' | 'updated_at'>): Promise<DatabaseProject> {
const { data, error } = await supabase
.from('projects')
.insert([project])
.select()
.single();
if (error) throw error;
return data;
},
async updateProject(id: number, updates: Partial<DatabaseProject>): Promise<DatabaseProject> {
const { data, error } = await supabase
.from('projects')
.update({ ...updates, updated_at: new Date().toISOString() })
.eq('id', id)
.select()
.single();
if (error) throw error;
return data;
},
async deleteProject(id: number): Promise<void> {
const { error } = await supabase
.from('projects')
.delete()
.eq('id', id);
if (error) throw error;
},
async searchProjects(query: string): Promise<DatabaseProject[]> {
const { data, error } = await supabase
.from('projects')
.select('*')
.or(`title.ilike.%${query}%,description.ilike.%${query}%,content.ilike.%${query}%,tags.cs.{${query}}`)
.order('created_at', { ascending: false });
if (error) throw error;
return data || [];
},
async getProjectsByCategory(category: string): Promise<DatabaseProject[]> {
const { data, error } = await supabase
.from('projects')
.select('*')
.eq('category', category)
.order('created_at', { ascending: false });
if (error) throw error;
return data || [];
},
async getFeaturedProjects(): Promise<DatabaseProject[]> {
const { data, error } = await supabase
.from('projects')
.select('*')
.eq('featured', true)
.eq('published', true)
.order('created_at', { ascending: false });
if (error) throw error;
return data || [];
}
};

27
middleware.ts Normal file
View File

@@ -0,0 +1,27 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
// Allow email and projects API routes without authentication
if (request.nextUrl.pathname.startsWith('/api/email/') ||
request.nextUrl.pathname.startsWith('/api/projects/')) {
return NextResponse.next();
}
// For all other routes, continue with normal processing
return NextResponse.next();
}
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - api/email (email API routes)
* - api/projects (projects API routes)
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
*/
'/((?!api/email|api/projects|_next/static|_next/image|favicon.ico).*)',
],
};

576
package-lock.json generated
View File

@@ -9,7 +9,7 @@
"version": "0.1.0",
"dependencies": {
"@next/bundle-analyzer": "^15.1.7",
"@prisma/client": "^6.3.1",
"@prisma/client": "^5.7.1",
"@vercel/og": "^0.6.5",
"clsx": "^2.1.0",
"dotenv": "^16.4.7",
@@ -20,6 +20,7 @@
"node-cache": "^5.1.2",
"node-fetch": "^2.7.0",
"nodemailer": "^6.10.0",
"prisma": "^5.7.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^9.0.1",
@@ -33,7 +34,7 @@
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@types/jest": "^29.5.14",
"@types/node": "^22",
"@types/node": "^20.10.0",
"@types/node-fetch": "^2.6.13",
"@types/nodemailer": "^6.4.17",
"@types/react": "^19",
@@ -50,6 +51,7 @@
"tailwindcss": "^3.4.17",
"ts-jest": "^29.2.5",
"ts-node": "^10.9.2",
"tsx": "^4.20.5",
"typescript": "^5.7.3",
"whatwg-fetch": "^3.6.20"
}
@@ -659,6 +661,422 @@
"tslib": "^2.4.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz",
"integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.9.tgz",
"integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz",
"integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.9.tgz",
"integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz",
"integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz",
"integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz",
"integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz",
"integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz",
"integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz",
"integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz",
"integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==",
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz",
"integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==",
"cpu": [
"loong64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz",
"integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==",
"cpu": [
"mips64el"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz",
"integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz",
"integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==",
"cpu": [
"riscv64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz",
"integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==",
"cpu": [
"s390x"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz",
"integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz",
"integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz",
"integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz",
"integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz",
"integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz",
"integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz",
"integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz",
"integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz",
"integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==",
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz",
"integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz",
@@ -2026,27 +2444,62 @@
"license": "MIT"
},
"node_modules/@prisma/client": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.4.1.tgz",
"integrity": "sha512-A7Mwx44+GVZVexT5e2GF/WcKkEkNNKbgr059xpr5mn+oUm2ZW1svhe+0TRNBwCdzhfIZ+q23jEgsNPvKD9u+6g==",
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz",
"integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==",
"hasInstallScript": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18.18"
"node": ">=16.13"
},
"peerDependencies": {
"prisma": "*",
"typescript": ">=5.1.0"
"prisma": "*"
},
"peerDependenciesMeta": {
"prisma": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/@prisma/debug": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz",
"integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ=="
},
"node_modules/@prisma/engines": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz",
"integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==",
"hasInstallScript": true,
"dependencies": {
"@prisma/debug": "5.22.0",
"@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
"@prisma/fetch-engine": "5.22.0",
"@prisma/get-platform": "5.22.0"
}
},
"node_modules/@prisma/engines-version": {
"version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz",
"integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ=="
},
"node_modules/@prisma/fetch-engine": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz",
"integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==",
"dependencies": {
"@prisma/debug": "5.22.0",
"@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
"@prisma/get-platform": "5.22.0"
}
},
"node_modules/@prisma/get-platform": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz",
"integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==",
"dependencies": {
"@prisma/debug": "5.22.0"
}
},
"node_modules/@resvg/resvg-wasm": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@resvg/resvg-wasm/-/resvg-wasm-2.4.0.tgz",
@@ -2461,13 +2914,12 @@
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="
},
"node_modules/@types/node": {
"version": "22.13.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.5.tgz",
"integrity": "sha512-+lTU0PxZXn0Dr1NBtC7Y8cR21AJr87dLLU953CWA6pMxxv/UDc7jYAY90upcrie1nRcD6XNG5HOYEDtgW5TxAg==",
"version": "20.19.11",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.11.tgz",
"integrity": "sha512-uug3FEEGv0r+jrecvUUpbY8lLisvIjg6AAic6a2bSP5OEOLeJsDSnvhCDov7ipFFMXS3orMpzlmi0ZcuGkBbow==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.20.0"
"undici-types": "~6.21.0"
}
},
"node_modules/@types/node-fetch": {
@@ -4619,6 +5071,47 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/esbuild": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz",
"integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==",
"dev": true,
"hasInstallScript": true,
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.9",
"@esbuild/android-arm": "0.25.9",
"@esbuild/android-arm64": "0.25.9",
"@esbuild/android-x64": "0.25.9",
"@esbuild/darwin-arm64": "0.25.9",
"@esbuild/darwin-x64": "0.25.9",
"@esbuild/freebsd-arm64": "0.25.9",
"@esbuild/freebsd-x64": "0.25.9",
"@esbuild/linux-arm": "0.25.9",
"@esbuild/linux-arm64": "0.25.9",
"@esbuild/linux-ia32": "0.25.9",
"@esbuild/linux-loong64": "0.25.9",
"@esbuild/linux-mips64el": "0.25.9",
"@esbuild/linux-ppc64": "0.25.9",
"@esbuild/linux-riscv64": "0.25.9",
"@esbuild/linux-s390x": "0.25.9",
"@esbuild/linux-x64": "0.25.9",
"@esbuild/netbsd-arm64": "0.25.9",
"@esbuild/netbsd-x64": "0.25.9",
"@esbuild/openbsd-arm64": "0.25.9",
"@esbuild/openbsd-x64": "0.25.9",
"@esbuild/openharmony-arm64": "0.25.9",
"@esbuild/sunos-x64": "0.25.9",
"@esbuild/win32-arm64": "0.25.9",
"@esbuild/win32-ia32": "0.25.9",
"@esbuild/win32-x64": "0.25.9"
}
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -5486,7 +5979,6 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -9821,6 +10313,24 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/prisma": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz",
"integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==",
"hasInstallScript": true,
"dependencies": {
"@prisma/engines": "5.22.0"
},
"bin": {
"prisma": "build/index.js"
},
"engines": {
"node": ">=16.13"
},
"optionalDependencies": {
"fsevents": "2.3.3"
}
},
"node_modules/prismjs": {
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
@@ -11655,6 +12165,25 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.20.5",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.5.tgz",
"integrity": "sha512-+wKjMNU9w/EaQayHXb7WA7ZaHY6hN8WgfvHNQ3t1PnU91/7O8TcTnIhCDYTZwnt8JsO9IBqZ30Ln1r7pPF52Aw==",
"dev": true,
"dependencies": {
"esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -11773,7 +12302,7 @@
"version": "5.7.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
"integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
"devOptional": true,
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -11803,11 +12332,10 @@
}
},
"node_modules/undici-types": {
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
"dev": true,
"license": "MIT"
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true
},
"node_modules/unicode-trie": {
"version": "2.0.0",

View File

@@ -8,11 +8,20 @@
"start": "next start",
"lint": "next lint",
"buildAnalyze": "cross-env ANALYZE=true next build",
"test": "jest"
"test": "jest",
"db:setup": "chmod +x scripts/setup-db.sh && ./scripts/setup-db.sh",
"db:generate": "prisma generate",
"db:push": "prisma db push",
"db:seed": "tsx prisma/seed.ts",
"db:studio": "prisma studio",
"db:reset": "prisma db push --force-reset"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
},
"dependencies": {
"@next/bundle-analyzer": "^15.1.7",
"@prisma/client": "^6.3.1",
"@prisma/client": "^5.7.1",
"@vercel/og": "^0.6.5",
"clsx": "^2.1.0",
"dotenv": "^16.4.7",
@@ -23,6 +32,7 @@
"node-cache": "^5.1.2",
"node-fetch": "^2.7.0",
"nodemailer": "^6.10.0",
"prisma": "^5.7.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^9.0.1",
@@ -36,7 +46,7 @@
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@types/jest": "^29.5.14",
"@types/node": "^22",
"@types/node": "^20.10.0",
"@types/node-fetch": "^2.6.13",
"@types/nodemailer": "^6.4.17",
"@types/react": "^19",
@@ -53,6 +63,7 @@
"tailwindcss": "^3.4.17",
"ts-jest": "^29.2.5",
"ts-node": "^10.9.2",
"tsx": "^4.20.5",
"typescript": "^5.7.3",
"whatwg-fetch": "^3.6.20"
}

103
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,103 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Project {
id Int @id @default(autoincrement())
title String @db.VarChar(255)
description String @db.Text
content String @db.Text
tags String[] @default([])
featured Boolean @default(false)
category String @db.VarChar(100)
date String @db.VarChar(10)
github String? @db.VarChar(500)
live String? @db.VarChar(500)
published Boolean @default(true)
imageUrl String? @db.VarChar(500)
metaDescription String? @db.Text
keywords String? @db.Text
ogImage String? @db.VarChar(500)
schema Json?
// Advanced features
difficulty Difficulty @default(INTERMEDIATE)
timeToComplete String? @db.VarChar(100)
technologies String[] @default([])
challenges String[] @default([])
lessonsLearned String[] @default([])
futureImprovements String[] @default([])
demoVideo String? @db.VarChar(500)
screenshots String[] @default([])
colorScheme String @db.VarChar(100) @default("Dark")
accessibility Boolean @default(true)
// Performance metrics
performance Json @default("{\"lighthouse\": 90, \"bundleSize\": \"50KB\", \"loadTime\": \"1.5s\"}")
// Analytics
analytics Json @default("{\"views\": 0, \"likes\": 0, \"shares\": 0}")
// Timestamps
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Indexes for performance
@@index([category])
@@index([featured])
@@index([published])
@@index([difficulty])
@@index([createdAt])
@@index([tags])
}
enum Difficulty {
BEGINNER
INTERMEDIATE
ADVANCED
EXPERT
}
// Analytics tracking
model PageView {
id Int @id @default(autoincrement())
projectId Int? @map("project_id")
page String @db.VarChar(100)
ip String? @db.VarChar(45)
userAgent String? @db.Text @map("user_agent")
referrer String? @db.VarChar(500)
timestamp DateTime @default(now())
@@index([projectId])
@@index([timestamp])
@@index([page])
}
// User interactions
model UserInteraction {
id Int @id @default(autoincrement())
projectId Int @map("project_id")
type InteractionType
ip String? @db.VarChar(45)
userAgent String? @db.Text @map("user_agent")
timestamp DateTime @default(now())
@@index([projectId])
@@index([type])
@@index([timestamp])
}
enum InteractionType {
LIKE
SHARE
BOOKMARK
COMMENT
}

329
prisma/seed.ts Normal file
View File

@@ -0,0 +1,329 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
console.log('🌱 Seeding database...');
// Clear existing data
await prisma.userInteraction.deleteMany();
await prisma.pageView.deleteMany();
await prisma.project.deleteMany();
// Create sample projects
const projects = [
{
title: "Portfolio Website 2.0",
description: "A cutting-edge portfolio website showcasing modern web development techniques with advanced features and stunning design.",
content: `# Portfolio Website 2.0
This is my personal portfolio website built with cutting-edge web technologies. The site features a dark theme with glassmorphism effects, smooth animations, and advanced interactive elements.
## 🚀 Features
- **Responsive Design**: Works perfectly on all devices
- **Dark Theme**: Modern dark mode with glassmorphism effects
- **Animations**: Smooth animations powered by Framer Motion
- **Markdown Support**: Projects are written in Markdown for easy editing
- **Performance**: Optimized for speed and SEO
- **Interactive Elements**: Advanced UI components and micro-interactions
- **Accessibility**: WCAG 2.1 AA compliant
- **Analytics**: Built-in performance and user analytics
## 🛠️ Technologies Used
- Next.js 15
- TypeScript
- Tailwind CSS
- Framer Motion
- React Markdown
- Advanced CSS (Grid, Flexbox, Custom Properties)
- Performance optimization techniques
## 📈 Development Process
The website was designed with a focus on user experience, performance, and accessibility. I used modern CSS techniques and best practices to create a responsive, fast, and beautiful layout.
## 🔮 Future Improvements
- AI-powered content suggestions
- Advanced project filtering and search
- Interactive project demos
- Real-time collaboration features
- Advanced analytics dashboard
## 🔗 Links
- [Live Demo](https://dki.one)
- [GitHub Repository](https://github.com/Denshooter/portfolio)`,
tags: ["Next.js", "TypeScript", "Tailwind CSS", "Framer Motion", "Advanced CSS", "Performance"],
featured: true,
category: "Web Development",
date: "2024",
published: true,
difficulty: "ADVANCED",
timeToComplete: "3-4 weeks",
technologies: ["Next.js 15", "TypeScript", "Tailwind CSS", "Framer Motion", "React Markdown"],
challenges: ["Complex state management", "Performance optimization", "Responsive design across devices"],
lessonsLearned: ["Advanced CSS techniques", "Performance optimization", "User experience design"],
futureImprovements: ["AI integration", "Advanced analytics", "Real-time features"],
demoVideo: "",
screenshots: [],
colorScheme: "Dark with glassmorphism",
accessibility: true,
performance: {
lighthouse: 95,
bundleSize: "45KB",
loadTime: "1.2s"
},
analytics: {
views: 1250,
likes: 89,
shares: 23
}
},
{
title: "E-Commerce Platform",
description: "A full-stack e-commerce solution with advanced features like real-time inventory, payment processing, and admin dashboard.",
content: `# E-Commerce Platform
A comprehensive e-commerce solution built with modern web technologies, featuring a robust backend, secure payment processing, and an intuitive user interface.
## 🚀 Features
- **User Authentication**: Secure login and registration
- **Product Management**: Add, edit, and delete products
- **Shopping Cart**: Persistent cart with real-time updates
- **Payment Processing**: Stripe integration for secure payments
- **Order Management**: Complete order lifecycle tracking
- **Admin Dashboard**: Comprehensive admin interface
- **Inventory Management**: Real-time stock tracking
- **Responsive Design**: Mobile-first approach
## 🛠️ Technologies Used
- Frontend: React, TypeScript, Tailwind CSS
- Backend: Node.js, Express, Prisma
- Database: PostgreSQL
- Payment: Stripe API
- Authentication: JWT, bcrypt
- Deployment: Docker, AWS
## 📈 Development Process
Built with a focus on scalability and user experience. Implemented proper error handling, input validation, and security measures throughout the development process.
## 🔮 Future Improvements
- Multi-language support
- Advanced analytics dashboard
- AI-powered product recommendations
- Mobile app development
- Advanced search and filtering`,
tags: ["React", "Node.js", "PostgreSQL", "Stripe", "E-commerce", "Full-Stack"],
featured: true,
category: "Full-Stack",
date: "2024",
published: true,
difficulty: "EXPERT",
timeToComplete: "8-10 weeks",
technologies: ["React", "Node.js", "PostgreSQL", "Stripe", "Docker", "AWS"],
challenges: ["Payment integration", "Real-time updates", "Scalability", "Security"],
lessonsLearned: ["Payment processing", "Real-time systems", "Security best practices", "Scalable architecture"],
futureImprovements: ["AI recommendations", "Mobile app", "Multi-language", "Advanced analytics"],
demoVideo: "",
screenshots: [],
colorScheme: "Professional and clean",
accessibility: true,
performance: {
lighthouse: 92,
bundleSize: "78KB",
loadTime: "1.8s"
},
analytics: {
views: 890,
likes: 67,
shares: 18
}
},
{
title: "Task Management App",
description: "A collaborative task management application with real-time updates, team collaboration, and progress tracking.",
content: `# Task Management App
A collaborative task management application designed for teams to organize, track, and complete projects efficiently.
## 🚀 Features
- **Task Creation**: Easy task creation with descriptions and deadlines
- **Team Collaboration**: Assign tasks to team members
- **Real-time Updates**: Live updates across all connected clients
- **Progress Tracking**: Visual progress indicators and analytics
- **File Attachments**: Support for documents and images
- **Notifications**: Email and push notifications for updates
- **Mobile Responsive**: Works perfectly on all devices
- **Dark/Light Theme**: User preference support
## 🛠️ Technologies Used
- Frontend: React, TypeScript, Tailwind CSS
- Backend: Node.js, Express, Socket.io
- Database: MongoDB
- Real-time: WebSockets
- Authentication: JWT
- File Storage: AWS S3
- Deployment: Heroku
## 📈 Development Process
Focused on creating an intuitive user interface and seamless real-time collaboration. Implemented proper error handling and user feedback throughout the development.
## 🔮 Future Improvements
- Advanced reporting and analytics
- Integration with external tools
- Mobile app development
- AI-powered task suggestions
- Advanced automation features`,
tags: ["React", "Node.js", "MongoDB", "WebSockets", "Collaboration", "Real-time"],
featured: false,
category: "Web Application",
date: "2024",
published: true,
difficulty: "INTERMEDIATE",
timeToComplete: "6-8 weeks",
technologies: ["React", "Node.js", "MongoDB", "Socket.io", "AWS S3", "Heroku"],
challenges: ["Real-time synchronization", "Team collaboration", "File management", "Mobile responsiveness"],
lessonsLearned: ["WebSocket implementation", "Real-time systems", "File upload handling", "Team collaboration features"],
futureImprovements: ["Advanced analytics", "Mobile app", "AI integration", "Automation"],
demoVideo: "",
screenshots: [],
colorScheme: "Modern and clean",
accessibility: true,
performance: {
lighthouse: 88,
bundleSize: "65KB",
loadTime: "1.5s"
},
analytics: {
views: 567,
likes: 34,
shares: 12
}
},
{
title: "Weather Dashboard",
description: "A beautiful weather application with real-time data, forecasts, and interactive maps.",
content: `# Weather Dashboard
A beautiful and functional weather application that provides real-time weather data, forecasts, and interactive maps.
## 🚀 Features
- **Current Weather**: Real-time weather conditions
- **Forecast**: 7-day weather predictions
- **Interactive Maps**: Visual weather maps with overlays
- **Location Search**: Find weather for any location
- **Weather Alerts**: Severe weather notifications
- **Historical Data**: Past weather information
- **Responsive Design**: Works on all devices
- **Offline Support**: Basic functionality without internet
## 🛠️ Technologies Used
- Frontend: React, TypeScript, Tailwind CSS
- Maps: Mapbox GL JS
- Weather API: OpenWeatherMap
- State Management: Zustand
- Charts: Chart.js
- Icons: Weather Icons
- Deployment: Vercel
## 📈 Development Process
Built with a focus on user experience and visual appeal. Implemented proper error handling for API failures and created an intuitive interface for weather information.
## 🔮 Future Improvements
- Weather widgets for other websites
- Advanced forecasting algorithms
- Weather-based recommendations
- Social sharing features
- Weather photography integration`,
tags: ["React", "TypeScript", "Weather API", "Maps", "Real-time", "UI/UX"],
featured: false,
category: "Web Application",
date: "2024",
published: true,
difficulty: "BEGINNER",
timeToComplete: "3-4 weeks",
technologies: ["React", "TypeScript", "Tailwind CSS", "Mapbox", "OpenWeatherMap", "Chart.js"],
challenges: ["API integration", "Map implementation", "Responsive design", "Error handling"],
lessonsLearned: ["External API integration", "Map libraries", "Responsive design", "Error handling"],
futureImprovements: ["Advanced forecasting", "Weather widgets", "Social features", "Mobile app"],
demoVideo: "",
screenshots: [],
colorScheme: "Light and colorful",
accessibility: true,
performance: {
lighthouse: 91,
bundleSize: "52KB",
loadTime: "1.3s"
},
analytics: {
views: 423,
likes: 28,
shares: 8
}
}
];
for (const project of projects) {
await prisma.project.create({
data: project
});
}
console.log(`✅ Created ${projects.length} sample projects`);
// Create some sample analytics data
for (let i = 1; i <= 4; i++) {
// Create page views
for (let j = 0; j < Math.floor(Math.random() * 100) + 50; j++) {
await prisma.pageView.create({
data: {
projectId: i,
page: `/projects/${i}`,
ip: `192.168.1.${Math.floor(Math.random() * 255)}`,
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
referrer: 'https://google.com'
}
});
}
// Create user interactions
for (let j = 0; j < Math.floor(Math.random() * 20) + 10; j++) {
await prisma.userInteraction.create({
data: {
projectId: i,
type: Math.random() > 0.5 ? 'LIKE' : 'SHARE',
ip: `192.168.1.${Math.floor(Math.random() * 255)}`,
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
});
}
}
console.log('✅ Created sample analytics data');
console.log('🎉 Database seeding completed!');
}
main()
.catch((e) => {
console.error('❌ Error seeding database:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

105
scripts/setup-db.sh Executable file
View File

@@ -0,0 +1,105 @@
#!/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"