Adam 1 mesiac pred
rodič
commit
956aba7c2f

+ 2 - 0
.gitignore

@@ -22,3 +22,5 @@ dist-ssr
 *.njsproj
 *.sln
 *.sw?
+public/posts/*
+public/posts/index.json

+ 221 - 5
backend/server.js

@@ -6,6 +6,7 @@ import fs from "fs-extra";
 import path from "path";
 import { fileURLToPath } from "url";
 import { v4 as uuidv4 } from "uuid";
+import multer from "multer";
 
 // Load environment variables
 dotenv.config();
@@ -38,6 +39,37 @@ const POSTS_DIR = path.resolve(
 );
 const INDEX_FILE = path.join(POSTS_DIR, "index.json");
 
+// Multer Storage Configuration
+const storage = multer.diskStorage({
+    destination: async function (req, file, cb) {
+        // Determine folder based on slug
+        let folder = "uploads"; // Default
+        if (req.query.slug) {
+            // Clean the slug just in case, though it should be safe if generated by us
+            const cleanSlug = req.query.slug.replace(/[^a-z0-9-]/gi, "");
+            folder = path.join(cleanSlug, "images");
+        }
+
+        const uploadPath = path.join(POSTS_DIR, folder);
+        try {
+            await fs.ensureDir(uploadPath);
+            cb(null, uploadPath);
+        } catch (err) {
+            cb(err);
+        }
+    },
+    filename: function (req, file, cb) {
+        // Keep original filename but ensure uniqueness if needed? 
+        // For now, simple filename is better for markdown readability.
+        // We'll replace spaces with dashes.
+        const cleanName = file.originalname.replace(/[^a-z0-9.]/gi, "-").toLowerCase();
+        cb(null, cleanName); // Overwrites if exists, which might be desired behavior for updating images
+    },
+});
+
+const upload = multer({ storage: storage });
+
+
 // Middleware
 app.use(
     cors({
@@ -51,6 +83,11 @@ app.use(
 app.use(express.json());
 app.use(express.urlencoded({ extended: true }));
 
+// Serve static files from POSTS_DIR so images are accessible
+// Access images via /api/media-files/SLUG/images/FILENAME.jpg
+// We use /api/media-files to avoid conflict with /api/posts routes and to align with API_BASE
+app.use("/api/media-files", express.static(POSTS_DIR));
+
 // Session configuration
 app.use(
     session({
@@ -91,6 +128,7 @@ function isAuthenticated(req, res, next) {
 async function generateIndex() {
     try {
         const files = await fs.readdir(POSTS_DIR);
+        // We only care about root level .md files for the index
         const mdFiles = files.filter((f) => f.endsWith(".md"));
         await fs.writeJSON(INDEX_FILE, mdFiles, { spaces: 2 });
         console.log(`Index updated: ${mdFiles.length} posts`);
@@ -106,11 +144,13 @@ function parsePostMetadata(content) {
     const titleMatch = content.match(/title:\s*(.*)/);
     const descMatch = content.match(/desc:\s*(.*)/);
     const tagsMatch = content.match(/tags:\s*(.*)/);
+    const hiddenMatch = content.match(/hidden:\s*(true|false)/);
 
     return {
         title: titleMatch ? titleMatch[1].trim() : "Untitled",
         description: descMatch ? descMatch[1].trim() : "",
         tags: tagsMatch ? tagsMatch[1].split(",").map((tag) => tag.trim()) : [],
+        hidden: hiddenMatch ? hiddenMatch[1] === "true" : false,
     };
 }
 
@@ -128,6 +168,21 @@ function generateFilename(title) {
     return slug ? `${dateStr}-${slug}.md` : `${dateStr}.md`;
 }
 
+// Helper to generate just the SLUG part (without date/extension if possible, or matches filename structure)
+// For internal consistency, we should try to match how valid posts are named.
+// But if user creates a "temporary" slug from title, we use that.
+function getSlugFromTitle(title) {
+    const date = new Date();
+    const dateStr = date.toISOString().slice(0, 10).replace(/-/g, "");
+    const slugText = title
+        .toLowerCase()
+        .replace(/[^a-z0-9]+/g, "-")
+        .replace(/^-|-$/g, "")
+        .slice(0, 30);
+    return `${dateStr}-${slugText}`;
+}
+
+
 // Authentication Routes
 
 // POST /api/auth/login - Login
@@ -240,7 +295,127 @@ app.post("/api/auth/change-password", requireAuth, async (req, res) => {
     }
 });
 
-// API Routes
+// MEDIA ROUTES
+
+// POST /api/upload - Upload file
+// Query param: slug (optional, for organization)
+app.post("/api/upload", requireAuth, upload.single("file"), (req, res) => {
+    if (!req.file) {
+        return res.status(400).json({ error: "No file uploaded" });
+    }
+
+    const { slug } = req.query;
+    let urlPath = "";
+
+    if (slug) {
+        // Should match the folder structure logic in storage config
+        const cleanSlug = slug.replace(/[^a-z0-9-]/gi, "");
+        urlPath = `/media-files/${cleanSlug}/images/${req.file.filename}`;
+    } else {
+        urlPath = `/media-files/uploads/${req.file.filename}`;
+    }
+
+    res.json({
+        success: true,
+        url: urlPath,
+        filename: req.file.filename,
+        originalName: req.file.originalname,
+    });
+});
+
+// GET /api/media - List files
+// Query param: slug (optional)
+app.get("/api/media", requireAuth, async (req, res) => {
+    try {
+        const { slug } = req.query;
+        let targetDir = "";
+
+        if (slug) {
+            const cleanSlug = slug.replace(/[^a-z0-9-]/gi, "");
+            targetDir = path.join(POSTS_DIR, cleanSlug, "images");
+        } else {
+            // If no slug, maybe list all? Or list 'uploads'?
+            // Providing a 'root' param or just listing everything might be expensive.
+            // Let's list 'uploads' by default or require a slug properly.
+            // For a "Media Manager", we might want to scan ALL folders.
+
+            // For now, let's implement listing a specific slug's images.
+            // If we want a global manager, we can return a tree.
+
+            // Simple approach: list all directories in POSTS_DIR that are directories, and then their images.
+            // That's complex. Let's just return empty if no slug, or implement logic later for global view.
+
+            // Let's default to "uploads" if no slug, OR if a special flag "all" is present, we scan?
+            targetDir = path.join(POSTS_DIR, "uploads");
+        }
+
+        if (!(await fs.pathExists(targetDir))) {
+            return res.json([]);
+        }
+
+        const files = await fs.readdir(targetDir);
+        const mediaFiles = [];
+
+        for (const file of files) {
+            const stats = await fs.stat(path.join(targetDir, file));
+            if (stats.isFile() && /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(file)) {
+                const slugPart = slug ? slug.replace(/[^a-z0-9-]/gi, "") : "uploads";
+                // If slug was passed, url is /media-files/SLUG/images/FILE
+                // If no slug (using uploads), url is /media-files/uploads/FILE
+
+                const url = slug
+                    ? `/media-files/${slugPart}/images/${file}`
+                    : `/media-files/uploads/${file}`;
+
+                mediaFiles.push({
+                    name: file,
+                    url: url,
+                    size: stats.size,
+                    date: stats.mtime,
+                });
+            }
+        }
+
+        // Sort by newest
+        mediaFiles.sort((a, b) => b.date - a.date);
+
+        res.json(mediaFiles);
+    } catch (err) {
+        console.error("Error listing media:", err);
+        res.status(500).json({ error: "Failed to list media" });
+    }
+});
+
+// DELETE /api/media - Delete file
+app.delete("/api/media", requireAuth, async (req, res) => {
+    try {
+        const { path: relativePath } = req.body; // e.g. "/media-files/slug/images/file.jpg"
+        if (!relativePath) return res.status(400).json({ error: "Path is required" });
+
+        // Security check: ensure path is within POSTS_DIR
+        // relativePath typically starts with /media-files/
+        const cleanPath = relativePath.replace(/^\/media-files\//, "");
+        const fullPath = path.join(POSTS_DIR, cleanPath);
+
+        // Prevent directory traversal
+        if (!fullPath.startsWith(POSTS_DIR)) {
+            return res.status(403).json({ error: "Invalid path" });
+        }
+
+        if (await fs.pathExists(fullPath)) {
+            await fs.remove(fullPath);
+            res.json({ success: true });
+        } else {
+            res.status(404).json({ error: "File not found" });
+        }
+
+    } catch (err) {
+        console.error("Delete media error:", err);
+        res.status(500).json({ error: "Failed to delete media" });
+    }
+});
+
+// API Routes continue...
 
 // GET /api/posts - Get all posts with metadata
 app.get("/api/posts", async (req, res) => {
@@ -266,7 +441,11 @@ app.get("/api/posts", async (req, res) => {
         // Sort by creation date, newest first
         posts.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
 
-        res.json(posts);
+        // Filter out hidden posts for non-admins
+        const isAdmin = req.session && req.session.user && req.session.user.role === "admin";
+        const visiblePosts = posts.filter(post => isAdmin || !post.hidden);
+
+        res.json(visiblePosts);
     } catch (error) {
         console.error("Error fetching posts:", error);
         res.status(500).json({ error: "Failed to fetch posts" });
@@ -288,6 +467,14 @@ app.get("/api/posts/:slug", async (req, res) => {
         const metadata = parsePostMetadata(content);
         const stats = await fs.stat(filePath);
 
+        // Access control for hidden posts
+        const isAdmin = req.session && req.session.user && req.session.user.role === "admin";
+        if (metadata.hidden && !isAdmin) {
+            // Return 404 to hide existence, or 403 if we want to be explicit.
+            // 404 is safer for "hidden" content.
+            return res.status(404).json({ error: "Post not found" });
+        }
+
         res.json({
             slug,
             filename,
@@ -305,7 +492,7 @@ app.get("/api/posts/:slug", async (req, res) => {
 // POST /api/posts - Create new post
 app.post("/api/posts", requireAuth, async (req, res) => {
     try {
-        const { title, description, content, tags } = req.body;
+        const { title, description, content, tags, hidden } = req.body;
 
         if (!title || !content) {
             return res
@@ -330,6 +517,7 @@ app.post("/api/posts", requireAuth, async (req, res) => {
         if (description) postContent += `desc: ${description}\n`;
         if (tags && tags.length > 0)
             postContent += `tags: ${tags.join(", ")}\n`;
+        if (hidden) postContent += `hidden: true\n`;
         postContent += "\n" + content;
 
         // Write the file
@@ -361,7 +549,7 @@ app.post("/api/posts", requireAuth, async (req, res) => {
 app.put("/api/posts/:slug", requireAuth, async (req, res) => {
     try {
         const { slug } = req.params;
-        const { title, description, content, tags } = req.body;
+        const { title, description, content, tags, hidden } = req.body;
 
         const oldFilename = `${slug}.md`;
         const oldFilePath = path.join(POSTS_DIR, oldFilename);
@@ -386,6 +574,7 @@ app.put("/api/posts/:slug", requireAuth, async (req, res) => {
         if (description) postContent += `desc: ${description}\n`;
         if (tags && tags.length > 0)
             postContent += `tags: ${tags.join(", ")}\n`;
+        if (hidden) postContent += `hidden: true\n`;
         postContent += "\n" + content;
 
         // Write to new file
@@ -394,6 +583,20 @@ app.put("/api/posts/:slug", requireAuth, async (req, res) => {
         // If filename changed, remove old file
         if (oldFilename !== newFilename) {
             await fs.remove(oldFilePath);
+            // IMPORTANT: If we rename the post, should we verify if images folder usage needs update?
+            // Currently images are stored in /slug/images/. 
+            // If the slug (filename) depends on the title, changing title changes slug.
+            // We should rename the image folder too!
+
+            const oldSlug = slug;
+            const newSlug = newFilename.replace(".md", "");
+
+            const oldImgDir = path.join(POSTS_DIR, oldSlug, "images");
+            const newImgDir = path.join(POSTS_DIR, newSlug, "images");
+
+            if (await fs.pathExists(oldImgDir)) {
+                await fs.move(oldImgDir, newImgDir, { overwrite: true });
+            }
         }
 
         // Update index
@@ -430,6 +633,20 @@ app.delete("/api/posts/:slug", requireAuth, async (req, res) => {
         }
 
         await fs.remove(filePath);
+
+        // Removing associated images folder
+        const imgDir = path.join(POSTS_DIR, slug, "images");
+        if (await fs.pathExists(imgDir)) {
+            await fs.remove(imgDir);
+        }
+        // Also remove parent folder if it was created just for this? 
+        // Structure is POSTS_DIR/slug/images. 
+        // We should remove POSTS_DIR/slug
+        const slugDir = path.join(POSTS_DIR, slug);
+        if (await fs.pathExists(slugDir)) {
+            await fs.remove(slugDir);
+        }
+
         await generateIndex();
 
         res.json({ message: "Post deleted successfully" });
@@ -440,7 +657,6 @@ app.delete("/api/posts/:slug", requireAuth, async (req, res) => {
 });
 
 // Theme API Routes
-
 // GET /api/themes - Get all themes
 app.get("/api/themes", async (req, res) => {
     try {

+ 1 - 1
backend/themes.json

@@ -1,5 +1,5 @@
 {
-  "activeTheme": "dark",
+  "activeTheme": "forest",
   "customThemes": [],
   "builtInThemes": [
     {

+ 3 - 1
public/posts/index.json

@@ -1,3 +1,5 @@
 [
-  "20251213-hey-there.md"
+  "20251213-hey-there.md",
+  "20251217-feature-showcase-advanced-mark.md",
+  "20251217-test.md"
 ]

+ 196 - 39
src/App.jsx

@@ -6,9 +6,6 @@ import {
     Link,
     useParams,
 } from "react-router-dom";
-import MarkdownIt from "markdown-it";
-import { full as emoji } from "markdown-it-emoji";
-import footnote from "markdown-it-footnote";
 import DOMPurify from "dompurify";
 import { AuthProvider, useAuth } from "./contexts/AuthContext";
 import { ThemeProvider } from "./contexts/ThemeContext";
@@ -18,44 +15,32 @@ import LoginForm from "./components/LoginForm";
 import ProtectedRoute from "./components/ProtectedRoute";
 import ThemesManager from "./components/ThemesManager";
 import ThemeEditor from "./components/ThemeEditor";
+import MediaManager from "./components/MediaManager";
+import { createMarkdownParser } from "./utils/markdownParser";
+import { API_BASE } from "./config";
 
-const scrollableTablesPlugin = (md) => {
-    const defaultRenderOpen =
-        md.renderer.rules.table_open ||
-        function (tokens, idx, options, env, self) {
-            return self.renderToken(tokens, idx, options);
-        };
-
-    const defaultRenderClose =
-        md.renderer.rules.table_close ||
-        function (tokens, idx, options, env, self) {
-            return self.renderToken(tokens, idx, options);
-        };
+// Initialize the shared markdown parser
+const md = createMarkdownParser();
 
-    md.renderer.rules.table_open = function (tokens, idx, options, env, self) {
-        return (
-            '<div class="overflow-x-auto">' +
-            defaultRenderOpen(tokens, idx, options, env, self)
-        );
-    };
+// Lightbox Component
+const Lightbox = ({ src, alt, onClose }) => {
+    if (!src) return null;
 
-    md.renderer.rules.table_close = function (tokens, idx, options, env, self) {
-        return defaultRenderClose(tokens, idx, options, env, self) + "</div>";
-    };
+    return (
+        <div className="lightbox-overlay" onClick={onClose}>
+            <span className="lightbox-close" onClick={onClose}>
+                &times;
+            </span>
+            <img
+                src={src}
+                alt={alt}
+                className="lightbox-content"
+                onClick={(e) => e.stopPropagation()} // Prevent closing when clicking the image
+            />
+        </div>
+    );
 };
 
-const md = new MarkdownIt({
-    html: true, // Enable HTML tags in source
-    linkify: true, // Auto-convert URL-like text to links
-    typographer: true, // Enable some language-neutral replacement + quotes beautification
-    breaks: false, // Convert '\n' in paragraphs into <br>
-})
-    .use(scrollableTablesPlugin) // Keep our table scrolling enhancement
-    .use(emoji) // GitHub-style emoji :emoji_name:
-    .use(footnote); // Standard footnotes [^1]
-
-import { API_BASE } from "./config";
-
 // Navigation Header Component
 function NavHeader() {
     const { isAdmin, user, logout } = useAuth();
@@ -218,7 +203,7 @@ function BlogHome() {
 }
 
 // Post View Component
-function PostView() {
+function PostView({ onImageClick }) {
     const { slug } = useParams();
     const [post, setPost] = useState(null);
     const [loading, setLoading] = useState(true);
@@ -299,6 +284,148 @@ function PostView() {
         };
     }, []);
 
+    // Effect to handle interactions (Comparison Slider, Zoom Reel & Lightbox)
+    useEffect(() => {
+        if (!post) return;
+
+        // 1. Handle Comparison Sliders (Updated to use clip-path)
+        const sliders = document.querySelectorAll(".comparison-slider");
+
+        const handleSliderInput = (e) => {
+            const container = e.target.closest(".comparison-wrapper");
+            const topImage = container.querySelector(".comparison-top");
+            const handle = container.querySelector(".slider-handle");
+            const val = e.target.value;
+
+            // Use clip-path inset(top right bottom left)
+            // We want to clip the right side based on the slider value.
+            // If slider is at 50%, we want to show 50% of the image from the left.
+            // So we clip 50% from the right.
+            // Inset right value = 100 - val
+            if (topImage) topImage.style.clipPath = `inset(0 ${100 - val}% 0 0)`;
+            if (handle) handle.style.left = `${val}%`;
+        };
+
+        sliders.forEach(slider => {
+            slider.addEventListener("input", handleSliderInput);
+        });
+
+        // 2. Handle Zoom Reels
+        const zoomReels = document.querySelectorAll(".interactive-zoom-reel");
+        const cleanupZoomReels = []; // To store cleanup functions for each reel
+
+        zoomReels.forEach(reel => {
+            const images = reel.querySelectorAll(".zoom-reel-img");
+            const viewports = reel.querySelectorAll(".zoom-reel-viewport");
+            const slider = reel.querySelector(".zoom-slider");
+            const resetBtn = reel.querySelector(".reset-zoom");
+
+            let state = {
+                zoom: 1,
+                panX: 0,
+                panY: 0,
+                isDragging: false,
+                startX: 0,
+                startY: 0,
+                initialPanX: 0,
+                initialPanY: 0
+            };
+
+            const updateTransform = () => {
+                images.forEach(img => {
+                    img.style.transform = `translate(${state.panX}px, ${state.panY}px) scale(${state.zoom})`;
+                });
+            };
+
+            const handleZoomInput = (e) => {
+                state.zoom = parseFloat(e.target.value);
+                // Reset pan if zoom is 1
+                if (state.zoom === 1) {
+                    state.panX = 0;
+                    state.panY = 0;
+                }
+                updateTransform();
+            };
+
+            const handleReset = () => {
+                state.zoom = 1;
+                state.panX = 0;
+                state.panY = 0;
+                if (slider) slider.value = 1;
+                updateTransform();
+            };
+
+            // Drag Logic for Viewports
+            const handleMouseDown = (e) => {
+                if (state.zoom <= 1) return; // Only pan if zoomed in
+                e.preventDefault(); // Prevent standard drag
+                state.isDragging = true;
+                state.startX = e.clientX;
+                state.startY = e.clientY;
+                state.initialPanX = state.panX;
+                state.initialPanY = state.panY;
+
+                viewports.forEach(vp => vp.style.cursor = "grabbing");
+            };
+
+            const handleMouseMove = (e) => {
+                if (!state.isDragging) return;
+                e.preventDefault();
+                const dx = e.clientX - state.startX;
+                const dy = e.clientY - state.startY;
+                state.panX = state.initialPanX + dx;
+                state.panY = state.initialPanY + dy;
+                updateTransform();
+            };
+
+            const handleMouseUp = () => {
+                state.isDragging = false;
+                viewports.forEach(vp => vp.style.cursor = "grab");
+            };
+
+            if (slider) slider.addEventListener("input", handleZoomInput);
+            if (resetBtn) resetBtn.addEventListener("click", handleReset);
+
+            viewports.forEach(vp => {
+                vp.addEventListener("mousedown", handleMouseDown);
+            });
+            // We listen to document for move/up to handle drag going outside viewport
+            document.addEventListener("mousemove", handleMouseMove);
+            document.addEventListener("mouseup", handleMouseUp);
+
+            cleanupZoomReels.push(() => {
+                if (slider) slider.removeEventListener("input", handleZoomInput);
+                if (resetBtn) resetBtn.removeEventListener("click", handleReset);
+                viewports.forEach(vp => vp.removeEventListener("mousedown", handleMouseDown));
+                document.removeEventListener("mousemove", handleMouseMove);
+                document.removeEventListener("mouseup", handleMouseUp);
+            });
+        });
+
+        // 3. Handle Lightbox clicks
+        const lightboxImages = document.querySelectorAll(".markdown-content img");
+        const handleImageClick = (e) => {
+            // Ignore images inside comparison slider or zoom reel
+            if (e.target.closest(".comparison-wrapper") || e.target.closest(".zoom-reel-container")) return;
+
+            onImageClick(e.target.src, e.target.alt);
+        };
+
+        lightboxImages.forEach(img => {
+            img.addEventListener("click", handleImageClick);
+        });
+
+        return () => {
+            sliders.forEach(slider => {
+                slider.removeEventListener("input", handleSliderInput);
+            });
+            lightboxImages.forEach(img => {
+                img.removeEventListener("click", handleImageClick);
+            });
+            cleanupZoomReels.forEach(cleanup => cleanup());
+        };
+    }, [post, onImageClick]);
+
     if (loading) {
         return (
             <div className="min-h-screen theme-bg flex items-center justify-center">
@@ -376,7 +503,10 @@ function PostView() {
 
     const { processedText } = conceiveFoxFromSemen(post.content);
     const htmlContent = md.render(processedText);
-    const sanitizedHtml = DOMPurify.sanitize(htmlContent);
+    const sanitizedHtml = DOMPurify.sanitize(htmlContent, {
+        ADD_TAGS: ["input"], // Allow input tags for the slider
+        ADD_ATTR: ["type", "min", "max", "value", "step", "checked"],
+    });
 
     return (
         <div className="min-h-screen theme-bg font-sans theme-text antialiased flex flex-col">
@@ -419,13 +549,32 @@ function PostView() {
 }
 
 function App() {
+    const [lightboxOpen, setLightboxOpen] = useState(false);
+    const [lightboxImage, setLightboxImage] = useState({ src: "", alt: "" });
+
+    const openLightbox = (src, alt) => {
+        setLightboxImage({ src, alt });
+        setLightboxOpen(true);
+    };
+
+    const closeLightbox = () => {
+        setLightboxOpen(false);
+    };
+
     return (
         <Router>
             <AuthProvider>
                 <ThemeProvider>
+                    {lightboxOpen && (
+                        <Lightbox
+                            src={lightboxImage.src}
+                            alt={lightboxImage.alt}
+                            onClose={closeLightbox}
+                        />
+                    )}
                     <Routes>
                         <Route path="/" element={<BlogHome />} />
-                        <Route path="/posts/:slug" element={<PostView />} />
+                        <Route path="/posts/:slug" element={<PostView onImageClick={openLightbox} />} />
                         <Route path="/login" element={<LoginForm />} />
                         <Route
                             path="/admin"
@@ -475,6 +624,14 @@ function App() {
                                 </ProtectedRoute>
                             }
                         />
+                        <Route
+                            path="/admin/media"
+                            element={
+                                <ProtectedRoute>
+                                    <MediaManager />
+                                </ProtectedRoute>
+                            }
+                        />
                     </Routes>
                 </ThemeProvider>
             </AuthProvider>

+ 9 - 3
src/components/AdminDashboard.jsx

@@ -103,9 +103,15 @@ function AdminDashboard() {
                             </Link>
                             <Link
                                 to="/admin/themes"
-                                className="btn-theme-primary text-white px-4 py-2 rounded-lg transition-colors"
+                                className="btn-theme-secondary text-white px-4 py-2 rounded-lg transition-colors"
+                            >
+                                Themes
+                            </Link>
+                            <Link
+                                to="/admin/media"
+                                className="btn-theme-secondary text-white px-4 py-2 rounded-lg transition-colors"
                             >
-                                Theme Manager
+                                Media
                             </Link>
                             <Link
                                 to="/admin/post/new"
@@ -189,7 +195,7 @@ function AdminDashboard() {
                                                 new Date(post.createdAt) >
                                                 new Date(
                                                     Date.now() -
-                                                        7 * 24 * 60 * 60 * 1000,
+                                                    7 * 24 * 60 * 60 * 1000,
                                                 ),
                                         ).length
                                     }

+ 183 - 0
src/components/MediaGalleryModal.jsx

@@ -0,0 +1,183 @@
+import React, { useState, useEffect } from "react";
+import { API_BASE } from "../config";
+
+function MediaGalleryModal({ isOpen, onClose, onSelect, slug }) {
+    const [images, setImages] = useState([]);
+    const [loading, setLoading] = useState(false);
+    const [uploading, setUploading] = useState(false);
+    const [error, setError] = useState(null);
+
+    useEffect(() => {
+        if (isOpen) {
+            fetchMedia();
+        }
+    }, [isOpen, slug]);
+
+    const fetchMedia = async () => {
+        try {
+            setLoading(true);
+            const url = slug
+                ? `${API_BASE}/media?slug=${encodeURIComponent(slug)}`
+                : `${API_BASE}/media`;
+
+            const response = await fetch(url, { credentials: "include" });
+            if (!response.ok) throw new Error("Failed to load media");
+            const data = await response.json();
+            setImages(data);
+        } catch (err) {
+            console.error("Fetch media error:", err);
+            setError("Could not load images.");
+        } finally {
+            setLoading(false);
+        }
+    };
+
+    const handleUpload = async (e) => {
+        const file = e.target.files[0];
+        if (!file) return;
+
+        try {
+            setUploading(true);
+            const formData = new FormData();
+            formData.append("file", file);
+
+            const uploadUrl = slug
+                ? `${API_BASE}/upload?slug=${encodeURIComponent(slug)}`
+                : `${API_BASE}/upload`;
+
+            const response = await fetch(uploadUrl, {
+                method: "POST",
+                body: formData,
+                credentials: "include",
+            });
+
+            if (!response.ok) throw new Error("Upload failed");
+
+            await fetchMedia(); // Refresh list
+        } catch (err) {
+            setError(err.message);
+        } finally {
+            setUploading(false);
+        }
+    };
+
+    const handleDelete = async (path, e) => {
+        e.stopPropagation();
+        if (!window.confirm("Delete this image?")) return;
+
+        try {
+            const response = await fetch(`${API_BASE}/media`, {
+                method: "DELETE",
+                headers: { "Content-Type": "application/json" },
+                body: JSON.stringify({ path }),
+                credentials: "include",
+            });
+
+            if (!response.ok) throw new Error("Delete failed");
+            await fetchMedia();
+        } catch (err) {
+            alert(err.message);
+        }
+    };
+
+    if (!isOpen) return null;
+
+    return (
+        <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
+            <div className="bg-white rounded-xl shadow-2xl w-full max-w-4xl max-h-[80vh] flex flex-col overflow-hidden">
+                {/* Header */}
+                <div className="px-6 py-4 border-b flex justify-between items-center bg-gray-50">
+                    <h3 className="text-lg font-bold text-gray-800">Media Gallery</h3>
+                    <div className="flex items-center space-x-4">
+                        <label className="cursor-pointer bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium">
+                            {uploading ? "Uploading..." : "Upload Image"}
+                            <input
+                                type="file"
+                                className="hidden"
+                                accept="image/*"
+                                onChange={handleUpload}
+                                disabled={uploading}
+                            />
+                        </label>
+                        <button
+                            onClick={onClose}
+                            className="text-gray-500 hover:text-gray-700"
+                        >
+                            <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" /></svg>
+                        </button>
+                    </div>
+                </div>
+
+                {/* Content */}
+                <div className="p-6 overflow-y-auto flex-grow bg-gray-100">
+                    {error && (
+                        <div className="mb-4 p-3 bg-red-100 text-red-700 rounded-lg">
+                            {error}
+                        </div>
+                    )}
+
+                    {loading ? (
+                        <div className="flex justify-center py-12">
+                            <div className="animate-spin h-8 w-8 border-4 border-blue-500 rounded-full border-t-transparent"></div>
+                        </div>
+                    ) : images.length === 0 ? (
+                        <div className="text-center py-12 text-gray-500">
+                            No images found in this folder.
+                        </div>
+                    ) : (
+                        <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
+                            {images.map((img) => (
+                                <div
+                                    key={img.name}
+                                    className="group relative bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-shadow cursor-pointer aspect-square"
+                                    onClick={() => onSelect(`${API_BASE}${img.url}`, img.name)} // Pass full URL for valid backend access? 
+                                // Actually, if we serve static files via /posts, and API_BASE is e.g. localhost:3001, we want the proxy handling or direct URL.
+                                // But markdown needs to work in PROD.
+                                // If we use relative URL `/api/posts/...` no.
+                                // We exposed `/posts` statically in server.js.
+                                // So access is `http://localhost:3001/posts/...`
+                                // Ideally we return absolute URL or relative to root if frontend acts as proxy.
+                                // Currently frontend (Vite) proxies /api.
+                                // We need to proxy /posts too or use full URL.
+                                // Let's use full URL for safety or decide based on config.
+                                // Better: Return relative path `/api/posts/...` ? No, `server.js` serves at `/posts`.
+                                // So `img.url` is likely `/posts/...` from the API response logic.
+                                // See server.js: `urlPath = /posts/...`
+                                // So we just need `API_BASE + img.url` IF `API_BASE` points to backend.
+                                >
+                                    <img
+                                        src={`${API_BASE}${img.url}`}
+                                        alt={img.name}
+                                        className="w-full h-full object-cover"
+                                        loading="lazy"
+                                    />
+                                    <div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors" />
+
+                                    {/* Delete Button */}
+                                    <button
+                                        onClick={(e) => handleDelete(img.url, e)}
+                                        className="absolute top-2 right-2 p-1.5 bg-red-500 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity hover:bg-red-600"
+                                        title="Delete"
+                                    >
+                                        <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
+                                    </button>
+
+                                    {/* Name Label */}
+                                    <div className="absolute bottom-0 left-0 right-0 bg-black/60 text-white text-xs p-2 truncate">
+                                        {img.name}
+                                    </div>
+                                </div>
+                            ))}
+                        </div>
+                    )}
+                </div>
+
+                <div className="p-4 bg-gray-50 border-t text-sm text-gray-500 text-center">
+                    Click an image to insert it into the editor.
+                </div>
+            </div>
+        </div>
+    );
+}
+
+export default MediaGalleryModal;

