| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369 |
- 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";
- import { createRoot } from "react-dom/client";
- import CsvGraph from "./CsvGraph";
- 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);
- });
- // 4. Mount CSV Graphs
- const graphWrappers = document.querySelectorAll('.csv-graph-wrapper');
- const graphRoots = [];
- graphWrappers.forEach(wrapper => {
- // Check if already mounted to avoid double mount
- if (wrapper.dataset.mounted) return;
- const dataElement = wrapper.querySelector('.csv-graph-data');
- if (dataElement) {
- const rawData = dataElement.textContent;
- // 1. Clear and prepare container
- wrapper.innerHTML = '';
- const container = document.createElement('div');
- container.style.width = '100%';
- wrapper.appendChild(container);
- // 2. Create root and render
- const root = createRoot(container);
- root.render(<CsvGraph rawData={rawData} />);
- wrapper.dataset.mounted = "true";
- graphRoots.push(root);
- }
- });
- return () => {
- sliders.forEach(slider => {
- slider.removeEventListener("input", handleSliderInput);
- });
- lightboxImages.forEach(img => {
- img.removeEventListener("click", handleImageClick);
- });
- cleanupZoomReels.forEach(cleanup => cleanup());
- // Unmount graphs
- graphRoots.forEach(root => setTimeout(() => root.unmount(), 0));
- };
- }, [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", "style", "data-line"],
- });
- 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;
|