import React, { useState, useEffect, useMemo } from "react"; import { useNavigate, useParams, Link } from "react-router-dom"; import MDEditor, { commands } from "@uiw/react-md-editor"; import "@uiw/react-md-editor/markdown-editor.css"; import { API_BASE } from "../config"; import { createMarkdownParser } from "../utils/markdownParser"; import DOMPurify from "dompurify"; import MediaGalleryModal from "./MediaGalleryModal"; // Initialize the shared markdown parser const md = createMarkdownParser(); function PostEditor() { const navigate = useNavigate(); const { slug } = useParams(); const isEditing = !!slug; const [activeTab, setActiveTab] = useState("write"); // "write" | "preview" const [galleryOpen, setGalleryOpen] = useState(false); const [formData, setFormData] = useState({ title: "", description: "", content: "", tags: "", hidden: false, }); const [cursorLine, setCursorLine] = useState(0); const [loading, setLoading] = useState(isEditing); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); // Calculate temporary slug for new posts const getSlugForUpload = () => { if (isEditing) return slug; if (formData.title) { const date = new Date(); const dateStr = date.toISOString().slice(0, 10).replace(/-/g, ""); const slugText = formData.title .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, "") .slice(0, 30); return `${dateStr}-${slugText}`; } return ""; }; useEffect(() => { if (isEditing) { fetchPost(); } }, [slug, isEditing]); const fetchPost = async () => { try { setLoading(true); const response = await fetch(`${API_BASE}/posts/${slug}`, { credentials: "include", }); if (!response.ok) throw new Error("Failed to fetch post"); const post = await response.json(); let content = post.content; content = content.replace(/^title:.*$/m, ""); content = content.replace(/^desc:.*$/m, ""); content = content.replace(/^tags:.*$/m, ""); content = content.replace(/^hidden:.*$/m, ""); // Remove hidden if present in content body (should be only in header but just in case) content = content.replace(/^\n+/, ""); setFormData({ title: post.title, description: post.description, content: content.trim(), tags: post.tags ? post.tags.join(", ") : "", hidden: !!post.hidden, }); } catch (err) { setError(err.message); } finally { setLoading(false); } }; const handleInputChange = (field, value, event) => { setFormData((prev) => ({ ...prev, [field]: value })); // Track cursor line for sync if (field === "content" && event) { // MDEditor's onChange passes the value, but we need the event or access to the textarea // However, uiw/react-md-editor's onChange only gives value. // We need to use onSelect or capture it from the underlying textarea. } }; // Improved cursor tracker const insertTextAtCursor = (text) => { setFormData(prev => ({ ...prev, content: prev.content + "\n" + text })); }; // Improved cursor tracker const handleCursorSelect = (e) => { // e.target is the textarea if (!e || !e.target) return; const { selectionStart, value } = e.target; const lines = value.substr(0, selectionStart).split("\n"); // Markdown-it lines are 0-based. lines.length is 1-based count. // So line index is lines.length - 1. setCursorLine(lines.length - 1); }; const handleEditorChange = (value, event, state) => { handleInputChange("content", value || ""); // We rely on handleCursorSelect via onSelect/onClick/onKeyUp for cursor tracking now. }; const handlePaste = async (event) => { const items = event.clipboardData.items; for (const item of items) { if (item.type.indexOf("image") === 0) { event.preventDefault(); const file = item.getAsFile(); if (!file) continue; const tempSlug = getSlugForUpload() || "uploads"; const placeholder = `![Uploading ${file.name}...]()...`; // Insert placeholder // We use api.replaceSelection if we had access to 'api' from MDEditor, // but here we are in a raw paste handler on the textarea. // We'll append for now or try to use a more sophisticated insertion if possible, // but sticking to insertTextAtCursor is safer for state consistency. // BETTER: MDEditor's `paste` command? No, native onPaste is best. // Native paste on textarea doesn't give us cursor position easily in React state flow // without ref manipulation. // Let's use the standard "append" or try to insert at end. // Actually, let's use the replaceSelection approach if we can get a ref to the editor instance, // but we don't have it easily. // We will append to content with a newline for simplicity and reliability. setFormData(prev => ({ ...prev, content: prev.content + "\n" + placeholder })); try { const formData = new FormData(); formData.append("file", file); const response = await fetch(`${API_BASE}/upload?slug=${tempSlug}`, { method: "POST", body: formData, credentials: "include", }); if (!response.ok) throw new Error("Upload failed"); const data = await response.json(); // Replace placeholder with actual image markdown setFormData(prev => ({ ...prev, content: prev.content.replace(placeholder, `![${data.originalName}](${API_BASE}${data.url})`) })); } catch (error) { console.error("Paste upload error:", error); setFormData(prev => ({ ...prev, content: prev.content.replace(placeholder, `[Upload Failed: ${error.message}]`) })); } } } }; // Gallery selection handler const handleImageSelect = (url, name) => { // We append to end since we lose cursor position when modal opens/closes // Ideally we would insert at cursor, but standard 'insertTextAtCursor' used state append. // Users can cut/paste. const markdown = `![${name}](${url})`; insertTextAtCursor(markdown); setGalleryOpen(false); }; // Define Custom Toolbar Commands const customCommands = useMemo(() => { const mediaCommand = { name: "media", keyCommand: "media", buttonProps: { "aria-label": "Media Gallery", title: "Media Gallery" }, icon: ( ), execute: (state, api) => { setGalleryOpen(true); }, }; const compareCommand = { name: "compare", keyCommand: "compare", buttonProps: { "aria-label": "Insert Comparison", title: "Insert Comparison" }, icon: ( ), execute: (state, api) => { api.replaceSelection(`\n\`\`\`compare\n![Before](url1)\n![After](url2)\n\`\`\`\n`); }, }; const reelCommand = { name: "reel", keyCommand: "reel", buttonProps: { "aria-label": "Insert Zoom Reel", title: "Insert Zoom Reel" }, icon: ( ), execute: (state, api) => { api.replaceSelection(`\n\`\`\`zoom-reel\n![Image 1](url1)\n![Image 2](url2)\n\`\`\`\n`); }, }; const resizeCommand = { name: "resize", keyCommand: "resize", buttonProps: { "aria-label": "Insert Resized Image", title: "Insert Resized Image" }, icon: ( ), execute: (state, api) => { api.replaceSelection(`![Resized](url =300x200)`); }, }; return [mediaCommand, compareCommand, reelCommand, resizeCommand]; }, []); const handleSubmit = async (e) => { e.preventDefault(); if (!formData.title.trim() || !formData.content.trim()) { setError("Title and content are required"); return; } try { setSaving(true); setError(null); const payload = { title: formData.title.trim(), description: formData.description.trim(), content: formData.content.trim(), tags: formData.tags .split(",") .map((tag) => tag.trim()) .filter((tag) => tag), hidden: formData.hidden, }; const url = isEditing ? `${API_BASE}/posts/${slug}` : `${API_BASE}/posts`; const method = isEditing ? "PUT" : "POST"; const response = await fetch(url, { method, headers: { "Content-Type": "application/json", }, credentials: "include", body: JSON.stringify(payload), }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || "Failed to save post"); } const savedPost = await response.json(); navigate(`/admin`); } catch (err) { setError(err.message); } finally { setSaving(false); } }; const [viewMode, setViewMode] = useState("tabs"); // "tabs" | "split" // Process markdown for preview const renderContent = () => { return md.render(formData.content || ""); }; // Calculate preview for both modes // Helper to sanitize const getSanitizedHtml = (html) => DOMPurify.sanitize(html, { ADD_TAGS: ["input"], ADD_ATTR: ["type", "min", "max", "value", "step", "style", "width", "height", "class", "data-line"], // Added data-line }); const previewHtml = renderContent(); const sanitizedPreview = getSanitizedHtml(previewHtml); const [settingsOpen, setSettingsOpen] = useState(false); // Auto-resize title textarea const titleRef = React.useRef(null); useEffect(() => { if (titleRef.current) { titleRef.current.style.height = "auto"; titleRef.current.style.height = titleRef.current.scrollHeight + "px"; } }, [formData.title]); // Scroll Sync & Flicker Effect useEffect(() => { if (activeTab === "preview" || viewMode === "split") { const elements = document.querySelectorAll('[data-line]'); let target = null; let maxLine = -1; elements.forEach(el => { const line = parseInt(el.getAttribute('data-line'), 10); if (line <= cursorLine && line > maxLine) { maxLine = line; target = el; } }); if (target) { target.scrollIntoView({ behavior: 'smooth', block: 'center' }); // Add flicker effect target.classList.add('highlight-pulse'); setTimeout(() => { target.classList.remove('highlight-pulse'); }, 1500); } } }, [activeTab, cursorLine, viewMode]); return (
setGalleryOpen(false)} onSelect={handleImageSelect} slug={getSlugForUpload()} /> {/* Glassmorphic Top Bar */} {/* Main Content Area */}
{error && (
{error}
)}
{/* Title Input - Scaled down and boxed */} Title: