Files
portfolio/components/GhostEditor.tsx
2025-09-10 10:59:14 +02:00

733 lines
29 KiB
TypeScript

'use client';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Save,
X,
Eye,
Settings,
Globe,
Github,
Image as ImageIcon,
Bold,
Italic,
List,
Quote,
Code,
Link2,
ListOrdered,
Underline,
Strikethrough,
Type,
Columns
} 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: Partial<Project>) => 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 = `![${selection || 'alt text'}](image-url)`;
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
const 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)
![Images](image-url)"
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>
);
};