+ 134 - 0
src/components/MediaManager.jsx

@@ -0,0 +1,134 @@
+import React, { useState, useEffect } from "react";
+import { Link } from "react-router-dom";
+import { API_BASE } from "../config";
+
+function MediaManager() {
+    const [posts, setPosts] = useState([]);
+    const [selectedSlug, setSelectedSlug] = useState(""); // "" = General Uploads
+    const [mediaFiles, setMediaFiles] = useState([]);
+    const [loading, setLoading] = useState(false);
+
+    useEffect(() => {
+        fetchPosts();
+        fetchMedia(""); // Load general uploads by default
+    }, []);
+
+    const fetchPosts = async () => {
+        try {
+            const res = await fetch(`${API_BASE}/posts`);
+            if (res.ok) {
+                const data = await res.json();
+                setPosts(data);
+            }
+        } catch (err) {
+            console.error(err);
+        }
+    };
+
+    const fetchMedia = async (slug) => {
+        try {
+            setLoading(true);
+            setSelectedSlug(slug);
+            const url = slug
+                ? `${API_BASE}/media?slug=${encodeURIComponent(slug)}`
+                : `${API_BASE}/media`; // No slug = general uploads (as per backend logic default)
+
+            const res = await fetch(url, { credentials: "include" });
+            if (res.ok) {
+                const data = await res.json();
+                setMediaFiles(data);
+            } else {
+                setMediaFiles([]);
+            }
+        } catch (err) {
+            console.error(err);
+        } finally {
+            setLoading(false);
+        }
+    };
+
+    const handleDelete = async (path) => {
+        if (!confirm("Delete this file?")) return;
+        try {
+            const res = await fetch(`${API_BASE}/media`, {
+                method: "DELETE",
+                headers: { "Content-Type": "application/json" },
+                body: JSON.stringify({ path }),
+                credentials: "include"
+            });
+            if (res.ok) {
+                fetchMedia(selectedSlug);
+            }
+        } catch (err) {
+            alert("Failed to delete");
+        }
+    };
+
+    return (
+        <div className="min-h-screen theme-bg">
+            <div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
+                <div className="flex justify-between items-center mb-6">
+                    <h1 className="text-3xl font-bold theme-text">Media Manager</h1>
+                    <Link to="/admin" className="text-blue-500 hover:underline">Back to Admin</Link>
+                </div>
+
+                <div className="grid grid-cols-1 md:grid-cols-4 gap-6">
+                    {/* Sidebar: Message folders */}
+                    <div className="md:col-span-1 bg-white rounded-lg shadow overflow-hidden">
+                        <div className="p-4 bg-gray-50 border-b font-medium">Folders</div>
+                        <ul className="divide-y max-h-[70vh] overflow-y-auto">
+                            <li
+                                className={`p-3 cursor-pointer hover:bg-blue-50 ${selectedSlug === "" ? "bg-blue-100 font-semibold" : ""}`}
+                                onClick={() => fetchMedia("")}
+                            >
+                                📁 General Uploads
+                            </li>
+                            {posts.map(post => (
+                                <li
+                                    key={post.slug}
+                                    className={`p-3 cursor-pointer hover:bg-blue-50 text-sm truncate ${selectedSlug === post.slug ? "bg-blue-100 font-semibold" : ""}`}
+                                    onClick={() => fetchMedia(post.slug)}
+                                >
+                                    📁 {post.title}
+                                </li>
+                            ))}
+                        </ul>
+                    </div>
+
+                    {/* Main: Gallery */}
+                    <div className="md:col-span-3 bg-white rounded-lg shadow p-6">
+                        <h2 className="text-xl font-bold mb-4">
+                            {selectedSlug ? `Images for: ${selectedSlug}` : "General Uploads"}
+                        </h2>
+
+                        {loading ? (
+                            <p>Loading...</p>
+                        ) : mediaFiles.length === 0 ? (
+                            <p className="text-gray-500">No images found in this folder.</p>
+                        ) : (
+                            <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
+                                {mediaFiles.map(file => (
+                                    <div key={file.name} className="relative group border rounded overflow-hidden aspect-square flex items-center justify-center bg-gray-100">
+                                        <img src={`${API_BASE}${file.url}`} alt={file.name} className="max-h-full max-w-full object-contain" loading="lazy" />
+                                        <button
+                                            onClick={() => handleDelete(file.url)}
+                                            className="absolute top-2 right-2 bg-red-600 text-white p-1 rounded opacity-0 group-hover:opacity-100 transition-opacity"
+                                            title="Delete"
+                                        >
+                                            🗑️
+                                        </button>
+                                        <div className="absolute bottom-0 w-full bg-black/60 text-white text-xs p-1 truncate text-center">
+                                            {file.name}
+                                        </div>
+                                    </div>
+                                ))}
+                            </div>
+                        )}
+                    </div>
+                </div>
+            </div>
+        </div>
+    );
+}
+
+export default MediaManager;

