✨ New Features: - Complete dark theme redesign with glassmorphism effects - Responsive admin dashboard with collapsible projects list - Enhanced markdown editor with live preview - Project image upload functionality - Improved project management (create, edit, delete, publish/unpublish) - Slug-based project URLs - Legal pages (Impressum, Privacy Policy) - Modern animations with Framer Motion 🔧 Improvements: - Fixed hydration errors with mounted state - Enhanced UI/UX with better spacing and proportions - Improved markdown rendering with custom components - Better project image placeholders with initials - Conditional rendering for GitHub/Live Demo links - Enhanced toolbar with categorized quick actions - Responsive grid layout for admin dashboard 📱 Technical: - Next.js 15 + TypeScript + Tailwind CSS - Local storage for project persistence - Optimized performance and responsive design
859 lines
40 KiB
TypeScript
859 lines
40 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { motion } from 'framer-motion';
|
|
import {
|
|
Save,
|
|
Eye,
|
|
Plus,
|
|
Edit,
|
|
Trash2,
|
|
Upload,
|
|
Bold,
|
|
Italic,
|
|
List,
|
|
Link as LinkIcon,
|
|
Image as ImageIcon,
|
|
Code,
|
|
Quote,
|
|
ArrowLeft,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
Palette,
|
|
Smile,
|
|
FileText,
|
|
Download,
|
|
Upload as UploadIcon,
|
|
Settings,
|
|
Smartphone
|
|
} from 'lucide-react';
|
|
import Link from 'next/link';
|
|
import ReactMarkdown from 'react-markdown';
|
|
|
|
interface Project {
|
|
id: number;
|
|
title: string;
|
|
description: string;
|
|
content: string;
|
|
tags: string[];
|
|
featured: boolean;
|
|
category: string;
|
|
date: string;
|
|
github?: string;
|
|
live?: string;
|
|
published: boolean;
|
|
imageUrl?: string;
|
|
metaDescription?: string;
|
|
keywords?: string;
|
|
ogImage?: string;
|
|
schema?: any;
|
|
}
|
|
|
|
const AdminPage = () => {
|
|
const [mounted, setMounted] = useState(false);
|
|
|
|
useEffect(() => {
|
|
setMounted(true);
|
|
}, []);
|
|
|
|
const [projects, setProjects] = useState<Project[]>([]);
|
|
|
|
// Load projects from localStorage 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));
|
|
}
|
|
}, []);
|
|
|
|
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
|
|
|
|
const [isPreview, setIsPreview] = useState(false);
|
|
const [isProjectsCollapsed, setIsProjectsCollapsed] = useState(false);
|
|
const [formData, setFormData] = useState({
|
|
title: '',
|
|
description: '',
|
|
content: '',
|
|
tags: '',
|
|
category: '',
|
|
featured: false,
|
|
github: '',
|
|
live: '',
|
|
published: true,
|
|
imageUrl: ''
|
|
});
|
|
|
|
const [markdownContent, setMarkdownContent] = useState('');
|
|
|
|
const categories = [
|
|
"Web Development",
|
|
"Full-Stack",
|
|
"Web Application",
|
|
"Mobile App",
|
|
"Desktop App",
|
|
"API Development",
|
|
"Database Design",
|
|
"DevOps",
|
|
"UI/UX Design",
|
|
"Game Development",
|
|
"Machine Learning",
|
|
"Data Science",
|
|
"Blockchain",
|
|
"IoT",
|
|
"Cybersecurity"
|
|
];
|
|
|
|
if (!mounted) {
|
|
return null;
|
|
}
|
|
|
|
const handleSave = () => {
|
|
if (!formData.title || !formData.description || !markdownContent || !formData.category) {
|
|
alert('Please fill in all required fields!');
|
|
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),
|
|
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()
|
|
};
|
|
const updatedProjects = [...projects, newProject];
|
|
setProjects(updatedProjects);
|
|
localStorage.setItem('portfolio-projects', JSON.stringify(updatedProjects));
|
|
console.log('New project created successfully:', newProject.id);
|
|
}
|
|
|
|
resetForm();
|
|
alert('Project saved successfully!');
|
|
} catch (error) {
|
|
console.error('Error saving project:', error);
|
|
alert('Error saving project. Please try again.');
|
|
}
|
|
};
|
|
|
|
const handleEdit = (project: Project) => {
|
|
console.log('Editing project:', project);
|
|
setSelectedProject(project);
|
|
setFormData({
|
|
title: project.title,
|
|
description: project.description,
|
|
content: project.content,
|
|
tags: project.tags.join(', '),
|
|
category: project.category,
|
|
featured: project.featured,
|
|
github: project.github || '',
|
|
live: project.live || '',
|
|
published: project.published !== undefined ? project.published : true,
|
|
imageUrl: project.imageUrl || ''
|
|
});
|
|
setMarkdownContent(project.content);
|
|
setIsPreview(false);
|
|
};
|
|
|
|
const handleDelete = (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));
|
|
}
|
|
};
|
|
|
|
const resetForm = () => {
|
|
console.log('Resetting form');
|
|
setSelectedProject(null);
|
|
setFormData({
|
|
title: '',
|
|
description: '',
|
|
content: '',
|
|
tags: '',
|
|
category: '',
|
|
featured: false,
|
|
github: '',
|
|
live: '',
|
|
published: true,
|
|
imageUrl: ''
|
|
});
|
|
setMarkdownContent('');
|
|
setIsPreview(false);
|
|
};
|
|
|
|
const insertMarkdown = (type: string) => {
|
|
const textarea = document.getElementById('markdown-editor') as HTMLTextAreaElement;
|
|
if (!textarea) return;
|
|
|
|
const start = textarea.selectionStart;
|
|
const end = textarea.selectionEnd;
|
|
const text = textarea.value;
|
|
|
|
let insertion = '';
|
|
let cursorOffset = 0;
|
|
|
|
switch (type) {
|
|
case 'h1':
|
|
insertion = `# ${text.substring(start, end) || 'Heading'}`;
|
|
cursorOffset = 2;
|
|
break;
|
|
case 'h2':
|
|
insertion = `## ${text.substring(start, end) || 'Heading'}`;
|
|
cursorOffset = 3;
|
|
break;
|
|
case 'bold':
|
|
insertion = `**${text.substring(start, end) || 'bold text'}**`;
|
|
cursorOffset = 2;
|
|
break;
|
|
case 'italic':
|
|
insertion = `*${text.substring(start, end) || 'italic text'}*`;
|
|
cursorOffset = 1;
|
|
break;
|
|
case 'list':
|
|
insertion = `- ${text.substring(start, end) || 'list item'}`;
|
|
cursorOffset = 2;
|
|
break;
|
|
case 'link':
|
|
insertion = `[${text.substring(start, end) || 'link text'}](url)`;
|
|
cursorOffset = 3;
|
|
break;
|
|
case 'image':
|
|
insertion = ``;
|
|
cursorOffset = 9;
|
|
break;
|
|
case 'code':
|
|
insertion = `\`${text.substring(start, end) || 'code'}\``;
|
|
cursorOffset = 1;
|
|
break;
|
|
case 'quote':
|
|
insertion = `> ${text.substring(start, end) || 'quote text'}`;
|
|
cursorOffset = 2;
|
|
break;
|
|
case 'table':
|
|
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;
|
|
}
|
|
|
|
const newText = text.substring(0, start) + insertion + text.substring(end);
|
|
setMarkdownContent(newText);
|
|
|
|
// Set cursor position and select the placeholder text for easy editing
|
|
setTimeout(() => {
|
|
textarea.focus();
|
|
if (type === 'h1' || type === 'h2') {
|
|
// For headings, select the placeholder text so user can type directly
|
|
const placeholderStart = start + (type === 'h1' ? 2 : 3);
|
|
const placeholderEnd = start + insertion.length;
|
|
textarea.setSelectionRange(placeholderStart, placeholderEnd);
|
|
} else {
|
|
// For other elements, position cursor appropriately
|
|
textarea.setSelectionRange(start + insertion.length - cursorOffset, start + insertion.length - cursorOffset);
|
|
}
|
|
}, 0);
|
|
};
|
|
|
|
const handleProjectImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = e.target.files;
|
|
if (!files || files.length === 0) return;
|
|
|
|
const file = files[0];
|
|
if (file) {
|
|
// Simulate image upload - in production you'd upload to a real service
|
|
const imageUrl = URL.createObjectURL(file);
|
|
setFormData(prev => ({ ...prev, imageUrl }));
|
|
}
|
|
};
|
|
|
|
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = e.target.files;
|
|
if (!files || files.length === 0) return;
|
|
|
|
const file = files[0];
|
|
if (file) {
|
|
// Create a more descriptive image URL for better organization
|
|
const imageName = file.name.replace(/\.[^/.]+$/, ""); // Remove file extension
|
|
const imageUrl = URL.createObjectURL(file);
|
|
|
|
const textarea = document.getElementById('markdown-editor') as HTMLTextAreaElement;
|
|
if (!textarea) return;
|
|
|
|
const start = textarea.selectionStart;
|
|
const text = textarea.value;
|
|
|
|
// Insert image with better alt text and a newline for spacing
|
|
const insertion = `\n\n`;
|
|
|
|
const newText = text.substring(0, start) + insertion + text.substring(start);
|
|
setMarkdownContent(newText);
|
|
|
|
// Focus back to textarea and position cursor after the image
|
|
setTimeout(() => {
|
|
textarea.focus();
|
|
const newCursorPos = start + insertion.length;
|
|
textarea.setSelectionRange(newCursorPos, newCursorPos);
|
|
}, 0);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen animated-bg">
|
|
<div className="max-w-7xl mx-auto px-4 py-20">
|
|
{/* Header */}
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 30 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.8 }}
|
|
className="mb-12"
|
|
>
|
|
<Link
|
|
href="/"
|
|
className="inline-flex items-center space-x-2 text-blue-400 hover:text-blue-300 transition-colors mb-6"
|
|
>
|
|
<ArrowLeft size={20} />
|
|
<span>Back to Home</span>
|
|
</Link>
|
|
|
|
<h1 className="text-5xl md:text-6xl font-bold mb-6 gradient-text">
|
|
Admin Dashboard
|
|
</h1>
|
|
<p className="text-xl text-gray-400 max-w-3xl">
|
|
Manage your projects with the built-in Markdown editor. Create, edit, and preview your content easily.
|
|
</p>
|
|
</motion.div>
|
|
|
|
{/* Projects Toggle Button - Always Visible */}
|
|
<div className="flex justify-center mb-6">
|
|
<motion.button
|
|
onClick={() => setIsProjectsCollapsed(!isProjectsCollapsed)}
|
|
className="flex items-center space-x-2 px-6 py-3 bg-gradient-to-r from-gray-700 to-gray-800 hover:from-gray-600 hover:to-gray-700 rounded-xl text-white transition-all duration-200 hover:scale-105 border border-gray-600/50 shadow-lg"
|
|
title={isProjectsCollapsed ? "Show Projects" : "Hide Projects"}
|
|
whileHover={{ scale: 1.05 }}
|
|
whileTap={{ scale: 0.95 }}
|
|
>
|
|
{isProjectsCollapsed ? (
|
|
<>
|
|
<ChevronRight size={20} />
|
|
<span>Show Projects</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<ChevronDown size={20} />
|
|
<span>Hide Projects</span>
|
|
</>
|
|
)}
|
|
</motion.button>
|
|
</div>
|
|
|
|
<div className={`grid gap-8 ${isProjectsCollapsed ? 'grid-cols-1' : 'grid-cols-1 lg:grid-cols-3'}`}>
|
|
{/* Projects List */}
|
|
<div className={`${isProjectsCollapsed ? 'hidden' : 'lg:col-span-1'}`}>
|
|
<motion.div
|
|
initial={{ opacity: 0, x: -30 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
transition={{ duration: 0.8, delay: 0.2 }}
|
|
className="glass-card p-6 rounded-2xl"
|
|
>
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h2 className="text-2xl font-bold text-white">Projects</h2>
|
|
<button
|
|
onClick={resetForm}
|
|
className="p-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-white transition-colors"
|
|
>
|
|
<Plus size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
{projects.map((project) => (
|
|
<div
|
|
key={project.id}
|
|
className={`p-3 rounded-lg cursor-pointer transition-all ${
|
|
selectedProject?.id === project.id
|
|
? 'bg-blue-600/20 border border-blue-500/50'
|
|
: 'bg-gray-800/30 hover:bg-gray-700/30'
|
|
}`}
|
|
onClick={() => handleEdit(project)}
|
|
>
|
|
<h3 className="font-medium text-white mb-1">{project.title}</h3>
|
|
<p className="text-sm text-gray-400">{project.description}</p>
|
|
<div className="flex items-center justify-between mt-2">
|
|
<span className="text-xs text-gray-500">{project.category}</span>
|
|
<div className="flex space-x-2">
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleEdit(project);
|
|
}}
|
|
className="p-1 text-gray-400 hover:text-blue-400 transition-colors"
|
|
>
|
|
<Edit size={16} />
|
|
</button>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleDelete(project.id);
|
|
}}
|
|
className="p-1 text-gray-400 hover:text-red-400 transition-colors"
|
|
>
|
|
<Trash2 size={16} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</motion.div>
|
|
</div>
|
|
|
|
{/* Editor */}
|
|
<div className={`${isProjectsCollapsed ? 'lg:col-span-1' : 'lg:col-span-2'}`}>
|
|
<motion.div
|
|
initial={{ opacity: 0, x: 30 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
transition={{ duration: 0.8, delay: 0.4 }}
|
|
className="glass-card p-6 rounded-2xl"
|
|
>
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h2 className="text-2xl font-bold text-white">
|
|
{selectedProject ? 'Edit Project' : 'New Project'}
|
|
</h2>
|
|
<div className="flex space-x-3">
|
|
<button
|
|
onClick={() => setIsPreview(!isPreview)}
|
|
className={`px-4 py-2 rounded-lg transition-colors ${
|
|
isPreview
|
|
? 'bg-blue-600 text-white'
|
|
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
|
|
}`}
|
|
title="Toggle Preview"
|
|
>
|
|
<Eye size={20} />
|
|
</button>
|
|
<button
|
|
onClick={handleSave}
|
|
className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors flex items-center space-x-2"
|
|
title="Save Project"
|
|
>
|
|
<Save size={20} />
|
|
<span>Save</span>
|
|
</button>
|
|
{selectedProject && (
|
|
<button
|
|
onClick={() => {
|
|
setSelectedProject(null);
|
|
resetForm();
|
|
}}
|
|
className="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg transition-colors"
|
|
title="Cancel Edit"
|
|
>
|
|
Cancel
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{!isPreview ? (
|
|
<div className="space-y-6">
|
|
{/* Basic Info */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
|
Title
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={formData.title}
|
|
onChange={(e) => setFormData({...formData, title: 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="Project title"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
|
Category
|
|
</label>
|
|
<select
|
|
value={formData.category}
|
|
onChange={(e) => setFormData({...formData, category: 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"
|
|
>
|
|
<option value="">Select category</option>
|
|
{categories.map(cat => (
|
|
<option key={cat} value={cat}>{cat}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Links */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
|
GitHub URL (optional)
|
|
</label>
|
|
<input
|
|
type="url"
|
|
value={formData.github || ''}
|
|
onChange={(e) => setFormData({...formData, github: 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://github.com/username/repo"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
|
Live Demo URL (optional)
|
|
</label>
|
|
<input
|
|
type="url"
|
|
value={formData.live || ''}
|
|
onChange={(e) => setFormData({...formData, live: 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://demo.example.com"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Project Image */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
|
Project Image (optional)
|
|
</label>
|
|
<div className="flex items-center space-x-4">
|
|
<div className="w-24 h-24 bg-gradient-to-br from-gray-700 to-gray-800 rounded-xl border-2 border-dashed border-gray-600 flex items-center justify-center overflow-hidden">
|
|
{formData.imageUrl ? (
|
|
<img
|
|
src={formData.imageUrl}
|
|
alt="Project preview"
|
|
className="w-full h-full object-cover rounded-lg"
|
|
/>
|
|
) : (
|
|
<div className="text-center">
|
|
<span className="text-2xl font-bold text-white">
|
|
{formData.title ? formData.title.split(' ').map(word => word[0]).join('').toUpperCase() : 'P'}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex-1">
|
|
<input
|
|
type="file"
|
|
accept="image/*"
|
|
onChange={handleProjectImageUpload}
|
|
className="hidden"
|
|
id="project-image-upload"
|
|
/>
|
|
<label
|
|
htmlFor="project-image-upload"
|
|
className="inline-flex items-center px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-white cursor-pointer transition-colors"
|
|
>
|
|
<Upload size={16} className="mr-2" />
|
|
Choose Image
|
|
</label>
|
|
<p className="text-sm text-gray-400 mt-1">Upload a project image or use auto-generated initials</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
|
Description
|
|
</label>
|
|
<textarea
|
|
value={formData.description}
|
|
onChange={(e) => setFormData({...formData, description: 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="Brief project description"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
|
Tags (comma-separated)
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={formData.tags}
|
|
onChange={(e) => setFormData({...formData, tags: 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="Next.js, TypeScript, Tailwind CSS"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<label className="flex items-center space-x-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={formData.featured}
|
|
onChange={(e) => setFormData({...formData, featured: 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">Featured Project</span>
|
|
</label>
|
|
|
|
<label className="flex items-center space-x-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={formData.published}
|
|
onChange={(e) => setFormData({...formData, published: e.target.checked})}
|
|
className="w-4 h-4 text-green-600 bg-gray-800 border-gray-700 rounded focus:ring-green-500 focus:ring-2"
|
|
/>
|
|
<span className="text-sm text-gray-300">Published</span>
|
|
</label>
|
|
</div>
|
|
|
|
{/* Markdown Editor with Live Preview */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
|
Content (Markdown)
|
|
</label>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
{/* Editor */}
|
|
<div className="space-y-4">
|
|
{/* Image Upload - Moved to top */}
|
|
<div className="bg-gradient-to-r from-gray-800/30 to-gray-700/30 p-4 rounded-xl border border-gray-600/30 mb-4">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<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>
|
|
<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
|
|
</p>
|
|
</div>
|
|
|
|
{/* Enhanced Toolbar */}
|
|
<div className="bg-gradient-to-r from-gray-800/30 to-gray-700/30 p-4 rounded-xl border border-gray-600/30">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<span className="text-sm font-medium text-gray-300">Quick Actions</span>
|
|
<span className="text-xs text-gray-500">Click to insert</span>
|
|
</div>
|
|
|
|
{/* Text Formatting */}
|
|
<div className="mb-4">
|
|
<div className="text-xs text-gray-500 mb-2 uppercase tracking-wide">Text Formatting</div>
|
|
<div className="grid grid-cols-6 gap-2">
|
|
<button
|
|
onClick={() => insertMarkdown('h1')}
|
|
className="p-3 bg-gradient-to-br from-blue-600/50 to-blue-700/50 hover:from-blue-500/60 hover:to-blue-600/60 rounded-lg text-white transition-all duration-200 hover:scale-105 border border-blue-500/50 shadow-lg"
|
|
title="Heading 1"
|
|
>
|
|
<span className="text-sm font-bold">H1</span>
|
|
</button>
|
|
<button
|
|
onClick={() => insertMarkdown('h2')}
|
|
className="p-3 bg-gradient-to-br from-purple-600/50 to-purple-700/50 hover:from-purple-500/60 hover:to-purple-600/60 rounded-lg text-white transition-all duration-200 hover:scale-105 border border-purple-500/50 shadow-lg"
|
|
title="Heading 2"
|
|
>
|
|
<span className="text-sm font-bold">H2</span>
|
|
</button>
|
|
<button
|
|
onClick={() => insertMarkdown('bold')}
|
|
className="p-3 bg-gradient-to-br from-gray-700/50 to-gray-800/50 hover:from-gray-600/60 hover:to-gray-700/60 rounded-lg text-gray-300 hover:text-white transition-all duration-200 hover:scale-105 border border-gray-600/50 shadow-lg"
|
|
title="Bold"
|
|
>
|
|
<Bold size={16} />
|
|
</button>
|
|
<button
|
|
onClick={() => insertMarkdown('italic')}
|
|
className="p-3 bg-gradient-to-br from-gray-700/50 to-gray-800/50 hover:from-gray-600/60 hover:to-gray-700/60 rounded-lg text-gray-300 hover:text-white transition-all duration-200 hover:scale-105 border border-gray-600/50 shadow-lg"
|
|
title="Italic"
|
|
>
|
|
<Italic size={16} />
|
|
</button>
|
|
<button
|
|
onClick={() => insertMarkdown('code')}
|
|
className="p-3 bg-gradient-to-br from-gray-700/50 to-gray-800/50 hover:from-gray-600/60 hover:to-gray-700/60 rounded-lg text-gray-300 hover:text-white transition-all duration-200 hover:scale-105 border border-gray-600/50 shadow-lg"
|
|
title="Inline Code"
|
|
>
|
|
<Code size={16} />
|
|
</button>
|
|
<button
|
|
onClick={() => insertMarkdown('quote')}
|
|
className="p-3 bg-gradient-to-br from-gray-700/50 to-gray-800/50 hover:from-gray-600/60 hover:to-gray-700/60 rounded-lg text-gray-300 hover:text-white transition-all duration-200 hover:scale-105 border border-gray-600/50 shadow-lg"
|
|
title="Quote"
|
|
>
|
|
<Quote size={16} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 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">
|
|
<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"
|
|
title="List Item"
|
|
>
|
|
<List size={16} />
|
|
</button>
|
|
<button
|
|
onClick={() => insertMarkdown('link')}
|
|
className="p-3 bg-gradient-to-br from-blue-600/50 to-blue-700/50 hover:from-blue-500/60 hover:to-blue-600/60 rounded-lg text-white transition-all duration-200 hover:scale-105 border border-blue-500/50 shadow-lg"
|
|
title="Link"
|
|
>
|
|
<LinkIcon size={16} />
|
|
</button>
|
|
<button
|
|
onClick={() => insertMarkdown('image')}
|
|
className="p-3 bg-gradient-to-br from-purple-600/50 to-purple-700/50 hover:from-purple-500/60 hover:to-purple-600/60 rounded-lg text-white transition-all duration-200 hover:scale-105 border border-purple-500/50 shadow-lg"
|
|
title="Image"
|
|
>
|
|
<ImageIcon size={16} />
|
|
</button>
|
|
<button
|
|
onClick={() => insertMarkdown('table')}
|
|
className="p-3 bg-gradient-to-br from-orange-600/50 to-orange-700/50 hover:from-orange-500/60 hover:to-orange-600/60 rounded-lg text-white transition-all duration-200 hover:scale-105 border border-orange-500/50 shadow-lg"
|
|
title="Table"
|
|
>
|
|
<span className="text-sm font-bold">📊</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Enhanced Textarea */}
|
|
<div className="relative">
|
|
<textarea
|
|
id="markdown-editor"
|
|
value={markdownContent}
|
|
onChange={(e) => setMarkdownContent(e.target.value)}
|
|
rows={20}
|
|
className="w-full px-6 py-4 bg-gray-800/50 border border-gray-600/50 rounded-xl text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500/50 resize-none font-mono text-sm leading-relaxed shadow-lg"
|
|
placeholder="✨ Write your project content in Markdown... # Start with a heading ## Add subheadings - Create lists - Add **bold** and *italic* text - Include [links](url) and  - Use `code` and code blocks"
|
|
/>
|
|
<div className="absolute top-4 right-4 text-xs text-gray-500 font-mono">
|
|
{markdownContent.length} chars
|
|
</div>
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
{/* Enhanced Live Preview */}
|
|
<div className="space-y-4 h-full flex flex-col">
|
|
<div className="flex items-center justify-between">
|
|
<div className="text-sm font-medium text-gray-300">Live Preview</div>
|
|
<div className="flex items-center space-x-2 text-xs text-gray-500">
|
|
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
|
|
<span>Real-time rendering</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto p-6 bg-gradient-to-br from-gray-800/40 to-gray-700/40 rounded-xl border border-gray-600/50 shadow-lg min-h-[32rem]">
|
|
<div className="markdown prose prose-invert max-w-none text-white">
|
|
{markdownContent ? (
|
|
<ReactMarkdown
|
|
components={{
|
|
h1: ({children}) => <h1 className="text-3xl font-bold text-white mb-4 bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent">{children}</h1>,
|
|
h2: ({children}) => <h2 className="text-2xl font-semibold text-white mb-3 border-l-4 border-blue-500 pl-3">{children}</h2>,
|
|
h3: ({children}) => <h3 className="text-xl font-semibold text-white mb-2">{children}</h3>,
|
|
p: ({children}) => <p className="text-gray-300 mb-3 leading-relaxed">{children}</p>,
|
|
ul: ({children}) => <ul className="list-disc list-inside text-gray-300 mb-3 space-y-1 marker:text-blue-400">{children}</ul>,
|
|
ol: ({children}) => <ol className="list-decimal list-inside text-gray-300 mb-3 space-y-1 marker:text-purple-400">{children}</ol>,
|
|
li: ({children}) => <li className="text-gray-300">{children}</li>,
|
|
a: ({href, children}) => (
|
|
<a href={href} className="text-blue-400 hover:text-blue-300 underline transition-colors decoration-2 underline-offset-2" target="_blank" rel="noopener noreferrer">
|
|
{children}
|
|
</a>
|
|
),
|
|
code: ({children}) => <code className="bg-gray-700/80 text-blue-400 px-2 py-1 rounded-md text-sm font-mono border border-gray-600/50">{children}</code>,
|
|
pre: ({children}) => <pre className="bg-gray-800/80 p-4 rounded-lg overflow-x-auto mb-3 border border-gray-600/50 shadow-inner">{children}</pre>,
|
|
blockquote: ({children}) => <blockquote className="border-l-4 border-blue-500 pl-4 italic text-gray-300 mb-3 bg-blue-500/10 py-2 rounded-r-lg">{children}</blockquote>,
|
|
strong: ({children}) => <strong className="font-semibold text-white">{children}</strong>,
|
|
em: ({children}) => <em className="italic text-gray-300">{children}</em>
|
|
}}
|
|
>
|
|
{markdownContent}
|
|
</ReactMarkdown>
|
|
) : (
|
|
<div className="text-center text-gray-500 py-20">
|
|
<div className="text-6xl mb-4">✨</div>
|
|
<p className="text-lg font-medium">Start writing to see the preview</p>
|
|
<p className="text-sm">Your Markdown will appear here in real-time</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
/* Preview */
|
|
<div className="prose prose-invert max-w-none">
|
|
<div className="markdown" dangerouslySetInnerHTML={{ __html: markdownContent }} />
|
|
</div>
|
|
)}
|
|
</motion.div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default AdminPage;
|