PostView.jsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369
  1. import React, { useState, useEffect } from "react";
  2. import { Link, useParams } from "react-router-dom";
  3. import DOMPurify from "dompurify";
  4. import { API_BASE } from "../config";
  5. import { createMarkdownParser } from "../utils/markdownParser";
  6. import Layout, { SkeletonPost } from "./Layout";
  7. import { createRoot } from "react-dom/client";
  8. import CsvGraph from "./CsvGraph";
  9. const md = createMarkdownParser();
  10. function PostView({ onImageClick }) {
  11. const { slug } = useParams();
  12. const [post, setPost] = useState(null);
  13. const [loading, setLoading] = useState(true);
  14. const [error, setError] = useState(null);
  15. useEffect(() => {
  16. async function fetchPost() {
  17. try {
  18. setLoading(true);
  19. const response = await fetch(`${API_BASE}/posts/${slug}`);
  20. if (!response.ok) throw new Error("Post not found");
  21. const postData = await response.json();
  22. setPost(postData);
  23. // Update document title
  24. document.title = postData.title || "GoonBlog";
  25. } catch (e) {
  26. console.error("Error fetching post:", e);
  27. setError(e.message);
  28. } finally {
  29. setLoading(false);
  30. }
  31. }
  32. if (slug) {
  33. fetchPost();
  34. }
  35. }, [slug]);
  36. useEffect(() => {
  37. if (post) {
  38. const setMeta = (name, content) => {
  39. let element = document.querySelector(`meta[name="${name}"]`);
  40. if (!element) {
  41. element = document.createElement("meta");
  42. element.setAttribute("name", name);
  43. document.head.appendChild(element);
  44. }
  45. element.setAttribute("content", content);
  46. };
  47. setMeta("og:title", post.title);
  48. setMeta("og:description", post.description);
  49. setMeta("og:type", "article");
  50. setMeta("og:url", window.location.href);
  51. setMeta("twitter:title", post.title);
  52. setMeta("twitter:description", post.description);
  53. setMeta("twitter:card", "summary");
  54. setMeta("twitter:url", window.location.href);
  55. }
  56. return () => {
  57. const metaTags = [
  58. "og:title",
  59. "og:description",
  60. "og:type",
  61. "og:url",
  62. "og:image",
  63. "twitter:title",
  64. "twitter:description",
  65. "twitter:card",
  66. "twitter:url",
  67. "twitter:image",
  68. ];
  69. metaTags.forEach((name) => {
  70. const element = document.querySelector(`meta[name="${name}"]`);
  71. if (element) {
  72. element.remove();
  73. }
  74. });
  75. };
  76. }, [post]);
  77. useEffect(() => {
  78. // Reset title when component unmounts
  79. return () => {
  80. document.title = "GoonBlog - A Retard's Thoughts";
  81. };
  82. }, []);
  83. useEffect(() => {
  84. if (!post) return;
  85. const sliders = document.querySelectorAll(".comparison-slider");
  86. const handleSliderInput = (e) => {
  87. const container = e.target.closest(".comparison-wrapper");
  88. const topImage = container.querySelector(".comparison-top");
  89. const handle = container.querySelector(".slider-handle");
  90. const val = e.target.value;
  91. if (topImage) topImage.style.clipPath = `inset(0 ${100 - val}% 0 0)`;
  92. if (handle) handle.style.left = `${val}%`;
  93. };
  94. sliders.forEach(slider => {
  95. slider.addEventListener("input", handleSliderInput);
  96. });
  97. const zoomReels = document.querySelectorAll(".interactive-zoom-reel");
  98. const cleanupZoomReels = [];
  99. zoomReels.forEach(reel => {
  100. const images = reel.querySelectorAll(".zoom-reel-img");
  101. const viewports = reel.querySelectorAll(".zoom-reel-viewport");
  102. const slider = reel.querySelector(".zoom-slider");
  103. const resetBtn = reel.querySelector(".reset-zoom");
  104. let state = {
  105. zoom: 1,
  106. panX: 0,
  107. panY: 0,
  108. isDragging: false,
  109. startX: 0,
  110. startY: 0,
  111. initialPanX: 0,
  112. initialPanY: 0
  113. };
  114. const updateTransform = () => {
  115. images.forEach(img => {
  116. img.style.transform = `translate(${state.panX}px, ${state.panY}px) scale(${state.zoom})`;
  117. });
  118. };
  119. const handleZoomInput = (e) => {
  120. state.zoom = parseFloat(e.target.value);
  121. // Reset pan if zoom is 1
  122. if (state.zoom === 1) {
  123. state.panX = 0;
  124. state.panY = 0;
  125. }
  126. updateTransform();
  127. };
  128. const handleReset = () => {
  129. state.zoom = 1;
  130. state.panX = 0;
  131. state.panY = 0;
  132. if (slider) slider.value = 1;
  133. updateTransform();
  134. };
  135. // Drag Logic for Viewports
  136. const handleMouseDown = (e) => {
  137. if (state.zoom <= 1) return; // Only pan if zoomed in
  138. e.preventDefault(); // Prevent standard drag
  139. state.isDragging = true;
  140. state.startX = e.clientX;
  141. state.startY = e.clientY;
  142. state.initialPanX = state.panX;
  143. state.initialPanY = state.panY;
  144. viewports.forEach(vp => vp.style.cursor = "grabbing");
  145. };
  146. const handleMouseMove = (e) => {
  147. if (!state.isDragging) return;
  148. e.preventDefault();
  149. const dx = e.clientX - state.startX;
  150. const dy = e.clientY - state.startY;
  151. state.panX = state.initialPanX + dx;
  152. state.panY = state.initialPanY + dy;
  153. updateTransform();
  154. };
  155. const handleMouseUp = () => {
  156. state.isDragging = false;
  157. viewports.forEach(vp => vp.style.cursor = "grab");
  158. };
  159. if (slider) slider.addEventListener("input", handleZoomInput);
  160. if (resetBtn) resetBtn.addEventListener("click", handleReset);
  161. viewports.forEach(vp => {
  162. vp.addEventListener("mousedown", handleMouseDown);
  163. });
  164. // We listen to document for move/up to handle drag going outside viewport
  165. document.addEventListener("mousemove", handleMouseMove);
  166. document.addEventListener("mouseup", handleMouseUp);
  167. cleanupZoomReels.push(() => {
  168. if (slider) slider.removeEventListener("input", handleZoomInput);
  169. if (resetBtn) resetBtn.removeEventListener("click", handleReset);
  170. viewports.forEach(vp => vp.removeEventListener("mousedown", handleMouseDown));
  171. document.removeEventListener("mousemove", handleMouseMove);
  172. document.removeEventListener("mouseup", handleMouseUp);
  173. });
  174. });
  175. // 3. Handle Lightbox clicks
  176. const lightboxImages = document.querySelectorAll(".markdown-content img");
  177. const handleImageClick = (e) => {
  178. // Ignore images inside comparison slider or zoom reel
  179. if (e.target.closest(".comparison-wrapper") || e.target.closest(".zoom-reel-container")) return;
  180. onImageClick(e.target.src, e.target.alt);
  181. };
  182. lightboxImages.forEach(img => {
  183. img.addEventListener("click", handleImageClick);
  184. });
  185. // 4. Mount CSV Graphs
  186. const graphWrappers = document.querySelectorAll('.csv-graph-wrapper');
  187. const graphRoots = [];
  188. graphWrappers.forEach(wrapper => {
  189. // Check if already mounted to avoid double mount
  190. if (wrapper.dataset.mounted) return;
  191. const dataElement = wrapper.querySelector('.csv-graph-data');
  192. if (dataElement) {
  193. const rawData = dataElement.textContent;
  194. // 1. Clear and prepare container
  195. wrapper.innerHTML = '';
  196. const container = document.createElement('div');
  197. container.style.width = '100%';
  198. wrapper.appendChild(container);
  199. // 2. Create root and render
  200. const root = createRoot(container);
  201. root.render(<CsvGraph rawData={rawData} />);
  202. wrapper.dataset.mounted = "true";
  203. graphRoots.push(root);
  204. }
  205. });
  206. return () => {
  207. sliders.forEach(slider => {
  208. slider.removeEventListener("input", handleSliderInput);
  209. });
  210. lightboxImages.forEach(img => {
  211. img.removeEventListener("click", handleImageClick);
  212. });
  213. cleanupZoomReels.forEach(cleanup => cleanup());
  214. // Unmount graphs
  215. graphRoots.forEach(root => setTimeout(() => root.unmount(), 0));
  216. };
  217. }, [post, onImageClick]);
  218. if (loading) {
  219. return (
  220. <Layout>
  221. <SkeletonPost />
  222. </Layout>
  223. );
  224. }
  225. if (error || !post) {
  226. return (
  227. <Layout>
  228. <div className="text-center">
  229. <h2 className="text-2xl font-bold theme-text mb-2">
  230. Post Not Found
  231. </h2>
  232. <p className="theme-text-secondary mb-4">
  233. {error || "The requested post could not be found."}
  234. </p>
  235. <Link
  236. to="/"
  237. className="theme-primary hover:theme-secondary font-medium"
  238. >
  239. ← Back to Home
  240. </Link>
  241. </div>
  242. </Layout>
  243. );
  244. }
  245. const conceiveFoxFromSemen = (rawMarkdown) => {
  246. let processedText = rawMarkdown;
  247. let tags = null;
  248. let imageCredit = null;
  249. let imageSrc = null;
  250. let imageAlt = null;
  251. let customQuestion = null;
  252. const tagsRegex = /tags: (.*)/;
  253. const tagsMatch = processedText.match(tagsRegex);
  254. if (tagsMatch) {
  255. tags = tagsMatch[1].split(",").map((tag) => tag.trim());
  256. processedText = processedText.replace(tagsRegex, "").trim();
  257. }
  258. const imageRegex = /!\[(.*?)\]\((.*?)\)\n_Image credit: (.*?)_/;
  259. const imageMatch = processedText.match(imageRegex);
  260. if (imageMatch) {
  261. imageAlt = imageMatch[1];
  262. imageSrc = imageMatch[2];
  263. imageCredit = imageMatch[3];
  264. processedText = processedText.replace(imageRegex, "").trim();
  265. }
  266. const questionRegex = /\?\?\? "(.*?)"/;
  267. const questionMatch = processedText.match(questionRegex);
  268. if (questionMatch) {
  269. customQuestion = questionMatch[1];
  270. processedText = processedText.replace(questionRegex, "").trim();
  271. }
  272. processedText = processedText
  273. .replace(/^title:.*$/m, "")
  274. .replace(/^desc:.*$/m, "");
  275. return {
  276. processedText,
  277. tags,
  278. imageSrc,
  279. imageAlt,
  280. imageCredit,
  281. customQuestion,
  282. };
  283. };
  284. const { processedText } = conceiveFoxFromSemen(post.content);
  285. const htmlContent = md.render(processedText);
  286. const sanitizedHtml = DOMPurify.sanitize(htmlContent, {
  287. ADD_TAGS: ["input"], // Allow input tags for the slider
  288. ADD_ATTR: ["type", "min", "max", "value", "step", "checked", "style", "data-line"],
  289. });
  290. return (
  291. <Layout>
  292. <div className="w-full">
  293. <div className="theme-surface theme-text border theme-border rounded-xl p-8 md:p-12 lg:p-16">
  294. <Link
  295. to="/"
  296. className="theme-text-secondary hover:theme-text transition-colors duration-200 mb-6 flex items-center"
  297. >
  298. ← Back to Home
  299. </Link>
  300. <div className="mb-8">
  301. <h1 className="text-3xl md:text-4xl font-bold theme-text mb-2 leading-tight">
  302. {post.title}
  303. </h1>
  304. <div className="text-lg italic font-light theme-text-secondary">
  305. {post.description}
  306. </div>
  307. </div>
  308. <hr className="theme-border mb-8" />
  309. <div
  310. className="markdown-content theme-text leading-relaxed text-lg"
  311. dangerouslySetInnerHTML={{
  312. __html: sanitizedHtml,
  313. }}
  314. />
  315. </div>
  316. </div>
  317. </Layout>
  318. );
  319. }
  320. export default PostView;