+ 287 - 79
src/components/PostEditor.jsx

@@ -1,30 +1,50 @@
-import React, { useState, useEffect } from "react";
+import React, { useState, useEffect, useMemo } from "react";
 import { useNavigate, useParams, Link } from "react-router-dom";
-import MDEditor from "@uiw/react-md-editor";
+import MDEditor, { commands } from "@uiw/react-md-editor";
 import "@uiw/react-md-editor/markdown-editor.css";
-import "@uiw/react-markdown-preview/markdown.css";
 import { API_BASE } from "../config";
+import { createMarkdownParser } from "../utils/markdownParser";
+import DOMPurify from "dompurify";
+import MediaGalleryModal from "./MediaGalleryModal";
 
-// Note: MDEditor handles its own markdown processing for the editor interface
-// The final blog rendering uses the MarkdownIt instance in App.jsx
-// This separation prevents footnote duplication issues
+// 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 [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();
@@ -40,19 +60,19 @@ function PostEditor() {
             if (!response.ok) throw new Error("Failed to fetch post");
 
             const post = await response.json();
-
-            // Extract content without frontmatter
             let content = post.content;
             content = content.replace(/^title:.*$/m, "");
             content = content.replace(/^desc:.*$/m, "");
             content = content.replace(/^tags:.*$/m, "");
-            content = content.replace(/^\n+/, ""); // Remove leading newlines
+            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);
@@ -65,6 +85,76 @@ function PostEditor() {
         setFormData((prev) => ({ ...prev, [field]: value }));
     };
 
