Parcourir la source

refactor: extract BlogHome, Layout, and PostView components from App

Adam Jafarov il y a 1 mois
Parent
commit
847daaee96
4 fichiers modifiés avec 556 ajouts et 546 suppressions
  1. 6 546
      src/App.jsx
  2. 99 0
      src/components/BlogHome.jsx
  3. 113 0
      src/components/Layout.jsx
  4. 338 0
      src/components/PostView.jsx

+ 6 - 546
src/App.jsx

@@ -1,15 +1,16 @@
-import React, { useState, useEffect } from "react";
+
+import React, { useState } from "react";
 import {
     BrowserRouter as Router,
     Routes,
     Route,
-    Link,
-    useParams,
 } from "react-router-dom";
-import DOMPurify from "dompurify";
-import { AuthProvider, useAuth } from "./contexts/AuthContext";
+import { AuthProvider } from "./contexts/AuthContext";
 import { ThemeProvider } from "./contexts/ThemeContext";
+
 // Lazy load components for performance
+const BlogHome = React.lazy(() => import("./components/BlogHome"));
+const PostView = React.lazy(() => import("./components/PostView"));
 const AdminDashboard = React.lazy(() => import("./components/AdminDashboard"));
 const PostEditor = React.lazy(() => import("./components/PostEditor"));
 const LoginForm = React.lazy(() => import("./components/LoginForm"));
@@ -18,11 +19,6 @@ const ThemeEditor = React.lazy(() => import("./components/ThemeEditor"));
 const MediaManager = React.lazy(() => import("./components/MediaManager"));
 
 import ProtectedRoute from "./components/ProtectedRoute";
