Files
portfolio/components/ProjectManager.tsx
denshooter 0349c686fa feat(auth): implement session token creation and verification for enhanced security
feat(api): require session authentication for admin routes and improve error handling

fix(api): streamline project image generation by fetching data directly from the database

fix(api): optimize project import/export functionality with session validation and improved error handling

fix(api): enhance analytics dashboard and email manager with session token for admin requests

fix(components): improve loading states and dynamic imports for better user experience

chore(security): update Content Security Policy to avoid unsafe-eval in production

chore(deps): update package.json scripts for consistent environment handling in linting and testing
2026-01-12 00:27:03 +01:00

344 lines
13 KiB
TypeScript

'use client';
import React, { useState } from 'react';
import { motion } from 'framer-motion';
import {
Plus,
Edit,
Trash2,
Search,
Grid,
List,
Globe,
Github,
RefreshCw
} from 'lucide-react';
// Editor is now a separate page at /editor
interface Project {
id: string;
title: string;
description: string;
content?: string;
category: string;
difficulty?: string;
tags?: string[];
featured: boolean;
published: boolean;
github?: string;
live?: string;
image?: string;
createdAt: string;
updatedAt: string;
analytics?: {
views: number;
likes: number;
shares: number;
};
performance?: {
lighthouse: number;
};
}
interface ProjectManagerProps {
projects: Project[];
onProjectsChange: () => void;
}
export const ProjectManager: React.FC<ProjectManagerProps> = ({
projects,
onProjectsChange
}) => {
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [searchTerm, setSearchTerm] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string>('all');
const categories = ['all', 'Web Development', 'Full-Stack', 'Web Application', 'Mobile App', 'Design'];
// Filter projects
const filteredProjects = projects.filter((project) => {
const matchesSearch =
project.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
project.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
project.tags?.some(tag => tag.toLowerCase().includes(searchTerm.toLowerCase()));
const matchesCategory = selectedCategory === 'all' || project.category === selectedCategory;
return matchesSearch && matchesCategory;
});
const openEditor = (project?: Project) => {
// Simple navigation to editor - let the editor handle auth
if (project) {
window.location.href = `/editor?id=${project.id}`;
} else {
window.location.href = '/editor';
}
};
const deleteProject = async (projectId: string) => {
if (!confirm('Are you sure you want to delete this project?')) return;
try {
const sessionToken = sessionStorage.getItem('admin_session_token') || '';
await fetch(`/api/projects/${projectId}`, {
method: 'DELETE',
headers: {
'x-admin-request': 'true',
'x-session-token': sessionToken
}
});
onProjectsChange();
} catch (error) {
console.error('Error deleting project:', error);
}
};
const getStatusColor = (project: Project) => {
if (project.published) {
return project.featured ? 'text-stone-700 bg-stone-200' : 'text-green-700 bg-green-100';
}
return 'text-yellow-700 bg-yellow-100';
};
const getStatusText = (project: Project) => {
if (project.published) {
return project.featured ? 'Featured' : 'Published';
}
return 'Draft';
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-stone-900">Project Management</h1>
<p className="text-stone-500">{projects.length} projects {projects.filter(p => p.published).length} published</p>
</div>
<div className="flex items-center space-x-3">
<button
onClick={onProjectsChange}
className="flex items-center space-x-2 px-4 py-2 bg-stone-100 border border-stone-200 rounded-xl hover:bg-stone-200 transition-all duration-200"
>
<RefreshCw className="w-4 h-4 text-stone-600" />
<span className="text-stone-700 font-medium">Refresh</span>
</button>
<button
onClick={() => openEditor()}
className="flex items-center space-x-2 px-6 py-2 bg-stone-900 text-white rounded-xl hover:bg-stone-800 transition-all duration-200 shadow-md"
>
<Plus size={18} />
<span className="font-medium">New Project</span>
</button>
</div>
</div>
{/* Filters and View Toggle */}
<div className="flex flex-col sm:flex-row gap-4">
{/* Search */}
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-stone-400" />
<input
type="text"
placeholder="Search projects..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-3 bg-white border border-stone-200 rounded-xl text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-stone-400"
/>
</div>
{/* Category Filter */}
<select
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
className="px-4 py-3 bg-white border border-stone-200 rounded-xl text-stone-900 focus:outline-none focus:ring-2 focus:ring-stone-400"
>
{categories.map(category => (
<option key={category} value={category} className="bg-white text-stone-900">
{category === 'all' ? 'All Categories' : category}
</option>
))}
</select>
{/* View Toggle */}
<div className="flex items-center space-x-1 bg-white border border-stone-200 rounded-xl p-1">
<button
onClick={() => setViewMode('grid')}
className={`p-2 rounded-lg transition-all duration-200 ${
viewMode === 'grid'
? 'bg-stone-100 text-stone-900'
: 'text-stone-400 hover:text-stone-900 hover:bg-stone-50'
}`}
>
<Grid className="w-4 h-4" />
</button>
<button
onClick={() => setViewMode('list')}
className={`p-2 rounded-lg transition-all duration-200 ${
viewMode === 'list'
? 'bg-stone-100 text-stone-900'
: 'text-stone-400 hover:text-stone-900 hover:bg-stone-50'
}`}
>
<List className="w-4 h-4" />
</button>
</div>
</div>
{/* Projects Grid/List */}
{viewMode === 'grid' ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{filteredProjects.map((project) => (
<motion.div
key={project.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="admin-glass-card p-6 rounded-xl hover:shadow-lg transition-all duration-300 group bg-white border border-stone-200"
>
{/* Project Header */}
<div className="flex items-start justify-between mb-4">
<div className="flex-1">
<h3 className="text-xl font-bold text-stone-900 mb-1">{project.title}</h3>
<p className="text-stone-500 text-sm">{project.category}</p>
</div>
<div className="flex items-center space-x-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => openEditor(project)}
className="p-2 text-stone-500 hover:text-stone-700 hover:bg-stone-100 rounded-lg transition-colors"
>
<Edit size={16} />
</button>
<button
onClick={() => deleteProject(project.id)}
className="p-2 text-stone-500 hover:text-red-600 hover:bg-stone-100 rounded-lg transition-colors"
>
<Trash2 size={16} />
</button>
</div>
</div>
{/* Project Content */}
<div className="space-y-4">
<div>
<p className="text-stone-600 text-sm line-clamp-2 leading-relaxed">{project.description}</p>
</div>
{/* Tags */}
{project.tags && project.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{project.tags.slice(0, 3).map((tag) => (
<span
key={tag}
className="px-2 py-1 bg-stone-100 text-stone-600 border border-stone-200 rounded-full text-xs"
>
{tag}
</span>
))}
{project.tags.length > 3 && (
<span className="px-2 py-1 bg-stone-100 text-stone-600 border border-stone-200 rounded-full text-xs">
+{project.tags.length - 3}
</span>
)}
</div>
)}
{/* Status and Links */}
<div className="flex items-center justify-between">
<span className={`px-3 py-1 rounded-full text-xs font-medium ${getStatusColor(project)}`}>
{getStatusText(project)}
</span>
<div className="flex items-center space-x-1">
{project.github && (
<a
href={project.github}
target="_blank"
rel="noopener noreferrer"
className="p-1 text-stone-400 hover:text-stone-900 transition-colors"
>
<Github size={14} />
</a>
)}
{project.live && (
<a
href={project.live}
target="_blank"
rel="noopener noreferrer"
className="p-1 text-stone-400 hover:text-stone-900 transition-colors"
>
<Globe size={14} />
</a>
)}
</div>
</div>
{/* Analytics */}
<div className="grid grid-cols-3 gap-2 pt-3 border-t border-stone-100">
<div className="text-center">
<p className="text-stone-900 font-bold text-sm">{project.analytics?.views || 0}</p>
<p className="text-stone-500 text-xs">Views</p>
</div>
<div className="text-center">
<p className="text-stone-900 font-bold text-sm">{project.analytics?.likes || 0}</p>
<p className="text-stone-500 text-xs">Likes</p>
</div>
<div className="text-center">
<p className="text-stone-900 font-bold text-sm">{project.performance?.lighthouse || 90}</p>
<p className="text-stone-500 text-xs">Score</p>
</div>
</div>
</div>
</motion.div>
))}
</div>
) : (
<div className="space-y-4">
{filteredProjects.map((project) => (
<motion.div
key={project.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
className="admin-glass-card p-6 rounded-xl hover:shadow-md transition-all duration-300 group bg-white border border-stone-200"
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<div className="flex-1">
<h3 className="text-stone-900 font-bold text-lg">{project.title}</h3>
<p className="text-stone-500 text-sm">{project.category}</p>
</div>
</div>
<div className="flex items-center space-x-4">
<span className={`px-3 py-1 rounded-full text-xs font-medium ${getStatusColor(project)}`}>
{getStatusText(project)}
</span>
<div className="flex items-center space-x-3 text-stone-500 text-sm">
<span>{project.analytics?.views || 0} views</span>
<span></span>
<span>{new Date(project.updatedAt).toLocaleDateString()}</span>
</div>
<div className="flex items-center space-x-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => openEditor(project)}
className="p-2 text-stone-500 hover:text-stone-700 hover:bg-stone-100 rounded-lg transition-colors"
>
<Edit size={16} />
</button>
<button
onClick={() => deleteProject(project.id)}
className="p-2 text-stone-500 hover:text-red-600 hover:bg-stone-100 rounded-lg transition-colors"
>
<Trash2 size={16} />
</button>
</div>
</div>
</div>
</motion.div>
))}
</div>
)}
</div>
);
};