feat: enhance analytics and performance tracking with real data metrics
- Integrate real page view data from the database for accurate analytics. - Implement cache-busting for fresh data retrieval in analytics dashboard. - Calculate and display bounce rate, average session duration, and unique users. - Refactor performance metrics to ensure only real data is considered. - Improve user experience with toast notifications for success and error messages. - Update project editor with undo/redo functionality and enhanced content management.
This commit is contained in:
@@ -29,6 +29,7 @@ import {
|
||||
Github,
|
||||
Tag,
|
||||
} from "lucide-react";
|
||||
import { useToast } from "@/components/Toast";
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
@@ -50,6 +51,7 @@ function EditorPageContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const projectId = searchParams.get("id");
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const { showSuccess, showError } = useToast();
|
||||
|
||||
const [, setProject] = useState<Project | null>(null);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
@@ -58,6 +60,10 @@ function EditorPageContent() {
|
||||
const [isCreating, setIsCreating] = useState(!projectId);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
const [history, setHistory] = useState<typeof formData[]>([]);
|
||||
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||
const [originalFormData, setOriginalFormData] = useState<typeof formData | null>(null);
|
||||
const shouldUpdateContentRef = useRef(true);
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState({
|
||||
@@ -84,8 +90,7 @@ function EditorPageContent() {
|
||||
);
|
||||
|
||||
if (foundProject) {
|
||||
setProject(foundProject);
|
||||
setFormData({
|
||||
const initialData = {
|
||||
title: foundProject.title || "",
|
||||
description: foundProject.description || "",
|
||||
content: foundProject.content || "",
|
||||
@@ -96,7 +101,19 @@ function EditorPageContent() {
|
||||
github: foundProject.github || "",
|
||||
live: foundProject.live || "",
|
||||
image: foundProject.image || "",
|
||||
});
|
||||
};
|
||||
setProject(foundProject);
|
||||
setFormData(initialData);
|
||||
setOriginalFormData(initialData);
|
||||
setHistory([initialData]);
|
||||
setHistoryIndex(0);
|
||||
shouldUpdateContentRef.current = true;
|
||||
// Initialize contentEditable after state update
|
||||
setTimeout(() => {
|
||||
if (contentRef.current && initialData.content) {
|
||||
contentRef.current.textContent = initialData.content;
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
} else {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
@@ -126,6 +143,30 @@ function EditorPageContent() {
|
||||
await loadProject(projectId);
|
||||
} else {
|
||||
setIsCreating(true);
|
||||
// Initialize history for new project
|
||||
const initialData = {
|
||||
title: "",
|
||||
description: "",
|
||||
content: "",
|
||||
category: "web",
|
||||
tags: [],
|
||||
featured: false,
|
||||
published: false,
|
||||
github: "",
|
||||
live: "",
|
||||
image: "",
|
||||
};
|
||||
setFormData(initialData);
|
||||
setOriginalFormData(initialData);
|
||||
setHistory([initialData]);
|
||||
setHistoryIndex(0);
|
||||
shouldUpdateContentRef.current = true;
|
||||
// Initialize contentEditable after state update
|
||||
setTimeout(() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.textContent = "";
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
} else {
|
||||
setIsAuthenticated(false);
|
||||
@@ -143,18 +184,20 @@ function EditorPageContent() {
|
||||
init();
|
||||
}, [projectId, loadProject]);
|
||||
|
||||
const handleSave = async () => {
|
||||
const handleSave = useCallback(async () => {
|
||||
try {
|
||||
setIsSaving(true);
|
||||
|
||||
// Validate required fields
|
||||
if (!formData.title.trim()) {
|
||||
alert("Please enter a project title");
|
||||
showError("Validation Error", "Please enter a project title");
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.description.trim()) {
|
||||
alert("Please enter a project description");
|
||||
showError("Validation Error", "Please enter a project description");
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -205,40 +248,156 @@ function EditorPageContent() {
|
||||
image: savedProject.imageUrl || "",
|
||||
}));
|
||||
|
||||
// Show success and redirect
|
||||
alert("Project saved successfully!");
|
||||
setTimeout(() => {
|
||||
window.location.href = "/manage";
|
||||
}, 1000);
|
||||
// Show success toast (smaller, smoother)
|
||||
showSuccess("Saved", `"${savedProject.title}" saved`);
|
||||
|
||||
// Update project ID if it was a new project
|
||||
if (!projectId && savedProject.id) {
|
||||
const newUrl = `/editor?id=${savedProject.id}`;
|
||||
window.history.replaceState({}, '', newUrl);
|
||||
}
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.error("Error saving project:", response.status, errorData);
|
||||
}
|
||||
alert(`Error saving project: ${errorData.error || "Unknown error"}`);
|
||||
showError("Save Failed", errorData.error || "Failed to save");
|
||||
}
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.error("Error saving project:", error);
|
||||
}
|
||||
alert(
|
||||
`Error saving project: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
showError(
|
||||
"Save Failed",
|
||||
error instanceof Error ? error.message : "Failed to save"
|
||||
);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
}, [projectId, formData, showSuccess, showError]);
|
||||
|
||||
const handleInputChange = (
|
||||
field: string,
|
||||
value: string | boolean | string[],
|
||||
) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
setFormData((prev) => {
|
||||
const newData = {
|
||||
...prev,
|
||||
[field]: value,
|
||||
};
|
||||
// Add to history for undo/redo
|
||||
setHistory((hist) => {
|
||||
const newHistory = hist.slice(0, historyIndex + 1);
|
||||
newHistory.push(newData);
|
||||
// Keep only last 50 history entries
|
||||
const trimmedHistory = newHistory.slice(-50);
|
||||
setHistoryIndex(trimmedHistory.length - 1);
|
||||
return trimmedHistory;
|
||||
});
|
||||
return newData;
|
||||
});
|
||||
};
|
||||
|
||||
const handleUndo = useCallback(() => {
|
||||
setHistoryIndex((currentIndex) => {
|
||||
if (currentIndex > 0) {
|
||||
const newIndex = currentIndex - 1;
|
||||
shouldUpdateContentRef.current = true;
|
||||
setFormData(history[newIndex]);
|
||||
return newIndex;
|
||||
}
|
||||
return currentIndex;
|
||||
});
|
||||
}, [history]);
|
||||
|
||||
const handleRedo = useCallback(() => {
|
||||
setHistoryIndex((currentIndex) => {
|
||||
if (currentIndex < history.length - 1) {
|
||||
const newIndex = currentIndex + 1;
|
||||
shouldUpdateContentRef.current = true;
|
||||
setFormData(history[newIndex]);
|
||||
return newIndex;
|
||||
}
|
||||
return currentIndex;
|
||||
});
|
||||
}, [history]);
|
||||
|
||||
const handleRevert = useCallback(() => {
|
||||
if (originalFormData) {
|
||||
if (confirm("Are you sure you want to revert all changes? This cannot be undone.")) {
|
||||
shouldUpdateContentRef.current = true;
|
||||
setFormData(originalFormData);
|
||||
setHistory([originalFormData]);
|
||||
setHistoryIndex(0);
|
||||
}
|
||||
} else if (projectId) {
|
||||
// Reload from server
|
||||
if (confirm("Are you sure you want to revert all changes? This will reload the project from the server.")) {
|
||||
shouldUpdateContentRef.current = true;
|
||||
loadProject(projectId);
|
||||
}
|
||||
}
|
||||
}, [originalFormData, projectId, loadProject]);
|
||||
|
||||
// Sync contentEditable when formData.content changes externally (undo/redo/revert)
|
||||
useEffect(() => {
|
||||
if (contentRef.current && shouldUpdateContentRef.current) {
|
||||
const currentContent = contentRef.current.textContent || "";
|
||||
if (currentContent !== formData.content) {
|
||||
contentRef.current.textContent = formData.content;
|
||||
}
|
||||
shouldUpdateContentRef.current = false;
|
||||
}
|
||||
}, [formData.content]);
|
||||
|
||||
// Initialize contentEditable when formData.content is set and editor is empty
|
||||
useEffect(() => {
|
||||
if (contentRef.current) {
|
||||
const currentText = contentRef.current.textContent || "";
|
||||
// Initialize if editor is empty and we have content, or if content changed externally
|
||||
if ((!currentText && formData.content) || (shouldUpdateContentRef.current && currentText !== formData.content)) {
|
||||
contentRef.current.textContent = formData.content;
|
||||
shouldUpdateContentRef.current = false;
|
||||
}
|
||||
}
|
||||
}, [formData.content]);
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Ctrl+S or Cmd+S - Save
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
if (!isSaving) {
|
||||
handleSave();
|
||||
}
|
||||
}
|
||||
// Ctrl+Z or Cmd+Z - Undo
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleUndo();
|
||||
}
|
||||
// Ctrl+Shift+Z or Cmd+Shift+Z - Redo
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleRedo();
|
||||
}
|
||||
// Ctrl+R or Cmd+R - Revert (but allow browser refresh if not in editor)
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'r') {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.isContentEditable || target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
|
||||
e.preventDefault();
|
||||
handleRevert();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isSaving, handleSave, handleUndo, handleRedo, handleRevert]);
|
||||
|
||||
const handleTagsChange = (tagsString: string) => {
|
||||
const tags = tagsString
|
||||
.split(",")
|
||||
@@ -358,7 +517,7 @@ function EditorPageContent() {
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
||||
className="w-12 h-12 border-3 border-blue-500 border-t-transparent rounded-full mx-auto mb-6"
|
||||
className="w-12 h-12 border-3 border-stone-500 border-t-transparent rounded-full mx-auto mb-6"
|
||||
/>
|
||||
<h2 className="text-xl font-semibold gradient-text mb-2">
|
||||
Loading Editor
|
||||
@@ -390,7 +549,7 @@ function EditorPageContent() {
|
||||
|
||||
<button
|
||||
onClick={() => (window.location.href = "/manage")}
|
||||
className="w-full px-6 py-3 bg-gradient-to-r from-blue-500 to-purple-500 text-white rounded-xl hover:scale-105 transition-all font-medium"
|
||||
className="w-full px-6 py-3 bg-stone-600 text-white rounded-xl hover:bg-stone-700 transition-all font-medium"
|
||||
>
|
||||
Go to Admin Login
|
||||
</button>
|
||||
@@ -400,15 +559,15 @@ function EditorPageContent() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen animated-bg">
|
||||
<div className="min-h-screen animated-bg flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="glass-card border-b border-white/10 sticky top-0 z-50">
|
||||
<div className="glass-card border-b border-white/10 sticky top-0 z-50 flex-shrink-0">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between h-auto sm:h-16 py-4 sm:py-0 gap-4 sm:gap-0">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center space-y-2 sm:space-y-0 sm:space-x-4">
|
||||
<button
|
||||
onClick={() => (window.location.href = "/manage")}
|
||||
className="inline-flex items-center space-x-2 text-blue-400 hover:text-blue-300 transition-colors"
|
||||
className="inline-flex items-center space-x-2 text-stone-400 hover:text-stone-300 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
<span className="hidden sm:inline">Back to Dashboard</span>
|
||||
@@ -427,8 +586,8 @@ function EditorPageContent() {
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
className={`flex items-center space-x-2 px-4 py-2 rounded-lg font-medium transition-all duration-200 text-sm ${
|
||||
showPreview
|
||||
? "bg-blue-600 text-white shadow-lg"
|
||||
: "bg-gray-800/50 text-gray-300 hover:bg-gray-700/50 hover:text-white"
|
||||
? "bg-stone-600 text-white shadow-lg"
|
||||
: "bg-gray-800/50 text-stone-300 hover:bg-gray-700/50 hover:text-stone-100"
|
||||
}`}
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
@@ -452,9 +611,10 @@ function EditorPageContent() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editor Content */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-6 sm:py-8">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-4 gap-6 lg:gap-8">
|
||||
{/* Editor Content - Scrollable */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-6 sm:py-8">
|
||||
<div className="flex flex-col lg:flex-row gap-6 lg:gap-8">
|
||||
{/* Floating particles background */}
|
||||
<div className="particles">
|
||||
{[...Array(20)].map((_, i) => (
|
||||
@@ -469,187 +629,12 @@ function EditorPageContent() {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* Main Editor */}
|
||||
<div className="xl:col-span-3 space-y-6">
|
||||
{/* Project Title */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="glass-card p-6 rounded-2xl"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={(e) => handleInputChange("title", e.target.value)}
|
||||
className="w-full text-3xl font-bold form-input-enhanced focus:outline-none p-4 rounded-lg"
|
||||
placeholder="Enter project title..."
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Rich Text Toolbar */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="glass-card p-4 rounded-2xl"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-1 sm:gap-2">
|
||||
<div className="flex items-center space-x-1 border-r border-white/20 pr-2 sm:pr-3">
|
||||
<button
|
||||
onClick={() => insertFormatting("bold")}
|
||||
className="p-2 rounded-lg text-gray-300"
|
||||
title="Bold"
|
||||
>
|
||||
<Bold className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => insertFormatting("italic")}
|
||||
className="p-2 rounded-lg text-gray-300"
|
||||
title="Italic"
|
||||
>
|
||||
<Italic className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => insertFormatting("code")}
|
||||
className="p-2 rounded-lg text-gray-300"
|
||||
title="Code"
|
||||
>
|
||||
<Code className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-1 border-r border-white/20 pr-2 sm:pr-3">
|
||||
<button
|
||||
onClick={() => insertFormatting("h1")}
|
||||
className="p-2 rounded-lg text-gray-300"
|
||||
title="Heading 1"
|
||||
>
|
||||
<Hash className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => insertFormatting("h2")}
|
||||
className="p-2 hover:bg-gray-800/50 rounded-lg transition-all duration-200 text-xs sm:text-sm text-gray-300 hover:text-white hover:scale-105"
|
||||
title="Heading 2"
|
||||
>
|
||||
H2
|
||||
</button>
|
||||
<button
|
||||
onClick={() => insertFormatting("h3")}
|
||||
className="p-2 hover:bg-gray-800/50 rounded-lg transition-all duration-200 text-xs sm:text-sm text-gray-300 hover:text-white hover:scale-105"
|
||||
title="Heading 3"
|
||||
>
|
||||
H3
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-1 border-r border-white/20 pr-2 sm:pr-3">
|
||||
<button
|
||||
onClick={() => insertFormatting("list")}
|
||||
className="p-2 rounded-lg text-gray-300"
|
||||
title="Bullet List"
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => insertFormatting("orderedList")}
|
||||
className="p-2 rounded-lg text-gray-300"
|
||||
title="Numbered List"
|
||||
>
|
||||
<ListOrdered className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => insertFormatting("quote")}
|
||||
className="p-2 rounded-lg text-gray-300"
|
||||
title="Quote"
|
||||
>
|
||||
<Quote className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-1">
|
||||
<button
|
||||
onClick={() => insertFormatting("link")}
|
||||
className="p-2 rounded-lg text-gray-300"
|
||||
title="Link"
|
||||
>
|
||||
<Link className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => insertFormatting("image")}
|
||||
className="p-2 rounded-lg text-gray-300"
|
||||
title="Image"
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/alt-text */}
|
||||
<Image className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Content Editor */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="glass-card p-6 rounded-2xl"
|
||||
>
|
||||
<h3 className="text-lg font-semibold gradient-text mb-4">
|
||||
Content
|
||||
</h3>
|
||||
<div
|
||||
ref={contentRef}
|
||||
contentEditable
|
||||
className="editor-content-editable w-full min-h-[400px] p-6 form-input-enhanced rounded-lg focus:outline-none leading-relaxed"
|
||||
style={{ whiteSpace: "pre-wrap" }}
|
||||
onInput={(e) => {
|
||||
const target = e.target as HTMLDivElement;
|
||||
setIsTyping(true);
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
content: target.textContent || "",
|
||||
}));
|
||||
}}
|
||||
onBlur={() => {
|
||||
setIsTyping(false);
|
||||
}}
|
||||
suppressContentEditableWarning={true}
|
||||
data-placeholder="Start writing your project content..."
|
||||
>
|
||||
{!isTyping ? formData.content : undefined}
|
||||
</div>
|
||||
<p className="text-xs text-white/50 mt-2">
|
||||
Supports Markdown formatting. Use the toolbar above or type
|
||||
directly.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Description */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="glass-card p-6 rounded-2xl"
|
||||
>
|
||||
<h3 className="text-lg font-semibold gradient-text mb-4">
|
||||
Description
|
||||
</h3>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) =>
|
||||
handleInputChange("description", e.target.value)
|
||||
}
|
||||
rows={4}
|
||||
className="w-full px-4 py-3 form-input-enhanced rounded-lg focus:outline-none resize-none"
|
||||
placeholder="Brief description of your project..."
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* Sidebar - Left (appears first in DOM for left positioning) */}
|
||||
<div className="w-full lg:w-80 flex-shrink-0 space-y-6 order-1 lg:order-1">
|
||||
{/* Project Settings */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className="glass-card p-6 rounded-2xl"
|
||||
@@ -660,7 +645,7 @@ function EditorPageContent() {
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-2">
|
||||
<label className="block text-sm font-medium text-stone-300 mb-2">
|
||||
Category
|
||||
</label>
|
||||
<div className="custom-select">
|
||||
@@ -681,7 +666,7 @@ function EditorPageContent() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-2">
|
||||
<label className="block text-sm font-medium text-stone-300 mb-2">
|
||||
Tags
|
||||
</label>
|
||||
<input
|
||||
@@ -697,7 +682,7 @@ function EditorPageContent() {
|
||||
|
||||
{/* Links */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
className="glass-card p-6 rounded-2xl"
|
||||
@@ -708,7 +693,7 @@ function EditorPageContent() {
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-2">
|
||||
<label className="block text-sm font-medium text-stone-300 mb-2">
|
||||
GitHub URL
|
||||
</label>
|
||||
<input
|
||||
@@ -723,7 +708,7 @@ function EditorPageContent() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/70 mb-2">
|
||||
<label className="block text-sm font-medium text-stone-300 mb-2">
|
||||
Live URL
|
||||
</label>
|
||||
<input
|
||||
@@ -739,7 +724,7 @@ function EditorPageContent() {
|
||||
|
||||
{/* Publish */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.6 }}
|
||||
className="glass-card p-6 rounded-2xl"
|
||||
@@ -756,9 +741,9 @@ function EditorPageContent() {
|
||||
onChange={(e) =>
|
||||
handleInputChange("featured", e.target.checked)
|
||||
}
|
||||
className="w-4 h-4 text-blue-500 bg-gray-900/80 border-gray-600/50 rounded focus:ring-blue-500 focus:ring-2"
|
||||
className="w-4 h-4 text-stone-500 bg-gray-900/80 border-gray-600/50 rounded focus:ring-stone-500 focus:ring-2"
|
||||
/>
|
||||
<span className="text-white">Featured Project</span>
|
||||
<span className="text-stone-200">Featured Project</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center space-x-3">
|
||||
@@ -768,20 +753,20 @@ function EditorPageContent() {
|
||||
onChange={(e) =>
|
||||
handleInputChange("published", e.target.checked)
|
||||
}
|
||||
className="w-4 h-4 text-blue-500 bg-gray-900/80 border-gray-600/50 rounded focus:ring-blue-500 focus:ring-2"
|
||||
className="w-4 h-4 text-stone-500 bg-gray-900/80 border-gray-600/50 rounded focus:ring-stone-500 focus:ring-2"
|
||||
/>
|
||||
<span className="text-white">Published</span>
|
||||
<span className="text-stone-200">Published</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-white/20">
|
||||
<h4 className="text-sm font-medium text-white/70 mb-2">
|
||||
<h4 className="text-sm font-medium text-stone-300 mb-2">
|
||||
Preview
|
||||
</h4>
|
||||
<div className="text-xs text-white/50 space-y-1">
|
||||
<div className="text-xs text-stone-400 space-y-1">
|
||||
<p>Status: {formData.published ? "Published" : "Draft"}</p>
|
||||
{formData.featured && (
|
||||
<p className="text-blue-400">⭐ Featured</p>
|
||||
<p className="text-stone-400">⭐ Featured</p>
|
||||
)}
|
||||
<p>Category: {formData.category}</p>
|
||||
<p>Tags: {formData.tags.length} tags</p>
|
||||
@@ -789,8 +774,227 @@ function EditorPageContent() {
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Main Editor - Right (appears second in DOM for right positioning) */}
|
||||
<div className="flex-1 space-y-6 order-2 lg:order-2 min-w-0">
|
||||
{/* Project Title */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="glass-card p-6 rounded-2xl"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={(e) => handleInputChange("title", e.target.value)}
|
||||
className="w-full text-3xl font-bold form-input-enhanced focus:outline-none p-4 rounded-lg"
|
||||
placeholder="Enter project title..."
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Description - Under Title */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="glass-card p-6 rounded-2xl"
|
||||
>
|
||||
<h3 className="text-lg font-semibold gradient-text mb-4">
|
||||
Description
|
||||
</h3>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) =>
|
||||
handleInputChange("description", e.target.value)
|
||||
}
|
||||
rows={4}
|
||||
className="w-full px-4 py-3 form-input-enhanced rounded-lg focus:outline-none resize-none"
|
||||
placeholder="Brief description of your project..."
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Rich Text Toolbar */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="glass-card p-4 rounded-2xl"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-1 sm:gap-2">
|
||||
<div className="flex items-center space-x-1 border-r border-white/20 pr-2 sm:pr-3">
|
||||
<button
|
||||
onClick={() => insertFormatting("bold")}
|
||||
className="p-2 rounded-lg text-stone-300"
|
||||
title="Bold"
|
||||
>
|
||||
<Bold className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => insertFormatting("italic")}
|
||||
className="p-2 rounded-lg text-stone-300"
|
||||
title="Italic"
|
||||
>
|
||||
<Italic className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => insertFormatting("code")}
|
||||
className="p-2 rounded-lg text-stone-300"
|
||||
title="Code"
|
||||
>
|
||||
<Code className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-1 border-r border-white/20 pr-2 sm:pr-3">
|
||||
<button
|
||||
onClick={() => insertFormatting("h1")}
|
||||
className="p-2 rounded-lg text-stone-300"
|
||||
title="Heading 1"
|
||||
>
|
||||
<Hash className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => insertFormatting("h2")}
|
||||
className="p-2 hover:bg-gray-800/50 rounded-lg transition-all duration-200 text-xs sm:text-sm text-stone-300 hover:text-stone-100"
|
||||
title="Heading 2"
|
||||
>
|
||||
H2
|
||||
</button>
|
||||
<button
|
||||
onClick={() => insertFormatting("h3")}
|
||||
className="p-2 hover:bg-gray-800/50 rounded-lg transition-all duration-200 text-xs sm:text-sm text-stone-300 hover:text-stone-100"
|
||||
title="Heading 3"
|
||||
>
|
||||
H3
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-1 border-r border-white/20 pr-2 sm:pr-3">
|
||||
<button
|
||||
onClick={() => insertFormatting("list")}
|
||||
className="p-2 rounded-lg text-stone-300"
|
||||
title="Bullet List"
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => insertFormatting("orderedList")}
|
||||
className="p-2 rounded-lg text-stone-300"
|
||||
title="Numbered List"
|
||||
>
|
||||
<ListOrdered className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => insertFormatting("quote")}
|
||||
className="p-2 rounded-lg text-stone-300"
|
||||
title="Quote"
|
||||
>
|
||||
<Quote className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-1">
|
||||
<button
|
||||
onClick={() => insertFormatting("link")}
|
||||
className="p-2 rounded-lg text-stone-300"
|
||||
title="Link"
|
||||
>
|
||||
<Link className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => insertFormatting("image")}
|
||||
className="p-2 rounded-lg text-stone-300"
|
||||
title="Image"
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/alt-text */}
|
||||
<Image className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Content Editor */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="glass-card p-6 rounded-2xl"
|
||||
>
|
||||
<h3 className="text-lg font-semibold gradient-text mb-4">
|
||||
Content
|
||||
</h3>
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={contentRef}
|
||||
contentEditable
|
||||
className="editor-content-editable w-full min-h-[500px] p-6 form-input-enhanced rounded-lg focus:outline-none leading-relaxed"
|
||||
style={{ whiteSpace: "pre-wrap" }}
|
||||
onFocus={(e) => {
|
||||
// Ensure content is set when focusing if empty
|
||||
const target = e.target as HTMLDivElement;
|
||||
const currentText = target.textContent || "";
|
||||
if (!currentText && formData.content) {
|
||||
target.textContent = formData.content;
|
||||
} else if (currentText !== formData.content && shouldUpdateContentRef.current) {
|
||||
// Sync if content changed externally (undo/redo)
|
||||
target.textContent = formData.content;
|
||||
}
|
||||
shouldUpdateContentRef.current = false;
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
// Prevent content from being cleared on click
|
||||
const target = e.target as HTMLDivElement;
|
||||
const currentText = target.textContent || "";
|
||||
if (!currentText && formData.content) {
|
||||
target.textContent = formData.content;
|
||||
}
|
||||
shouldUpdateContentRef.current = false;
|
||||
}}
|
||||
onClick={(e) => {
|
||||
// Ensure content persists on click
|
||||
const target = e.target as HTMLDivElement;
|
||||
if (!target.textContent && formData.content) {
|
||||
target.textContent = formData.content;
|
||||
}
|
||||
}}
|
||||
onInput={(e) => {
|
||||
const target = e.target as HTMLDivElement;
|
||||
setIsTyping(true);
|
||||
shouldUpdateContentRef.current = false;
|
||||
const newContent = target.textContent || "";
|
||||
setFormData((prev) => {
|
||||
const newData = {
|
||||
...prev,
|
||||
content: newContent,
|
||||
};
|
||||
// Add to history for undo/redo
|
||||
setHistory((hist) => {
|
||||
const newHistory = hist.slice(0, historyIndex + 1);
|
||||
newHistory.push(newData);
|
||||
// Keep only last 50 history entries
|
||||
const trimmedHistory = newHistory.slice(-50);
|
||||
setHistoryIndex(trimmedHistory.length - 1);
|
||||
return trimmedHistory;
|
||||
});
|
||||
return newData;
|
||||
});
|
||||
}}
|
||||
onBlur={() => {
|
||||
setIsTyping(false);
|
||||
}}
|
||||
suppressContentEditableWarning={true}
|
||||
data-placeholder="Start writing your project content..."
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-stone-400 mt-2">
|
||||
Supports Markdown formatting. Use the toolbar above or type
|
||||
directly.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview Modal */}
|
||||
<AnimatePresence>
|
||||
@@ -806,7 +1010,7 @@ function EditorPageContent() {
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.9, opacity: 0 }}
|
||||
className="glass-card rounded-2xl p-8 max-w-4xl w-full max-h-[90vh] overflow-y-auto"
|
||||
className="glass-card rounded-2xl p-8 max-w-4xl w-full max-h-[90vh] overflow-y-auto scrollbar-hide"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
@@ -834,12 +1038,12 @@ function EditorPageContent() {
|
||||
|
||||
{/* Project Meta */}
|
||||
<div className="flex flex-wrap justify-center gap-4 mb-6">
|
||||
<div className="flex items-center space-x-2 text-gray-300">
|
||||
<div className="flex items-center space-x-2 text-stone-300">
|
||||
<Tag className="w-4 h-4" />
|
||||
<span className="capitalize">{formData.category}</span>
|
||||
</div>
|
||||
{formData.featured && (
|
||||
<div className="flex items-center space-x-2 text-blue-400">
|
||||
<div className="flex items-center space-x-2 text-stone-400">
|
||||
<span className="text-sm font-semibold">
|
||||
⭐ Featured
|
||||
</span>
|
||||
@@ -853,7 +1057,7 @@ function EditorPageContent() {
|
||||
{formData.tags.map((tag, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-3 py-1 bg-gray-800/50 text-gray-300 text-sm rounded-full border border-gray-700"
|
||||
className="px-3 py-1 bg-gray-800/50 text-stone-300 text-sm rounded-full border border-gray-700"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
@@ -870,7 +1074,7 @@ function EditorPageContent() {
|
||||
href={formData.github}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-gray-800/50 text-gray-300 rounded-lg"
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-gray-800/50 text-stone-300 rounded-lg"
|
||||
>
|
||||
<Github className="w-4 h-4" />
|
||||
<span>GitHub</span>
|
||||
@@ -881,7 +1085,7 @@ function EditorPageContent() {
|
||||
href={formData.live}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-blue-600/80 text-white rounded-lg"
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-stone-600/80 text-white rounded-lg"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
<span>Live Demo</span>
|
||||
@@ -898,7 +1102,7 @@ function EditorPageContent() {
|
||||
Content
|
||||
</h3>
|
||||
<div className="prose prose-invert max-w-none">
|
||||
<div className="markdown text-gray-300 leading-relaxed">
|
||||
<div className="markdown text-stone-300 leading-relaxed">
|
||||
<ReactMarkdown components={markdownComponents}>
|
||||
{formData.content}
|
||||
</ReactMarkdown>
|
||||
@@ -921,7 +1125,7 @@ function EditorPageContent() {
|
||||
{formData.published ? "Published" : "Draft"}
|
||||
</span>
|
||||
{formData.featured && (
|
||||
<span className="px-3 py-1 bg-blue-500/20 text-blue-400 rounded-full text-sm font-medium">
|
||||
<span className="px-3 py-1 bg-stone-500/20 text-stone-400 rounded-full text-sm font-medium">
|
||||
Featured
|
||||
</span>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user