+    const insertTextAtCursor = (text) => {
+        setFormData(prev => ({
+            ...prev,
+            content: prev.content + "\n" + text
+        }));
+    };
+
+    // 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: (
+                <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
+            ),
+            execute: (state, api) => {
+                setGalleryOpen(true);
+            },
+        };
+
+        const compareCommand = {
+            name: "compare",
+            keyCommand: "compare",
+            buttonProps: { "aria-label": "Insert Comparison", title: "Insert Comparison" },
+            icon: (
+                <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" /></svg>
+            ),
+            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: (
+                <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7" /></svg>
+            ),
+            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: (
+                <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /></svg>
+            ),
+            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()) {
@@ -84,6 +174,7 @@ function PostEditor() {
                     .split(",")
                     .map((tag) => tag.trim())
                     .filter((tag) => tag),
+                hidden: formData.hidden,
             };
 
             const url = isEditing
@@ -115,19 +206,27 @@ function PostEditor() {
         }
     };
 
-    if (loading) {
-        return (
-            <div className="min-h-screen theme-bg flex items-center justify-center">
-                <div className="text-center">
-                    <div className="animate-spin rounded-full h-12 w-12 border-b-2 theme-primary mx-auto"></div>
-                    <p className="mt-4 theme-text-secondary">Loading post...</p>
-                </div>
-            </div>
-        );
-    }
+    // Process markdown for preview
+    const renderContent = () => {
+        return md.render(formData.content || "");
+    };
+
+    const previewHtml = activeTab === "preview" ? renderContent() : "";
+    const sanitizedPreview = activeTab === "preview" ? DOMPurify.sanitize(previewHtml, {
+        ADD_TAGS: ["input"],
+        ADD_ATTR: ["type", "min", "max", "value", "step", "style", "width", "height", "class"],
+    }) : "";
+
 
     return (
         <div className="min-h-screen theme-bg">
+            <MediaGalleryModal
+                isOpen={galleryOpen}
+                onClose={() => setGalleryOpen(false)}
+                onSelect={handleImageSelect}
+                slug={getSlugForUpload()}
+            />
+
             <div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
                 {/* Header */}
                 <div className="theme-surface shadow rounded-lg mb-6">
@@ -259,78 +358,187 @@ function PostEditor() {
                                     Separate tags with commas
                                 </p>
                             </div>
+
+                            <div className="flex items-center">
+                                <input
+                                    type="checkbox"
+                                    id="hidden"
+                                    checked={formData.hidden}
+                                    onChange={(e) =>
+                                        handleInputChange(
+                                            "hidden",
+                                            e.target.checked
+                                        )
+                                    }
+                                    className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
+                                />
+                                <label
+                                    htmlFor="hidden"
+                                    className="ml-2 block text-sm theme-text"
+                                >
+                                    Admin Only (Hidden from public)
+                                </label>
+                            </div>
                         </div>
                     </div>
 
                     {/* Content Editor */}
                     <div className="theme-surface shadow rounded-lg">
-                        <div className="px-6 py-4 border-b theme-border">
+                        <div className="px-6 py-4 border-b theme-border flex flex-col sm:flex-row justify-between items-center space-y-3 sm:space-y-0">
                             <h2 className="text-lg font-semibold theme-text">
                                 Content
                             </h2>
-                            <p className="text-sm theme-text-secondary mt-1">
-                                Use the WYSIWYG editor below. Click the tabs to
-                                switch between Edit and Preview modes.
-                            </p>
+                            <div className="flex space-x-4">
+                                {/* Custom Tabs */}
+                                <div className="flex space-x-2 bg-gray-100 p-1 rounded-lg border theme-border">
+                                    <button
+                                        type="button"
+                                        onClick={() => setActiveTab("write")}
+                                        className={`px-4 py-1.5 rounded-md text-sm font-medium transition-all ${activeTab === "write"
+                                            ? "bg-white text-blue-600 shadow-sm"
+                                            : "text-gray-500 hover:text-gray-700"
+                                            }`}
+                                    >
+                                        Write
+                                    </button>
+                                    <button
+                                        type="button"
+                                        onClick={() => setActiveTab("preview")}
+                                        className={`px-4 py-1.5 rounded-md text-sm font-medium transition-all ${activeTab === "preview"
+                                            ? "bg-white text-blue-600 shadow-sm"
+                                            : "text-gray-500 hover:text-gray-700"
+                                            }`}
+                                    >
+                                        Preview
+                                    </button>
+                                </div>
+                            </div>
                         </div>
 
                         <div className="px-6 py-4">
-                            <div className="border theme-border rounded-lg overflow-hidden">
-                                <MDEditor
-                                    value={formData.content}
-                                    onChange={(value) =>
-                                        handleInputChange(
-                                            "content",
-                                            value || "",
-                                        )
-                                    }
-                                    height={500}
-                                    preview="edit"
-                                    hideToolbar={false}
-                                    data-color-mode="light"
-                                    visibleDragBar={false}
-                                    textareaProps={{
-                                        placeholder:
-                                            "Write your post content in Markdown...",
-                                        style: {
-                                            fontSize: "14px",
-                                            fontFamily:
-                                                "ui-monospace, monospace",
-                                        },
-                                        required: true,
-                                    }}
-                                />
+                            <div className="border theme-border rounded-lg overflow-hidden min-h-[500px]">
+                                {activeTab === "write" ? (
+                                    <MDEditor
+                                        value={formData.content}
+                                        onChange={(value) =>
+                                            handleInputChange(
+                                                "content",
+                                                value || "",
+                                            )
+                                        }
+                                        commands={[...commands.getCommands(), ...customCommands]}
+                                        height={500}
+                                        preview="edit" // Hide default preview
+                                        hideToolbar={false}
+                                        visibleDragBar={false}
+                                        textareaProps={{
+                                            placeholder:
+                                                "Write your post content in Markdown...",
+                                            style: {
+                                                fontSize: "14px",
+                                                fontFamily:
+                                                    "ui-monospace, monospace",
+                                            },
+                                            required: true,
+                                        }}
+                                    />
+                                ) : (
+                                    <div
+                                        className="p-8 bg-white markdown-content"
+                                        dangerouslySetInnerHTML={{ __html: sanitizedPreview }}
+                                        ref={(node) => {
+                                            if (node) {
+                                                // Initialize comparison sliders if present in preview
+                                                const sliders = node.querySelectorAll(".comparison-slider");
+                                                sliders.forEach(slider => {
+                                                    slider.oninput = (e) => {
+                                                        const container = e.target.closest(".comparison-wrapper");
+                                                        const topImage = container.querySelector(".comparison-top");
+                                                        const handle = container.querySelector(".slider-handle");
+                                                        const val = e.target.value;
+
+                                                        if (topImage) topImage.style.clipPath = `inset(0 ${100 - val}% 0 0)`;
+                                                        if (handle) handle.style.left = `${val}%`;
+                                                    }
+                                                });
+
+                                                // Initialize Zoom Reels in Preview
+                                                const zoomReels = node.querySelectorAll(".interactive-zoom-reel");
+                                                zoomReels.forEach(reel => {
+                                                    // Cleanup old listeners if any (hard in a ref callback without cleanup hook, 
+                                                    // but preview re-renders fully so nodes are new).
+                                                    const images = reel.querySelectorAll(".zoom-reel-img");
+                                                    const viewports = reel.querySelectorAll(".zoom-reel-viewport");
+                                                    const slider = reel.querySelector(".zoom-slider");
+                                                    const resetBtn = reel.querySelector(".reset-zoom");
+
+                                                    let state = { zoom: 1, panX: 0, panY: 0, isDragging: false, startX: 0, startY: 0, initialPanX: 0, initialPanY: 0 };
+                                                    const updateTransform = () => {
+                                                        images.forEach(img => {
+                                                            img.style.transform = `translate(${state.panX}px, ${state.panY}px) scale(${state.zoom})`;
+                                                        });
+                                                    };
+
+                                                    slider.oninput = (e) => {
+                                                        state.zoom = parseFloat(e.target.value);
+                                                        if (state.zoom === 1) { state.panX = 0; state.panY = 0; }
+                                                        updateTransform();
+                                                    };
+
+                                                    resetBtn.onclick = () => {
+                                                        state.zoom = 1; state.panX = 0; state.panY = 0; slider.value = 1;
+                                                        updateTransform();
+                                                    };
+
+                                                    viewports.forEach(vp => {
+                                                        vp.onmousedown = (e) => {
+                                                            if (state.zoom <= 1) return;
+                                                            e.preventDefault();
+                                                            state.isDragging = true;
+                                                            state.startX = e.clientX;
+                                                            state.startY = e.clientY;
+                                                            state.initialPanX = state.panX;
+                                                            state.initialPanY = state.panY;
+                                                            viewports.forEach(v => v.style.cursor = "grabbing");
+                                                        };
+                                                    });
+
+                                                    // Note: mousemove/up on document won't easily work scoped here without leaking listeners 
+                                                    // or extensive cleanup logic which Ref callback doesn't support well.
+                                                    // RESTRICTION: In Preview, drag might only work if mouse stays over the element if we attach to node, 
+                                                    // or we accept leak (bad). 
+                                                    // Compromise: Attach to 'reel' valid for preview.
+                                                    reel.onmousemove = (e) => {
+                                                        if (!state.isDragging) return;
+                                                        e.preventDefault();
+                                                        const dx = e.clientX - state.startX;
+                                                        const dy = e.clientY - state.startY;
+                                                        state.panX = state.initialPanX + dx;
+                                                        state.panY = state.initialPanY + dy;
+                                                        updateTransform();
+                                                    }
+                                                    reel.onmouseup = () => {
+                                                        state.isDragging = false;
+                                                        viewports.forEach(v => v.style.cursor = "grab");
+                                                    };
+                                                    reel.onmouseleave = () => {
+                                                        state.isDragging = false;
+                                                        viewports.forEach(v => v.style.cursor = "grab");
+                                                    };
+                                                });
+                                            }
+                                        }}
+                                    />
+                                )}
                             </div>
+
                             <div className="mt-3 text-xs theme-text-secondary bg-blue-50 p-3 rounded-lg">
-                                <p>
-                                    <strong>WYSIWYG Editor Features:</strong>
-                                </p>
+                                <p><strong>Tips:</strong></p>
                                 <ul className="list-disc list-inside mt-1 space-y-1">
-                                    <li>
-                                        Toggle between <strong>Edit</strong>,{" "}
-                                        <strong>Preview</strong>, and{" "}
-                                        <strong>Live</strong> modes using the
-                                        tabs
-                                    </li>
-                                    <li>
-                                        Use toolbar buttons for quick formatting
-                                        (bold, italic, headers, lists, etc.)
-                                    </li>
-                                    <li>
-                                        Standard markdown features: tables,
-                                        footnotes, emoji (:emoji:), HTML support
-                                    </li>
-                                    <li>
-                                        Collapsible content: Use{" "}
-                                        <code>
-                                            &lt;details&gt;&lt;summary&gt;
-                                        </code>{" "}
-                                        HTML tags
-                                    </li>
-                                    <li>
-                                        Drag the divider to resize edit/preview
-                                        panes in Live mode
-                                    </li>
+                                    <li><strong>Media:</strong> Use the Media button to upload images.</li>
+                                    <li><strong>Zoom Reel:</strong> <code>```zoom-reel</code> block with images. Synchronized pan/zoom.</li>
+                                    <li><strong>Comparison:</strong> Custom <code>```compare</code> block with 2 images inside.</li>
+                                    <li><strong>Image Resize:</strong> <code>![Alt =300x200](url)</code></li>
                                 </ul>
                             </div>
                         </div>
@@ -352,8 +560,8 @@ function PostEditor() {
                             {saving
                                 ? "Saving..."
                                 : isEditing
-                                  ? "Update Post"
-                                  : "Create Post"}
+                                    ? "Update Post"
+                                    : "Create Post"}
                         </button>
                     </div>
                 </form>

+ 132 - 8
src/index.css

@@ -22,16 +22,16 @@ body,
  */
 body {
     font-family: var(--font-body), "Inter", "Noto Color Emoji", sans-serif;
-    background-color: var(
-        --color-background,
-        #f7fafc
-    ); /* A light gray background color */
+    background-color: var(--color-background,
+            #f7fafc);
+    /* A light gray background color */
     color: var(--color-text, #1f2937);
 }
 
 .markdown-content table {
     display: table;
-    width: auto; /* prevent stretching full screen */
+    width: auto;
+    /* prevent stretching full screen */
     max-width: max-content;
 }
 
@@ -43,12 +43,14 @@ body {
 
 /* Headings */
 .markdown-content h1 {
-    font-size: 2.25rem; /* 36px */
+    font-size: 2.25rem;
+    /* 36px */
     font-weight: 800;
     margin-top: 2.5rem;
     margin-bottom: 1rem;
     color: var(--color-text, #000000);
 }
+
 .markdown-content h2 {
     font-size: 1.75rem;
     font-weight: 700;
@@ -56,6 +58,7 @@ body {
     margin-bottom: 1rem;
     color: var(--color-text, #000000);
 }
+
 .markdown-content h3 {
     font-size: 1.5rem;
     font-weight: 600;
@@ -63,6 +66,7 @@ body {
     margin-bottom: 0.75rem;
     color: var(--color-text, #000000);
 }
+
 .markdown-content h4,
 .markdown-content h5,
 .markdown-content h6 {
@@ -94,17 +98,21 @@ body {
     margin: 1.25rem 0 1.25rem 1.5rem;
     padding-left: 1rem;
 }
+
 .markdown-content ul {
     list-style-type: disc;
 }
+
 .markdown-content ol {
     list-style-type: decimal;
 }
+
 .markdown-content li {
     margin: 0.5rem 0;
 }
-.markdown-content li > ul,
-.markdown-content li > ol {
+
+.markdown-content li>ul,
+.markdown-content li>ol {
     margin-top: 0.5rem;
     margin-bottom: 0.5rem;
 }
@@ -125,6 +133,7 @@ body {
     margin: 1.5rem 0;
     border: 1px solid var(--color-border, #374151);
 }
+
 .markdown-content pre code {
     background: none;
     color: var(--color-text, #e5e7eb);
@@ -146,6 +155,7 @@ body {
     font-style: italic;
     color: var(--color-text, #000000);
 }
+
 .markdown-content strong {
     font-weight: 700;
     color: var(--color-text, #000000);
@@ -156,6 +166,7 @@ body {
     color: var(--color-primary, #3b82f6);
     text-decoration: underline;
 }
+
 .markdown-content a:hover {
     color: var(--color-secondary, #60a5fa);
 }
@@ -176,6 +187,7 @@ body {
     width: 100%;
     font-size: 0.95rem;
 }
+
 .markdown-content th,
 .markdown-content td {
     padding: 0.75rem 1rem;
@@ -183,6 +195,7 @@ body {
     text-align: left;
     vertical-align: top;
 }
+
 .markdown-content th {
     background-color: var(--color-surface, #777777);
     font-weight: 600;
@@ -206,6 +219,7 @@ body {
     font-size: 0.8em;
     vertical-align: sub;
 }
+
 .markdown-content sup {
     font-size: 0.8em;
     vertical-align: super;
@@ -243,16 +257,19 @@ body {
     vertical-align: super;
     color: var(--color-primary, #3b82f6);
 }
+
 .markdown-content .footnotes {
     font-size: 0.9rem;
     border-top: 1px solid var(--color-border, #4b5563);
     margin-top: 2rem;
     padding-top: 1rem;
 }
+
 .markdown-content .footnotes ol {
     list-style-type: decimal;
     margin-left: 1.5rem;
 }
+
 .markdown-content .footnotes li {
     margin: 0.5rem 0;
 }
@@ -429,6 +446,113 @@ body {
     border: 1px solid var(--color-secondary, #8b5cf6);
 }
 
+
 .btn-theme-secondary:hover {
     opacity: 0.9;
 }
+
+/* --- Image Comparison Styles --- */
+.image-comparison-container {
+    width: 100%;
+    margin-top: 2rem;
+    margin-bottom: 2rem;
+    border-radius: 0.5rem;
+    overflow: hidden;
+}
+
+.comparison-wrapper {
+    position: relative;
+    width: 100%;
+    overflow: hidden;
+    /* Aspect ratio is determined by the bottom image (the 'after' image) */
+}
+
+/* The slider input is invisible but covers the whole area */
+.comparison-slider {
+    -webkit-appearance: none;
+    appearance: none;
+    background: transparent;
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    cursor: ew-resize;
+    margin: 0;
+    z-index: 20;
+    /* above overlay */
+}
+
+.comparison-slider:focus {
+    outline: none;
+}
+
+/* The vertical white line handle */
+.slider-handle {
+    pointer-events: none;
+    /* Let clicks pass through to slider */
+    background-color: white;
+    width: 2px;
+}
+
+/* --- Lightbox Styles --- */
+.lightbox-overlay {
+    position: fixed;
+    top: 0;
+    left: 0;
+    right: 0;
+    bottom: 0;
+    background-color: rgba(0, 0, 0, 0.9);
+    z-index: 9999;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    animation: fadeIn 0.2s ease-out;
+}
+
+.lightbox-content {
+    max-width: 95vw;
+    max-height: 95vh;
+    box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
+    border-radius: 4px;
+    animation: zoomIn 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
+}
+
+.lightbox-close {
+    position: absolute;
+    top: 20px;
+    right: 30px;
+    color: white;
+    font-size: 3rem;
+    cursor: pointer;
+    line-height: 1;
+    z-index: 10000;
+    opacity: 0.7;
+    transition: opacity 0.2s;
+}
+
+.lightbox-close:hover {
+    opacity: 1;
+}
+
+@keyframes fadeIn {
+    from {
+        opacity: 0;
+    }
+
+    to {
+        opacity: 1;
+    }
+}
+
+@keyframes zoomIn {
+    from {
+        transform: scale(0.9);
+        opacity: 0;
+    }
+
+    to {
+        transform: scale(1);
+        opacity: 1;
+    }
+}

+ 189 - 0
src/utils/markdownParser.js

@@ -0,0 +1,189 @@
+import MarkdownIt from "markdown-it";
+import { full as emoji } from "markdown-it-emoji";
+import footnote from "markdown-it-footnote";
+
+// Plugin for scrollable tables
+const scrollableTablesPlugin = (md) => {
+    const defaultRenderOpen =
+        md.renderer.rules.table_open ||
+        function (tokens, idx, options, env, self) {
+            return self.renderToken(tokens, idx, options);
+        };
+
+    const defaultRenderClose =
+        md.renderer.rules.table_close ||
+        function (tokens, idx, options, env, self) {
+            return self.renderToken(tokens, idx, options);
+        };
+
+    md.renderer.rules.table_open = function (tokens, idx, options, env, self) {
+        return (
+            '<div class="overflow-x-auto">' +
+            defaultRenderOpen(tokens, idx, options, env, self)
+        );
+    };
+
+    md.renderer.rules.table_close = function (tokens, idx, options, env, self) {
+        return defaultRenderClose(tokens, idx, options, env, self) + "</div>";
+    };
+};
+
+// Plugin for Image Resizing: ![Alt =100x200](url) or ![Alt =100x](url)
+const imageResizePlugin = (md) => {
+    const defaultRender =
+        md.renderer.rules.image ||
+        function (tokens, idx, options, env, self) {
+            return self.renderToken(tokens, idx, options);
+        };
+
+    md.renderer.rules.image = function (tokens, idx, options, env, self) {
+        const token = tokens[idx];
+        const alt = token.content;
+        const src = token.attrGet("src");
+
+        // Parse size from alt text
+        const sizeMatch = alt.match(/=((\d*)x(\d*))$/);
+
+        if (sizeMatch) {
+            const width = sizeMatch[2];
+            const height = sizeMatch[3];
+            const cleanAlt = alt.replace(sizeMatch[0], "").trim();
+
+            token.content = cleanAlt; // Update alt text for rendering
+            token.attrs[token.attrIndex("alt")][1] = cleanAlt;
+
+            if (width) token.attrSet("width", width);
+            if (height) token.attrSet("height", height);
+
+            // Add a style to maintain aspect ratio if only one dimension is set, 
+            // but also ensure it doesn't break responsive layouts
+            const style = [];
+            if (width) style.push(`width: ${width}px; max-width: 100%;`);
+            if (height) style.push(`height: ${height}px;`);
+            if (style.length > 0) token.attrSet("style", style.join(" "));
+        }
+
+        // Add class for lightbox interaction
+        token.attrSet("class", "markdown-image cursor-pointer hover:opacity-90 transition-opacity");
+        token.attrSet("loading", "lazy");
+
+        return defaultRender(tokens, idx, options, env, self);
+    };
+};
+
+// Plugin for Image Comparison
+// Usage:
+// ```compare
+// ![Before](url1)
+// ![After](url2)
+// ```
+// Plugin for Image Comparison
+// Usage:
+// ```compare
+// ![Before](url1)
+// ![After](url2)
+// ```
+const imageComparisonPlugin = (md) => {
+    const fence = md.renderer.rules.fence;
+    md.renderer.rules.fence = function (tokens, idx, options, env, self) {
+        const token = tokens[idx];
+        const info = token.info ? md.utils.unescapeAll(token.info).trim() : "";
+
+        if (info === "compare") {
+            const content = token.content;
+            const imageRegex = /!\[(.*?)\]\((.*?)\)/g;
+            const matches = [...content.matchAll(imageRegex)];
+
+            if (matches.length >= 2) {
+                const img1 = { alt: matches[0][1], src: matches[0][2] };
+                const img2 = { alt: matches[1][1], src: matches[1][2] };
+
+                // Using clip-path technique for better scaling
+                // Img1 is 'After' (or Top), Img2 is 'Before' (or Bottom). Usually Top overlays Bottom.
+                // Left side is typically 'Before' in standard sliders, but clip-path inset(0 50% 0 0) clips the RIGHT side.
+                // So if we clip the right side of Top, the Left side of Top remains visible.
+                // If Top is 'Before', then Left is 'Before'.
+                // Let's call matches[0] Left/Top and matches[1] Right/Bottom.
+
+                return `
+<div class="image-comparison-container my-8 interactive-comparison">
+    <div class="comparison-wrapper relative w-full overflow-hidden rounded-lg select-none group">
+        <img src="${img2.src}" alt="${img2.alt}" class="w-full h-auto block select-none" />
+        <img src="${img1.src}" alt="${img1.alt}" class="comparison-top absolute top-0 left-0 w-full h-full object-cover select-none" style="clip-path: inset(0 50% 0 0);" />
+        
+        <div class="absolute bottom-2 left-2 bg-black/50 text-white text-xs px-2 py-1 rounded backdrop-blur-sm z-20 pointer-events-none">${img1.alt}</div>
+        <div class="absolute bottom-2 right-2 bg-black/50 text-white text-xs px-2 py-1 rounded backdrop-blur-sm z-10 pointer-events-none">${img2.alt}</div>
+        
+        <input type="range" min="0" max="100" value="50" class="comparison-slider absolute top-0 left-0 w-full h-full opacity-0 cursor-col-resize z-30" />
+        
+        <div class="slider-handle absolute top-0 bottom-0 w-1 bg-white shadow-lg pointer-events-none z-20" style="left: 50%;">
+             <div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white rounded-full p-1 shadow-md text-gray-800">
+                <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg>
+                <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="rotate-180"><path d="m9 18 6-6-6-6"/></svg>
+             </div>
+        </div>
+    </div>
+</div>
+`;
+            }
+        }
+
+        // Plugin for Zoom Reel (Synchronized Crop/Zoom)
+        // Usage:
+        // ```zoom-reel
+        // ![1](url)
+        // ![2](url)
+        // ```
+        if (info === "zoom-reel") {
+            const content = token.content;
+            const imageRegex = /!\[(.*?)\]\((.*?)\)/g;
+            const matches = [...content.matchAll(imageRegex)];
+
+            if (matches.length > 0) {
+                let imagesHtml = matches.map(m => `
+                    <div class="zoom-reel-viewport relative overflow-hidden bg-gray-100 dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 aspect-[4/3] cursor-grab active:cursor-grabbing touch-none">
+                        <img src="${m[2]}" alt="${m[1]}" class="zoom-reel-img origin-top-left absolute top-0 left-0 w-full h-full object-contain pointer-events-none select-none will-change-transform" />
+                        <div class="absolute bottom-1 left-1 bg-black/50 text-white text-[10px] px-1 rounded pointer-events-none">${m[1]}</div>
+                    </div>
+                `).join("");
+
+                // Grid layout logic
+                let gridClass = "grid-cols-1";
+                if (matches.length === 2) gridClass = "grid-cols-2";
+                if (matches.length >= 3) gridClass = "grid-cols-3";
+
+                return `
+<div class="zoom-reel-container my-8 interactive-zoom-reel select-none">
+    <div class="zoom-reel-grid grid ${gridClass} gap-4 mb-4">
+        ${imagesHtml}
+    </div>
+    <div class="zoom-controls flex items-center bg-gray-100 dark:bg-gray-800 p-2 rounded-lg gap-4">
+        <span class="text-xs font-bold uppercase text-gray-500">Zoom</span>
+        <input type="range" min="1" max="5" step="0.01" value="1" class="zoom-slider w-full h-2 bg-gray-300 rounded-lg appearance-none cursor-pointer accent-blue-600" />
+        <button class="reset-zoom text-xs bg-blue-600 hover:bg-blue-700 text-white px-3 py-1 rounded transition-colors">Reset</button>
+    </div>
+    <p class="text-xs text-center text-gray-500 mt-2">Drag image to pan. Use slider to zoom all images synchronously.</p>
+</div>
+`;
+            }
+        }
+
+        return fence(tokens, idx, options, env, self);
+    };
+};
+
+export const createMarkdownParser = () => {
+    const md = new MarkdownIt({
+        html: true,
+        linkify: true,
+        typographer: true,
+        breaks: false,
+    })
+        .use(scrollableTablesPlugin)
+        .use(emoji)
+        .use(footnote)
+        .use(imageResizePlugin)
+        .use(imageComparisonPlugin);
+
+    return md;
+};