✅ Updated Middleware Logic: - Enhanced admin route protection with Basic Auth for legacy routes and session-based auth for `/manage` and `/editor`. ✅ Improved Admin Panel Styles: - Added glassmorphism styles for admin components to enhance UI aesthetics. ✅ Refined Rate Limiting: - Adjusted rate limits for admin dashboard requests to allow more generous access. ✅ Introduced Analytics Reset API: - Added a new endpoint for resetting analytics data with rate limiting and admin authentication. 🎯 Overall Improvements: - Strengthened security and user experience for admin functionalities. - Enhanced visual design for better usability. - Streamlined analytics management processes.
749 lines
30 KiB
TypeScript
749 lines
30 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import {
|
|
Save,
|
|
X,
|
|
Eye,
|
|
EyeOff,
|
|
Settings,
|
|
Link as LinkIcon,
|
|
Tag,
|
|
Calendar,
|
|
Globe,
|
|
Github,
|
|
Image as ImageIcon,
|
|
Bold,
|
|
Italic,
|
|
List,
|
|
Hash,
|
|
Quote,
|
|
Code,
|
|
Zap,
|
|
Type,
|
|
Columns,
|
|
PanelLeft,
|
|
PanelRight,
|
|
Monitor,
|
|
Smartphone,
|
|
Tablet,
|
|
Undo,
|
|
Redo,
|
|
AlignLeft,
|
|
AlignCenter,
|
|
AlignRight,
|
|
Link2,
|
|
ListOrdered,
|
|
Underline,
|
|
Strikethrough
|
|
} from 'lucide-react';
|
|
|
|
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;
|
|
}
|
|
|
|
interface GhostEditorProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
project?: Project | null;
|
|
onSave: (projectData: any) => void;
|
|
isCreating: boolean;
|
|
}
|
|
|
|
export const GhostEditor: React.FC<GhostEditorProps> = ({
|
|
isOpen,
|
|
onClose,
|
|
project,
|
|
onSave,
|
|
isCreating
|
|
}) => {
|
|
const [title, setTitle] = useState('');
|
|
const [description, setDescription] = useState('');
|
|
const [content, setContent] = useState('');
|
|
const [category, setCategory] = useState('Web Development');
|
|
const [tags, setTags] = useState<string[]>([]);
|
|
const [github, setGithub] = useState('');
|
|
const [live, setLive] = useState('');
|
|
const [featured, setFeatured] = useState(false);
|
|
const [published, setPublished] = useState(false);
|
|
const [difficulty, setDifficulty] = useState('Intermediate');
|
|
|
|
// Editor UI state
|
|
const [viewMode, setViewMode] = useState<'edit' | 'preview' | 'split'>('split');
|
|
const [showSettings, setShowSettings] = useState(false);
|
|
const [wordCount, setWordCount] = useState(0);
|
|
const [readingTime, setReadingTime] = useState(0);
|
|
|
|
const titleRef = useRef<HTMLTextAreaElement>(null);
|
|
const contentRef = useRef<HTMLTextAreaElement>(null);
|
|
const previewRef = useRef<HTMLDivElement>(null);
|
|
|
|
const categories = ['Web Development', 'Full-Stack', 'Web Application', 'Mobile App', 'Design'];
|
|
const difficulties = ['Beginner', 'Intermediate', 'Advanced', 'Expert'];
|
|
|
|
useEffect(() => {
|
|
if (project && !isCreating) {
|
|
setTitle(project.title);
|
|
setDescription(project.description);
|
|
setContent(project.content || '');
|
|
setCategory(project.category);
|
|
setTags(project.tags || []);
|
|
setGithub(project.github || '');
|
|
setLive(project.live || '');
|
|
setFeatured(project.featured);
|
|
setPublished(project.published);
|
|
setDifficulty(project.difficulty || 'Intermediate');
|
|
} else {
|
|
// Reset for new project
|
|
setTitle('');
|
|
setDescription('');
|
|
setContent('');
|
|
setCategory('Web Development');
|
|
setTags([]);
|
|
setGithub('');
|
|
setLive('');
|
|
setFeatured(false);
|
|
setPublished(false);
|
|
setDifficulty('Intermediate');
|
|
}
|
|
}, [project, isCreating, isOpen]);
|
|
|
|
// Calculate word count and reading time
|
|
useEffect(() => {
|
|
const words = content.trim().split(/\s+/).filter(word => word.length > 0).length;
|
|
setWordCount(words);
|
|
setReadingTime(Math.ceil(words / 200)); // Average reading speed: 200 words/minute
|
|
}, [content]);
|
|
|
|
const handleSave = () => {
|
|
const projectData = {
|
|
title,
|
|
description,
|
|
content,
|
|
category,
|
|
tags,
|
|
github,
|
|
live,
|
|
featured,
|
|
published,
|
|
difficulty
|
|
};
|
|
onSave(projectData);
|
|
};
|
|
|
|
const addTag = (tag: string) => {
|
|
if (tag.trim() && !tags.includes(tag.trim())) {
|
|
setTags([...tags, tag.trim()]);
|
|
}
|
|
};
|
|
|
|
const removeTag = (tagToRemove: string) => {
|
|
setTags(tags.filter(tag => tag !== tagToRemove));
|
|
};
|
|
|
|
const insertMarkdown = useCallback((syntax: string, selectedText: string = '') => {
|
|
if (!contentRef.current) return;
|
|
|
|
const textarea = contentRef.current;
|
|
const start = textarea.selectionStart;
|
|
const end = textarea.selectionEnd;
|
|
const selection = selectedText || content.substring(start, end);
|
|
|
|
let newText = '';
|
|
let cursorOffset = 0;
|
|
|
|
switch (syntax) {
|
|
case 'bold':
|
|
newText = `**${selection || 'bold text'}**`;
|
|
cursorOffset = selection ? newText.length : 2;
|
|
break;
|
|
case 'italic':
|
|
newText = `*${selection || 'italic text'}*`;
|
|
cursorOffset = selection ? newText.length : 1;
|
|
break;
|
|
case 'underline':
|
|
newText = `<u>${selection || 'underlined text'}</u>`;
|
|
cursorOffset = selection ? newText.length : 3;
|
|
break;
|
|
case 'strikethrough':
|
|
newText = `~~${selection || 'strikethrough text'}~~`;
|
|
cursorOffset = selection ? newText.length : 2;
|
|
break;
|
|
case 'heading1':
|
|
newText = `# ${selection || 'Heading 1'}`;
|
|
cursorOffset = selection ? newText.length : 2;
|
|
break;
|
|
case 'heading2':
|
|
newText = `## ${selection || 'Heading 2'}`;
|
|
cursorOffset = selection ? newText.length : 3;
|
|
break;
|
|
case 'heading3':
|
|
newText = `### ${selection || 'Heading 3'}`;
|
|
cursorOffset = selection ? newText.length : 4;
|
|
break;
|
|
case 'list':
|
|
newText = `- ${selection || 'List item'}`;
|
|
cursorOffset = selection ? newText.length : 2;
|
|
break;
|
|
case 'list-ordered':
|
|
newText = `1. ${selection || 'List item'}`;
|
|
cursorOffset = selection ? newText.length : 3;
|
|
break;
|
|
case 'quote':
|
|
newText = `> ${selection || 'Quote'}`;
|
|
cursorOffset = selection ? newText.length : 2;
|
|
break;
|
|
case 'code':
|
|
if (selection.includes('\n')) {
|
|
newText = `\`\`\`\n${selection || 'code block'}\n\`\`\``;
|
|
cursorOffset = selection ? newText.length : 4;
|
|
} else {
|
|
newText = `\`${selection || 'code'}\``;
|
|
cursorOffset = selection ? newText.length : 1;
|
|
}
|
|
break;
|
|
case 'link':
|
|
newText = `[${selection || 'link text'}](url)`;
|
|
cursorOffset = selection ? newText.length - 4 : newText.length - 4;
|
|
break;
|
|
case 'image':
|
|
newText = ``;
|
|
cursorOffset = selection ? newText.length - 11 : newText.length - 11;
|
|
break;
|
|
case 'divider':
|
|
newText = '\n---\n';
|
|
cursorOffset = newText.length;
|
|
break;
|
|
default:
|
|
return;
|
|
}
|
|
|
|
const newContent = content.substring(0, start) + newText + content.substring(end);
|
|
setContent(newContent);
|
|
|
|
// Focus and set cursor position
|
|
setTimeout(() => {
|
|
textarea.focus();
|
|
const newPosition = start + cursorOffset;
|
|
textarea.setSelectionRange(newPosition, newPosition);
|
|
}, 0);
|
|
}, [content]);
|
|
|
|
const autoResizeTextarea = (element: HTMLTextAreaElement) => {
|
|
element.style.height = 'auto';
|
|
element.style.height = element.scrollHeight + 'px';
|
|
};
|
|
|
|
// Render markdown preview
|
|
const renderMarkdownPreview = (markdown: string) => {
|
|
// Simple markdown renderer for preview
|
|
let html = markdown
|
|
// Headers
|
|
.replace(/^### (.*$)/gim, '<h3 class="text-xl font-semibold text-white mb-3 mt-6">$1</h3>')
|
|
.replace(/^## (.*$)/gim, '<h2 class="text-2xl font-bold text-white mb-4 mt-8">$1</h2>')
|
|
.replace(/^# (.*$)/gim, '<h1 class="text-3xl font-bold text-white mb-6 mt-8">$1</h1>')
|
|
// Bold and Italic
|
|
.replace(/\*\*(.*?)\*\*/g, '<strong class="font-bold">$1</strong>')
|
|
.replace(/\*(.*?)\*/g, '<em class="italic">$1</em>')
|
|
// Underline and Strikethrough
|
|
.replace(/<u>(.*?)<\/u>/g, '<u class="underline">$1</u>')
|
|
.replace(/~~(.*?)~~/g, '<del class="line-through opacity-75">$1</del>')
|
|
// Code
|
|
.replace(/```([^`]+)```/g, '<pre class="bg-gray-800 border border-gray-700 rounded-lg p-4 my-4 overflow-x-auto"><code class="text-green-400 font-mono text-sm">$1</code></pre>')
|
|
.replace(/`([^`]+)`/g, '<code class="bg-gray-800 border border-gray-700 rounded px-2 py-1 font-mono text-sm text-green-400">$1</code>')
|
|
// Lists
|
|
.replace(/^\- (.*$)/gim, '<li class="ml-4 mb-1">• $1</li>')
|
|
.replace(/^\d+\. (.*$)/gim, '<li class="ml-4 mb-1 list-decimal">$1</li>')
|
|
// Links
|
|
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" class="text-blue-400 hover:text-blue-300 underline" target="_blank">$1</a>')
|
|
// Images
|
|
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" class="max-w-full h-auto rounded-lg my-4" />')
|
|
// Quotes
|
|
.replace(/^> (.*$)/gim, '<blockquote class="border-l-4 border-blue-500 pl-4 py-2 my-4 bg-gray-800/50 italic text-gray-300">$1</blockquote>')
|
|
// Dividers
|
|
.replace(/^---$/gim, '<hr class="border-gray-600 my-8" />')
|
|
// Paragraphs
|
|
.replace(/\n\n/g, '</p><p class="mb-4 text-gray-200 leading-relaxed">')
|
|
.replace(/\n/g, '<br />');
|
|
|
|
return `<div class="prose prose-invert max-w-none"><p class="mb-4 text-gray-200 leading-relaxed">${html}</p></div>`;
|
|
};
|
|
|
|
if (!isOpen) return null;
|
|
|
|
return (
|
|
<AnimatePresence>
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
className="fixed inset-0 bg-black/95 backdrop-blur-sm z-50"
|
|
>
|
|
{/* Professional Ghost Editor */}
|
|
<div className="h-full flex flex-col bg-gray-900">
|
|
{/* Top Navigation Bar */}
|
|
<div className="flex items-center justify-between p-4 border-b border-gray-700 bg-gray-800">
|
|
<div className="flex items-center space-x-4">
|
|
<button
|
|
onClick={onClose}
|
|
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
|
|
<div className="flex items-center space-x-2">
|
|
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
|
<span className="text-sm font-medium text-white">
|
|
{isCreating ? 'New Project' : 'Editing Project'}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-2">
|
|
{published ? (
|
|
<span className="px-3 py-1 bg-green-600 text-white rounded-full text-sm font-medium">
|
|
Published
|
|
</span>
|
|
) : (
|
|
<span className="px-3 py-1 bg-gray-600 text-gray-300 rounded-full text-sm font-medium">
|
|
Draft
|
|
</span>
|
|
)}
|
|
{featured && (
|
|
<span className="px-3 py-1 bg-purple-600 text-white rounded-full text-sm font-medium">
|
|
Featured
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* View Mode Toggle */}
|
|
<div className="flex items-center space-x-2">
|
|
<div className="flex items-center bg-gray-700 rounded-lg p-1">
|
|
<button
|
|
onClick={() => setViewMode('edit')}
|
|
className={`p-2 rounded transition-colors ${
|
|
viewMode === 'edit' ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-white'
|
|
}`}
|
|
title="Edit Mode"
|
|
>
|
|
<Type className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => setViewMode('split')}
|
|
className={`p-2 rounded transition-colors ${
|
|
viewMode === 'split' ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-white'
|
|
}`}
|
|
title="Split View"
|
|
>
|
|
<Columns className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => setViewMode('preview')}
|
|
className={`p-2 rounded transition-colors ${
|
|
viewMode === 'preview' ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-white'
|
|
}`}
|
|
title="Preview Mode"
|
|
>
|
|
<Eye className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<button
|
|
onClick={() => setShowSettings(!showSettings)}
|
|
className={`p-2 rounded-lg transition-colors ${
|
|
showSettings ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-white hover:bg-gray-700'
|
|
}`}
|
|
>
|
|
<Settings className="w-5 h-5" />
|
|
</button>
|
|
|
|
<button
|
|
onClick={handleSave}
|
|
className="flex items-center space-x-2 px-6 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors font-medium"
|
|
>
|
|
<Save className="w-4 h-4" />
|
|
<span>Save</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Rich Text Toolbar */}
|
|
<div className="flex items-center justify-between p-3 border-b border-gray-700 bg-gray-800/50">
|
|
<div className="flex items-center space-x-1">
|
|
{/* Text Formatting */}
|
|
<div className="flex items-center space-x-1 pr-2 border-r border-gray-600">
|
|
<button
|
|
onClick={() => insertMarkdown('bold')}
|
|
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
|
|
title="Bold (Ctrl+B)"
|
|
>
|
|
<Bold className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => insertMarkdown('italic')}
|
|
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
|
|
title="Italic (Ctrl+I)"
|
|
>
|
|
<Italic className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => insertMarkdown('underline')}
|
|
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
|
|
title="Underline"
|
|
>
|
|
<Underline className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => insertMarkdown('strikethrough')}
|
|
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
|
|
title="Strikethrough"
|
|
>
|
|
<Strikethrough className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Headers */}
|
|
<div className="flex items-center space-x-1 px-2 border-r border-gray-600">
|
|
<button
|
|
onClick={() => insertMarkdown('heading1')}
|
|
className="px-2 py-1 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors text-sm font-bold"
|
|
title="Heading 1"
|
|
>
|
|
H1
|
|
</button>
|
|
<button
|
|
onClick={() => insertMarkdown('heading2')}
|
|
className="px-2 py-1 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors text-sm font-bold"
|
|
title="Heading 2"
|
|
>
|
|
H2
|
|
</button>
|
|
<button
|
|
onClick={() => insertMarkdown('heading3')}
|
|
className="px-2 py-1 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors text-sm font-bold"
|
|
title="Heading 3"
|
|
>
|
|
H3
|
|
</button>
|
|
</div>
|
|
|
|
{/* Lists */}
|
|
<div className="flex items-center space-x-1 px-2 border-r border-gray-600">
|
|
<button
|
|
onClick={() => insertMarkdown('list')}
|
|
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
|
|
title="Bullet List"
|
|
>
|
|
<List className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => insertMarkdown('list-ordered')}
|
|
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
|
|
title="Numbered List"
|
|
>
|
|
<ListOrdered className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Insert Elements */}
|
|
<div className="flex items-center space-x-1 px-2">
|
|
<button
|
|
onClick={() => insertMarkdown('link')}
|
|
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
|
|
title="Insert Link"
|
|
>
|
|
<Link2 className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => insertMarkdown('image')}
|
|
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
|
|
title="Insert Image"
|
|
>
|
|
<ImageIcon className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => insertMarkdown('code')}
|
|
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
|
|
title="Code Block"
|
|
>
|
|
<Code className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => insertMarkdown('quote')}
|
|
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
|
|
title="Quote"
|
|
>
|
|
<Quote className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stats */}
|
|
<div className="flex items-center space-x-4 text-sm text-gray-400">
|
|
<span>{wordCount} words</span>
|
|
<span>{readingTime} min read</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main Editor Area */}
|
|
<div className="flex-1 flex overflow-hidden">
|
|
{/* Content Area */}
|
|
<div className="flex-1 flex">
|
|
{/* Editor Pane */}
|
|
{(viewMode === 'edit' || viewMode === 'split') && (
|
|
<div className={`${viewMode === 'split' ? 'w-1/2' : 'w-full'} flex flex-col bg-gray-900`}>
|
|
{/* Title & Description */}
|
|
<div className="p-8 border-b border-gray-800">
|
|
<textarea
|
|
ref={titleRef}
|
|
value={title}
|
|
onChange={(e) => {
|
|
setTitle(e.target.value);
|
|
autoResizeTextarea(e.target);
|
|
}}
|
|
onInput={(e) => autoResizeTextarea(e.target as HTMLTextAreaElement)}
|
|
placeholder="Project title..."
|
|
className="w-full text-5xl font-bold text-white bg-transparent border-none outline-none placeholder-gray-500 resize-none overflow-hidden leading-tight mb-6"
|
|
rows={1}
|
|
/>
|
|
<textarea
|
|
value={description}
|
|
onChange={(e) => {
|
|
setDescription(e.target.value);
|
|
autoResizeTextarea(e.target);
|
|
}}
|
|
onInput={(e) => autoResizeTextarea(e.target as HTMLTextAreaElement)}
|
|
placeholder="Brief description of your project..."
|
|
className="w-full text-xl text-gray-300 bg-transparent border-none outline-none placeholder-gray-500 resize-none overflow-hidden leading-relaxed"
|
|
rows={1}
|
|
/>
|
|
</div>
|
|
|
|
{/* Content Editor */}
|
|
<div className="flex-1 p-8">
|
|
<textarea
|
|
ref={contentRef}
|
|
value={content}
|
|
onChange={(e) => setContent(e.target.value)}
|
|
placeholder="Start writing your story...
|
|
|
|
Use Markdown for formatting:
|
|
**Bold text** or *italic text*
|
|
# Large heading
|
|
## Medium heading
|
|
### Small heading
|
|
- Bullet points
|
|
1. Numbered lists
|
|
> Quotes
|
|
`code`
|
|
[Links](https://example.com)
|
|
"
|
|
className="w-full h-full text-lg text-white bg-transparent border-none outline-none placeholder-gray-600 resize-none font-mono leading-relaxed focus:ring-0"
|
|
style={{ minHeight: '500px' }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Preview Pane */}
|
|
{(viewMode === 'preview' || viewMode === 'split') && (
|
|
<div className={`${viewMode === 'split' ? 'w-1/2 border-l border-gray-700' : 'w-full'} bg-gray-850 overflow-y-auto`}>
|
|
<div className="p-8">
|
|
{/* Preview Header */}
|
|
<div className="mb-8 border-b border-gray-700 pb-8">
|
|
<h1 className="text-5xl font-bold text-white mb-6 leading-tight">
|
|
{title || 'Project title...'}
|
|
</h1>
|
|
<p className="text-xl text-gray-300 leading-relaxed">
|
|
{description || 'Brief description of your project...'}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Preview Content */}
|
|
<div
|
|
ref={previewRef}
|
|
className="prose prose-invert max-w-none"
|
|
dangerouslySetInnerHTML={{
|
|
__html: content ? renderMarkdownPreview(content) : '<p class="text-gray-500 italic">Start writing to see the preview...</p>'
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Settings Sidebar */}
|
|
<AnimatePresence>
|
|
{showSettings && (
|
|
<motion.div
|
|
initial={{ x: 320 }}
|
|
animate={{ x: 0 }}
|
|
exit={{ x: 320 }}
|
|
className="w-80 bg-gray-800 border-l border-gray-700 flex flex-col"
|
|
>
|
|
<div className="p-6 border-b border-gray-700">
|
|
<h3 className="text-lg font-semibold text-white">Project Settings</h3>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto p-6 space-y-8">
|
|
{/* Status */}
|
|
<div>
|
|
<h4 className="text-sm font-medium text-gray-400 uppercase tracking-wider mb-4">Publication</h4>
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-white">Published</span>
|
|
<button
|
|
onClick={() => setPublished(!published)}
|
|
className={`w-12 h-6 rounded-full transition-colors relative ${
|
|
published ? 'bg-green-600' : 'bg-gray-600'
|
|
}`}
|
|
>
|
|
<div className={`w-4 h-4 bg-white rounded-full transition-transform absolute top-1 ${
|
|
published ? 'translate-x-7' : 'translate-x-1'
|
|
}`} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-white">Featured</span>
|
|
<button
|
|
onClick={() => setFeatured(!featured)}
|
|
className={`w-12 h-6 rounded-full transition-colors relative ${
|
|
featured ? 'bg-purple-600' : 'bg-gray-600'
|
|
}`}
|
|
>
|
|
<div className={`w-4 h-4 bg-white rounded-full transition-transform absolute top-1 ${
|
|
featured ? 'translate-x-7' : 'translate-x-1'
|
|
}`} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Category & Difficulty */}
|
|
<div>
|
|
<h4 className="text-sm font-medium text-gray-400 uppercase tracking-wider mb-4">Classification</h4>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-gray-300 text-sm mb-2">Category</label>
|
|
<select
|
|
value={category}
|
|
onChange={(e) => setCategory(e.target.value)}
|
|
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
>
|
|
{categories.map(cat => (
|
|
<option key={cat} value={cat}>{cat}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-gray-300 text-sm mb-2">Difficulty</label>
|
|
<select
|
|
value={difficulty}
|
|
onChange={(e) => setDifficulty(e.target.value)}
|
|
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
>
|
|
{difficulties.map(diff => (
|
|
<option key={diff} value={diff}>{diff}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Links */}
|
|
<div>
|
|
<h4 className="text-sm font-medium text-gray-400 uppercase tracking-wider mb-4">External Links</h4>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-gray-300 text-sm mb-2">
|
|
<Github className="w-4 h-4 inline mr-1" />
|
|
GitHub Repository
|
|
</label>
|
|
<input
|
|
type="url"
|
|
value={github}
|
|
onChange={(e) => setGithub(e.target.value)}
|
|
placeholder="https://github.com/..."
|
|
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-gray-300 text-sm mb-2">
|
|
<Globe className="w-4 h-4 inline mr-1" />
|
|
Live Demo
|
|
</label>
|
|
<input
|
|
type="url"
|
|
value={live}
|
|
onChange={(e) => setLive(e.target.value)}
|
|
placeholder="https://..."
|
|
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tags */}
|
|
<div>
|
|
<h4 className="text-sm font-medium text-gray-400 uppercase tracking-wider mb-4">Tags</h4>
|
|
<div className="space-y-3">
|
|
<input
|
|
type="text"
|
|
placeholder="Add a tag and press Enter"
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
addTag(e.currentTarget.value);
|
|
e.currentTarget.value = '';
|
|
}
|
|
}}
|
|
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
/>
|
|
|
|
{tags.length > 0 && (
|
|
<div className="flex flex-wrap gap-2">
|
|
{tags.map((tag) => (
|
|
<span
|
|
key={tag}
|
|
className="inline-flex items-center space-x-1 px-3 py-1 bg-blue-600 text-white rounded-full text-sm"
|
|
>
|
|
<span>{tag}</span>
|
|
<button
|
|
onClick={() => removeTag(tag)}
|
|
className="text-blue-200 hover:text-white"
|
|
>
|
|
<X className="w-3 h-3" />
|
|
</button>
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
);
|
|
}; |