-import { createMarkdownParser } from "./utils/markdownParser";
-import { API_BASE } from "./config";
-
-// Initialize the shared markdown parser
-const md = createMarkdownParser();
 
 // Loading Fallback Component
 const LoadingSpinner = () => (
@@ -31,7 +27,6 @@ const LoadingSpinner = () => (
     </div>
 );
 
-
 // Lightbox Component
 const Lightbox = ({ src, alt, onClose }) => {
     if (!src) return null;
@@ -51,541 +46,6 @@ const Lightbox = ({ src, alt, onClose }) => {
     );
 };
 
-// Navigation Header Component
-function NavHeader() {
-    const { isAdmin, user, logout } = useAuth();
-
-    const handleLogout = async () => {
-        await logout();
-    };
-
-    return (
-        <header className="headercontainer py-6 border-b theme-border flex items-center justify-between">
-            <div className="text-2xl font-bold theme-text">
-                <Link to="/">
-                    <span className="theme-primary">Goon</span>Blog
-                </Link>
-            </div>
-            <nav>
-                <ul className="flex space-x-4 items-center">
-                    <li>
-                        <Link
-                            to="/"
-                            className="theme-text-secondary hover:theme-text transition-colors duration-200 font-medium"
-                        >
-                            Home
-                        </Link>
-                    </li>
-                    {isAdmin && (
-                        <li>
-                            <Link
-                                to="/admin"
-                                className="theme-primary hover:theme-secondary transition-colors duration-200 font-medium"
-                            >
-                                Admin
-                            </Link>
-                        </li>
-                    )}
-                    {user ? (
-                        <li className="flex items-center space-x-2">
-                            <span className="text-sm theme-text-secondary">
-                                Welcome, {user.username}
-                            </span>
-                            <button
-                                onClick={handleLogout}
-                                className="text-red-600 hover:text-red-800 transition-colors duration-200 font-medium text-sm"
-                            >
-                                Logout
-                            </button>
-                        </li>
-                    ) : (
-                        <li>
-                            <Link
-                                to="/login"
-                                className="theme-primary hover:theme-secondary transition-colors duration-200 font-medium"
-                            >
-                                Login
-                            </Link>
-                        </li>
-                    )}
-                </ul>
-            </nav>
-        </header>
-    );
-}
-
-// Layout ComponentWrapper
-const Layout = ({ children }) => (
-    <div className="min-h-screen theme-bg font-sans theme-text antialiased flex flex-col">
-        <div className="max-w-5xl mx-auto w-full flex-grow">
-            <NavHeader />
-            <main className="py-10 px-4 sm:px-6 lg:px-8">
-                {children}
-            </main>
-        </div>
-    </div>
-);
-
-// Skeleton Components
-const SkeletonCard = () => (
-    <div className="theme-surface border theme-border rounded-xl p-6 flex flex-col h-full animate-pulse">
-        <div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-3/4 mb-4"></div>
-        <div className="space-y-2 flex-grow">
-            <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-full"></div>
-            <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-5/6"></div>
-        </div>
-        <div className="mt-4 h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/4"></div>
-    </div>
-);
-
-const SkeletonPost = () => (
-    <div className="w-full animate-pulse">
-        <div className="theme-surface border theme-border rounded-xl p-8 md:p-12 lg:p-16">
-            <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-6"></div>
-            <div className="mb-8">
-                <div className="h-10 bg-gray-200 dark:bg-gray-700 rounded w-3/4 mb-4"></div>
-                <div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-1/2"></div>
-            </div>
-            <hr className="theme-border mb-8" />
-            <div className="space-y-4">
-                <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-full"></div>
-                <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-full"></div>
-                <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-5/6"></div>
-                <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-full"></div>
-                <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-4/5"></div>
-            </div>
-        </div>
-    </div>
-);
-
-// Blog Home Component
-function BlogHome() {
-    const [posts, setPosts] = useState([]);
-    const [loading, setLoading] = useState(true);
-    const [error, setError] = useState(null);
-
-    useEffect(() => {
-        async function getTingyun() {
-            setLoading(true);
-            try {
-                const response = await fetch(`${API_BASE}/posts`);
-                if (!response.ok)
-                    throw new Error(
-                        `Failed to fetch posts: ${response.statusText}`,
-                    );
-                const postsData = await response.json();
-                setPosts(postsData);
-            } catch (e) {
-                console.error("Error fetching posts:", e);
-                setError(
-                    "Failed to load posts. Please check if the backend server is running.",
-                );
-            } finally {
-                setLoading(false);
-            }
-        }
-        getTingyun();
-    }, []);
-
-    if (loading) {
-        return (
-            <Layout>
-                <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
-                    {[1, 2, 3, 4, 5, 6].map((i) => (
-                        <SkeletonCard key={i} />
-                    ))}
-                </div>
-            </Layout>
-        );
-    }
-
-    if (error) {
-        return (
-            <Layout>
-                <div className="text-center">
-                    <div className="bg-red-50 border border-red-200 rounded-lg p-6 inline-block">
-                        <h2 className="text-red-800 font-semibold mb-2">
-                            Error
-                        </h2>
-                        <p className="text-red-600">{error}</p>
-                    </div>
-                </div>
-            </Layout>
-        );
-    }
-
-    return (
-        <Layout>
-            <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
-                {posts.map((post) => (
-                    <div
-                        key={post.slug}
-                        className="group cursor-pointer theme-surface border theme-border rounded-xl hover:border-blue-400 transition-colors duration-200 p-6 flex flex-col justify-between h-full"
-                    >
-                        <div>
-                            <h2 className="text-xl font-semibold theme-text group-hover:theme-primary transition-colors duration-200 mb-2">
-                                <Link to={`/posts/${post.slug}`}>
-                                    {post.title}
-                                </Link>
-                            </h2>
-                        </div>
-                        <div className="flex-grow mt-4">
-                            <div className="theme-text-secondary leading-relaxed">
-                                {post.description}
-                            </div>
-                        </div>
-                        <div className="mt-4">
-                            <Link
-                                to={`/posts/${post.slug}`}
-                                className="theme-primary font-medium hover:underline focus:outline-none"
-                            >
-                                Read more →
-                            </Link>
-                        </div>
-                    </div>
-                ))}
-            </div>
-        </Layout>
-    );
-}
-
-// Post View Component
-function PostView({ onImageClick }) {
-    const { slug } = useParams();
-    const [post, setPost] = useState(null);
-    const [loading, setLoading] = useState(true);
-    const [error, setError] = useState(null);
-
-    useEffect(() => {
-        async function fetchPost() {
-            try {
-                setLoading(true);
-                const response = await fetch(`${API_BASE}/posts/${slug}`);
-                if (!response.ok) throw new Error("Post not found");
-                const postData = await response.json();
-                setPost(postData);
-
-                // Update document title
-                document.title = postData.title || "GoonBlog";
-            } catch (e) {
-                console.error("Error fetching post:", e);
-                setError(e.message);
-            } finally {
-                setLoading(false);
-            }
-        }
-
-        if (slug) {
-            fetchPost();
-        }
-    }, [slug]);
-
-    useEffect(() => {
-        if (post) {
-            const setMeta = (name, content) => {
-                let element = document.querySelector(`meta[name="${name}"]`);
-                if (!element) {
-                    element = document.createElement("meta");
-                    element.setAttribute("name", name);
-                    document.head.appendChild(element);
-                }
-                element.setAttribute("content", content);
-            };
-
-            setMeta("og:title", post.title);
-            setMeta("og:description", post.description);
-            setMeta("og:type", "article");
-            setMeta("og:url", window.location.href);
-            setMeta("twitter:title", post.title);
-            setMeta("twitter:description", post.description);
-            setMeta("twitter:card", "summary");
-            setMeta("twitter:url", window.location.href);
-        }
-
-        return () => {
-            const metaTags = [
-                "og:title",
-                "og:description",
-                "og:type",
-                "og:url",
-                "og:image",
-                "twitter:title",
-                "twitter:description",
-                "twitter:card",
-                "twitter:url",
-                "twitter:image",
-            ];
-            metaTags.forEach((name) => {
-                const element = document.querySelector(`meta[name="${name}"]`);
-                if (element) {
-                    element.remove();
-                }
-            });
-        };
-    }, [post]);
-
-    useEffect(() => {
-        // Reset title when component unmounts
-        return () => {
-            document.title = "GoonBlog - A Retard's Thoughts";
-        };
-    }, []);
-
-    // 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 (
-            <Layout>
-                <SkeletonPost />
-            </Layout>
-        );
-    }
-
-    if (error || !post) {
-        return (
-            <Layout>
-                <div className="text-center">
-                    <h2 className="text-2xl font-bold theme-text mb-2">
-                        Post Not Found
-                    </h2>
-                    <p className="theme-text-secondary mb-4">
-                        {error || "The requested post could not be found."}
-                    </p>
-                    <Link
-                        to="/"
-                        className="theme-primary hover:theme-secondary font-medium"
-                    >
-                        ← Back to Home
-                    </Link>
-                </div>
-            </Layout>
-        );
-    }
-
-    const conceiveFoxFromSemen = (rawMarkdown) => {
-        let processedText = rawMarkdown;
-        let tags = null;
-        let imageCredit = null;
-        let imageSrc = null;
-        let imageAlt = null;
-        let customQuestion = null;
-
-        const tagsRegex = /tags: (.*)/;
-        const tagsMatch = processedText.match(tagsRegex);
-        if (tagsMatch) {
-            tags = tagsMatch[1].split(",").map((tag) => tag.trim());
-            processedText = processedText.replace(tagsRegex, "").trim();
-        }
-
-        const imageRegex = /!\[(.*?)\]\((.*?)\)\n_Image credit: (.*?)_/;
-        const imageMatch = processedText.match(imageRegex);
-        if (imageMatch) {
-            imageAlt = imageMatch[1];
-            imageSrc = imageMatch[2];
-            imageCredit = imageMatch[3];
-            processedText = processedText.replace(imageRegex, "").trim();
-        }
-
-        const questionRegex = /\?\?\? "(.*?)"/;
-        const questionMatch = processedText.match(questionRegex);
-        if (questionMatch) {
-            customQuestion = questionMatch[1];
-            processedText = processedText.replace(questionRegex, "").trim();
-        }
-        processedText = processedText
-            .replace(/^title:.*$/m, "")
-            .replace(/^desc:.*$/m, "");
-        return {
-            processedText,
-            tags,
-            imageSrc,
-            imageAlt,
-            imageCredit,
-            customQuestion,
-        };
-    };
-
-    const { processedText } = conceiveFoxFromSemen(post.content);
-    const htmlContent = md.render(processedText);
-    const sanitizedHtml = DOMPurify.sanitize(htmlContent, {
-        ADD_TAGS: ["input"], // Allow input tags for the slider
-        ADD_ATTR: ["type", "min", "max", "value", "step", "checked"],
-    });
-
-    return (
-        <Layout>
-            <div className="w-full">
-                <div className="theme-surface theme-text border theme-border rounded-xl p-8 md:p-12 lg:p-16">
-                    <Link
-                        to="/"
-                        className="theme-text-secondary hover:theme-text transition-colors duration-200 mb-6 flex items-center"
-                    >
-                        ← Back to Home
-                    </Link>
-
-                    <div className="mb-8">
-                        <h1 className="text-3xl md:text-4xl font-bold theme-text mb-2 leading-tight">
-                            {post.title}
-                        </h1>
-                        <div className="text-lg italic font-light theme-text-secondary">
-                            {post.description}
-                        </div>
-                    </div>
-
-                    <hr className="theme-border mb-8" />
-
-                    <div
-                        className="markdown-content theme-text leading-relaxed text-lg"
-                        dangerouslySetInnerHTML={{
-                            __html: sanitizedHtml,
-                        }}
-                    />
-                </div>
-            </div>
-        </Layout>
-    );
-}
-
 function App() {
     const [lightboxOpen, setLightboxOpen] = useState(false);
     const [lightboxImage, setLightboxImage] = useState({ src: "", alt: "" });

+ 99 - 0
src/components/BlogHome.jsx

@@ -0,0 +1,99 @@
+
+import React, { useState, useEffect } from "react";
+import { Link } from "react-router-dom";
+import { useAuth } from "../contexts/AuthContext";
+import { API_BASE } from "../config";
+
+import Layout, { SkeletonCard } from "./Layout";
+
+function BlogHome() {
+    const [posts, setPosts] = useState([]);
+    const [loading, setLoading] = useState(true);
+    const [error, setError] = useState(null);
+
+    useEffect(() => {
+        async function getTingyun() {
+            setLoading(true);
+            try {
+                const response = await fetch(`${API_BASE}/posts`);
+                if (!response.ok)
+                    throw new Error(
+                        `Failed to fetch posts: ${response.statusText}`,
+                    );
+                const postsData = await response.json();
+                setPosts(postsData);
+            } catch (e) {
+                console.error("Error fetching posts:", e);
+                setError(
+                    "Failed to load posts. Please check if the backend server is running.",
+                );
+            } finally {
+                setLoading(false);
+            }
+        }
+        getTingyun();
+    }, []);
+
+    if (loading) {
+        return (
+            <Layout>
+                <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
+                    {[1, 2, 3, 4, 5, 6].map((i) => (
+                        <SkeletonCard key={i} />
+                    ))}
+                </div>
+            </Layout>
+        );
+    }
+
+    if (error) {
+        return (
+            <Layout>
+                <div className="text-center">
+                    <div className="bg-red-50 border border-red-200 rounded-lg p-6 inline-block">
+                        <h2 className="text-red-800 font-semibold mb-2">
+                            Error
+                        </h2>
+                        <p className="text-red-600">{error}</p>
+                    </div>
+                </div>
+            </Layout>
+        );
+    }
+
+    return (
+        <Layout>
+            <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
+                {posts.map((post) => (
+                    <div
+                        key={post.slug}
+                        className="group cursor-pointer theme-surface border theme-border rounded-xl hover:border-blue-400 transition-colors duration-200 p-6 flex flex-col justify-between h-full"
+                    >
+                        <div>
+                            <h2 className="text-xl font-semibold theme-text group-hover:theme-primary transition-colors duration-200 mb-2">
+                                <Link to={`/posts/${post.slug}`}>
+                                    {post.title}
+                                </Link>
+                            </h2>
+                        </div>
+                        <div className="flex-grow mt-4">
+                            <div className="theme-text-secondary leading-relaxed">
+                                {post.description}
+                            </div>
+                        </div>
+                        <div className="mt-4">
+                            <Link
+                                to={`/posts/${post.slug}`}
+                                className="theme-primary font-medium hover:underline focus:outline-none"
+                            >
+                                Read more →
+                            </Link>
+                        </div>
+                    </div>
+                ))}
+            </div>
+        </Layout>
+    );
+}
+
+export default BlogHome;

+ 113 - 0
src/components/Layout.jsx

@@ -0,0 +1,113 @@
+
+import React from 'react';
+import { Link } from 'react-router-dom';
+import { useAuth } from '../contexts/AuthContext';
+
+// Navigation Header Component
+export function NavHeader() {
+    const { isAdmin, user, logout } = useAuth();
+
+    const handleLogout = async () => {
+        await logout();
+    };
+
+    return (
+        <header className="headercontainer py-6 border-b theme-border flex items-center justify-between">
+            <div className="text-2xl font-bold theme-text">
+                <Link to="/">
+                    <span className="theme-primary">Goon</span>Blog
+                </Link>
+            </div>
+            <nav>
+                <ul className="flex space-x-4 items-center">
+                    <li>
+                        <Link
+                            to="/"
+                            className="theme-text-secondary hover:theme-text transition-colors duration-200 font-medium"
+                        >
+                            Home
+                        </Link>
+                    </li>
+                    {isAdmin && (
+                        <li>
+                            <Link
+                                to="/admin"
+                                className="theme-primary hover:theme-secondary transition-colors duration-200 font-medium"
+                            >
+                                Admin
+                            </Link>
+                        </li>
+                    )}
+                    {user ? (
+                        <li className="flex items-center space-x-2">
+                            <span className="text-sm theme-text-secondary">
+                                Welcome, {user.username}
+                            </span>
+                            <button
+                                onClick={handleLogout}
+                                className="text-red-600 hover:text-red-800 transition-colors duration-200 font-medium text-sm"
+                            >
+                                Logout
+                            </button>
+                        </li>
+                    ) : (
+                        <li>
+                            <Link
+                                to="/login"
+                                className="theme-primary hover:theme-secondary transition-colors duration-200 font-medium"
+                            >
+                                Login
+                            </Link>
+                        </li>
+                    )}
+                </ul>
+            </nav>
+        </header>
+    );
+}
+
+// Layout ComponentWrapper
+const Layout = ({ children }) => (
+    <div className="min-h-screen theme-bg font-sans theme-text antialiased flex flex-col">
+        <div className="max-w-5xl mx-auto w-full flex-grow">
+            <NavHeader />
+            <main className="py-10 px-4 sm:px-6 lg:px-8">
+                {children}
+            </main>
+        </div>
+    </div>
+);
+
+// Skeleton Components
+export const SkeletonCard = () => (
+    <div className="theme-surface border theme-border rounded-xl p-6 flex flex-col h-full animate-pulse">
+        <div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-3/4 mb-4"></div>
+        <div className="space-y-2 flex-grow">
+            <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-full"></div>
+            <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-5/6"></div>
+        </div>
+        <div className="mt-4 h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/4"></div>
+    </div>
+);
+
+export const SkeletonPost = () => (
+    <div className="w-full animate-pulse">
+        <div className="theme-surface border theme-border rounded-xl p-8 md:p-12 lg:p-16">
+            <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-6"></div>
+            <div className="mb-8">
+                <div className="h-10 bg-gray-200 dark:bg-gray-700 rounded w-3/4 mb-4"></div>
+                <div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-1/2"></div>
+            </div>
+            <hr className="theme-border mb-8" />
+            <div className="space-y-4">
+                <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-full"></div>
+                <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-full"></div>
+                <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-5/6"></div>
+                <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-full"></div>
+                <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-4/5"></div>
+            </div>
+        </div>
+    </div>
+);
+
+export default Layout;

+ 338 - 0
src/components/PostView.jsx

@@ -0,0 +1,338 @@
+
+import React, { useState, useEffect } from "react";
+import { Link, useParams } from "react-router-dom";
+import DOMPurify from "dompurify";
+import { API_BASE } from "../config";
+import { createMarkdownParser } from "../utils/markdownParser";
+import Layout, { SkeletonPost } from "./Layout";
+const md = createMarkdownParser();
+
+function PostView({ onImageClick }) {
+    const { slug } = useParams();
+    const [post, setPost] = useState(null);
+    const [loading, setLoading] = useState(true);
+    const [error, setError] = useState(null);
+
+    useEffect(() => {
+        async function fetchPost() {
+            try {
+                setLoading(true);
+                const response = await fetch(`${API_BASE}/posts/${slug}`);
+                if (!response.ok) throw new Error("Post not found");
+                const postData = await response.json();
+                setPost(postData);
+
+                // Update document title
+                document.title = postData.title || "GoonBlog";
+            } catch (e) {
+                console.error("Error fetching post:", e);
+                setError(e.message);
+            } finally {
+                setLoading(false);
+            }
+        }
+
+        if (slug) {
+            fetchPost();
+        }
+    }, [slug]);
+
+    useEffect(() => {
+        if (post) {
+            const setMeta = (name, content) => {
+                let element = document.querySelector(`meta[name="${name}"]`);
+                if (!element) {
+                    element = document.createElement("meta");
+                    element.setAttribute("name", name);
+                    document.head.appendChild(element);
+                }
+                element.setAttribute("content", content);
+            };
+
+            setMeta("og:title", post.title);
+            setMeta("og:description", post.description);
+            setMeta("og:type", "article");
+            setMeta("og:url", window.location.href);
+            setMeta("twitter:title", post.title);
+            setMeta("twitter:description", post.description);
+            setMeta("twitter:card", "summary");
+            setMeta("twitter:url", window.location.href);
+        }
+
+        return () => {
+            const metaTags = [
+                "og:title",
+                "og:description",
+                "og:type",
+                "og:url",
+                "og:image",
+                "twitter:title",
+                "twitter:description",
+                "twitter:card",
+                "twitter:url",
+                "twitter:image",
+            ];
+            metaTags.forEach((name) => {
+                const element = document.querySelector(`meta[name="${name}"]`);
+                if (element) {
+                    element.remove();
+                }
+            });
+        };
+    }, [post]);
+
+    useEffect(() => {
+        // Reset title when component unmounts
+        return () => {
+            document.title = "GoonBlog - A Retard's Thoughts";
+        };
+    }, []);
+
+    useEffect(() => {
+        if (!post) return;
+
+        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;
+
+            if (topImage) topImage.style.clipPath = `inset(0 ${100 - val}% 0 0)`;
+            if (handle) handle.style.left = `${val}%`;
+        };
+
+        sliders.forEach(slider => {
+            slider.addEventListener("input", handleSliderInput);
+        });
+
+        const zoomReels = document.querySelectorAll(".interactive-zoom-reel");
+        const cleanupZoomReels = [];
+
+        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 (
+            <Layout>
+                <SkeletonPost />
+            </Layout>
+        );
+    }
+
+    if (error || !post) {
+        return (
+            <Layout>
+                <div className="text-center">
+                    <h2 className="text-2xl font-bold theme-text mb-2">
+                        Post Not Found
+                    </h2>
+                    <p className="theme-text-secondary mb-4">
+                        {error || "The requested post could not be found."}
+                    </p>
+                    <Link
+                        to="/"
+                        className="theme-primary hover:theme-secondary font-medium"
+                    >
+                        ← Back to Home
+                    </Link>
+                </div>
+            </Layout>
+        );
+    }
+
+    const conceiveFoxFromSemen = (rawMarkdown) => {
+        let processedText = rawMarkdown;
+        let tags = null;
+        let imageCredit = null;
+        let imageSrc = null;
+        let imageAlt = null;
+        let customQuestion = null;
+
+        const tagsRegex = /tags: (.*)/;
+        const tagsMatch = processedText.match(tagsRegex);
+        if (tagsMatch) {
+            tags = tagsMatch[1].split(",").map((tag) => tag.trim());
+            processedText = processedText.replace(tagsRegex, "").trim();
+        }
+
+        const imageRegex = /!\[(.*?)\]\((.*?)\)\n_Image credit: (.*?)_/;
+        const imageMatch = processedText.match(imageRegex);
+        if (imageMatch) {
+            imageAlt = imageMatch[1];
+            imageSrc = imageMatch[2];
+            imageCredit = imageMatch[3];
+            processedText = processedText.replace(imageRegex, "").trim();
+        }
+
+        const questionRegex = /\?\?\? "(.*?)"/;
+        const questionMatch = processedText.match(questionRegex);
+        if (questionMatch) {
+            customQuestion = questionMatch[1];
+            processedText = processedText.replace(questionRegex, "").trim();
+        }
+        processedText = processedText
+            .replace(/^title:.*$/m, "")
+            .replace(/^desc:.*$/m, "");
+        return {
+            processedText,
+            tags,
+            imageSrc,
+            imageAlt,
+            imageCredit,
+            customQuestion,
+        };
+    };
+
+    const { processedText } = conceiveFoxFromSemen(post.content);
+    const htmlContent = md.render(processedText);
+    const sanitizedHtml = DOMPurify.sanitize(htmlContent, {
+        ADD_TAGS: ["input"], // Allow input tags for the slider
+        ADD_ATTR: ["type", "min", "max", "value", "step", "checked"],
+    });
+
+    return (
+        <Layout>
+            <div className="w-full">
+                <div className="theme-surface theme-text border theme-border rounded-xl p-8 md:p-12 lg:p-16">
+                    <Link
+                        to="/"
+                        className="theme-text-secondary hover:theme-text transition-colors duration-200 mb-6 flex items-center"
+                    >
+                        ← Back to Home
+                    </Link>
+
+                    <div className="mb-8">
+                        <h1 className="text-3xl md:text-4xl font-bold theme-text mb-2 leading-tight">
+                            {post.title}
+                        </h1>
+                        <div className="text-lg italic font-light theme-text-secondary">
+                            {post.description}
+                        </div>
+                    </div>
+
+                    <hr className="theme-border mb-8" />
+
+                    <div
+                        className="markdown-content theme-text leading-relaxed text-lg"
+                        dangerouslySetInnerHTML={{
+                            __html: sanitizedHtml,
+                        }}
+                    />
+                </div>
+            </div>
+        </Layout>
+    );
+}
+
+export default PostView;