|
@@ -1,15 +1,16 @@
|
|
|
-import React, { useState, useEffect } from "react";
|
|
|
|
|
|
|
+
|
|
|
|
|
+import React, { useState } from "react";
|
|
|
import {
|
|
import {
|
|
|
BrowserRouter as Router,
|
|
BrowserRouter as Router,
|
|
|
Routes,
|
|
Routes,
|
|
|
Route,
|
|
Route,
|
|
|
- Link,
|
|
|
|
|
- useParams,
|
|
|
|
|
} from "react-router-dom";
|
|
} from "react-router-dom";
|
|
|
-import DOMPurify from "dompurify";
|
|
|
|
|
-import { AuthProvider, useAuth } from "./contexts/AuthContext";
|
|
|
|
|
|
|
+import { AuthProvider } from "./contexts/AuthContext";
|
|
|
import { ThemeProvider } from "./contexts/ThemeContext";
|
|
import { ThemeProvider } from "./contexts/ThemeContext";
|
|
|
|
|
+
|
|
|
// Lazy load components for performance
|
|
// 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 AdminDashboard = React.lazy(() => import("./components/AdminDashboard"));
|
|
|
const PostEditor = React.lazy(() => import("./components/PostEditor"));
|
|
const PostEditor = React.lazy(() => import("./components/PostEditor"));
|
|
|
const LoginForm = React.lazy(() => import("./components/LoginForm"));
|
|
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"));
|
|
const MediaManager = React.lazy(() => import("./components/MediaManager"));
|
|
|
|
|
|
|
|
import ProtectedRoute from "./components/ProtectedRoute";
|
|
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
|
|
// Loading Fallback Component
|
|
|
const LoadingSpinner = () => (
|
|
const LoadingSpinner = () => (
|
|
@@ -31,7 +27,6 @@ const LoadingSpinner = () => (
|
|
|
</div>
|
|
</div>
|
|
|
);
|
|
);
|
|
|
|
|
|
|
|
-
|
|
|
|
|
// Lightbox Component
|
|
// Lightbox Component
|
|
|
const Lightbox = ({ src, alt, onClose }) => {
|
|
const Lightbox = ({ src, alt, onClose }) => {
|
|
|
if (!src) return null;
|
|
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() {
|
|
function App() {
|
|
|
const [lightboxOpen, setLightboxOpen] = useState(false);
|
|
const [lightboxOpen, setLightboxOpen] = useState(false);
|
|
|
const [lightboxImage, setLightboxImage] = useState({ src: "", alt: "" });
|
|
const [lightboxImage, setLightboxImage] = useState({ src: "", alt: "" });
|