App.jsx 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682
  1. import React, { useState, useEffect } from "react";
  2. import {
  3. BrowserRouter as Router,
  4. Routes,
  5. Route,
  6. Link,
  7. useParams,
  8. } from "react-router-dom";
  9. import DOMPurify from "dompurify";
  10. import { AuthProvider, useAuth } from "./contexts/AuthContext";
  11. import { ThemeProvider } from "./contexts/ThemeContext";
  12. // Lazy load components for performance
  13. const AdminDashboard = React.lazy(() => import("./components/AdminDashboard"));
  14. const PostEditor = React.lazy(() => import("./components/PostEditor"));
  15. const LoginForm = React.lazy(() => import("./components/LoginForm"));
  16. const ThemesManager = React.lazy(() => import("./components/ThemesManager"));
  17. const ThemeEditor = React.lazy(() => import("./components/ThemeEditor"));
  18. const MediaManager = React.lazy(() => import("./components/MediaManager"));
  19. import ProtectedRoute from "./components/ProtectedRoute";
  20. import { createMarkdownParser } from "./utils/markdownParser";
  21. import { API_BASE } from "./config";
  22. // Initialize the shared markdown parser
  23. const md = createMarkdownParser();
  24. // Loading Fallback Component
  25. const LoadingSpinner = () => (
  26. <div className="flex justify-center items-center p-12">
  27. <div className="animate-spin rounded-full h-8 w-8 border-b-2 theme-primary"></div>
  28. </div>
  29. );
  30. // Lightbox Component
  31. const Lightbox = ({ src, alt, onClose }) => {
  32. if (!src) return null;
  33. return (
  34. <div className="lightbox-overlay" onClick={onClose}>
  35. <span className="lightbox-close" onClick={onClose}>
  36. &times;
  37. </span>
  38. <img
  39. src={src}
  40. alt={alt}
  41. className="lightbox-content"
  42. onClick={(e) => e.stopPropagation()} // Prevent closing when clicking the image
  43. />
  44. </div>
  45. );
  46. };
  47. // Navigation Header Component
  48. function NavHeader() {
  49. const { isAdmin, user, logout } = useAuth();
  50. const handleLogout = async () => {
  51. await logout();
  52. };
  53. return (
  54. <header className="headercontainer py-6 border-b theme-border flex items-center justify-between">
  55. <div className="text-2xl font-bold theme-text">
  56. <Link to="/">
  57. <span className="theme-primary">Goon</span>Blog
  58. </Link>
  59. </div>
  60. <nav>
  61. <ul className="flex space-x-4 items-center">
  62. <li>
  63. <Link
  64. to="/"
  65. className="theme-text-secondary hover:theme-text transition-colors duration-200 font-medium"
  66. >
  67. Home
  68. </Link>
  69. </li>
  70. {isAdmin && (
  71. <li>
  72. <Link
  73. to="/admin"
  74. className="theme-primary hover:theme-secondary transition-colors duration-200 font-medium"
  75. >
  76. Admin
  77. </Link>
  78. </li>
  79. )}
  80. {user ? (
  81. <li className="flex items-center space-x-2">
  82. <span className="text-sm theme-text-secondary">
  83. Welcome, {user.username}
  84. </span>
  85. <button
  86. onClick={handleLogout}
  87. className="text-red-600 hover:text-red-800 transition-colors duration-200 font-medium text-sm"
  88. >
  89. Logout
  90. </button>
  91. </li>
  92. ) : (
  93. <li>
  94. <Link
  95. to="/login"
  96. className="theme-primary hover:theme-secondary transition-colors duration-200 font-medium"
  97. >
  98. Login
  99. </Link>
  100. </li>
  101. )}
  102. </ul>
  103. </nav>
  104. </header>
  105. );
  106. }
  107. // Layout ComponentWrapper
  108. const Layout = ({ children }) => (
  109. <div className="min-h-screen theme-bg font-sans theme-text antialiased flex flex-col">
  110. <div className="max-w-5xl mx-auto w-full flex-grow">
  111. <NavHeader />
  112. <main className="py-10 px-4 sm:px-6 lg:px-8">
  113. {children}
  114. </main>
  115. </div>
  116. </div>
  117. );
  118. // Skeleton Components
  119. const SkeletonCard = () => (
  120. <div className="theme-surface border theme-border rounded-xl p-6 flex flex-col h-full animate-pulse">
  121. <div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-3/4 mb-4"></div>
  122. <div className="space-y-2 flex-grow">
  123. <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-full"></div>
  124. <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-5/6"></div>
  125. </div>
  126. <div className="mt-4 h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/4"></div>
  127. </div>
  128. );
  129. const SkeletonPost = () => (
  130. <div className="w-full animate-pulse">
  131. <div className="theme-surface border theme-border rounded-xl p-8 md:p-12 lg:p-16">
  132. <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-6"></div>
  133. <div className="mb-8">
  134. <div className="h-10 bg-gray-200 dark:bg-gray-700 rounded w-3/4 mb-4"></div>
  135. <div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-1/2"></div>
  136. </div>
  137. <hr className="theme-border mb-8" />
  138. <div className="space-y-4">
  139. <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-full"></div>
  140. <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-full"></div>
  141. <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-5/6"></div>
  142. <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-full"></div>
  143. <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-4/5"></div>
  144. </div>
  145. </div>
  146. </div>
  147. );
  148. // Blog Home Component
  149. function BlogHome() {
  150. const [posts, setPosts] = useState([]);
  151. const [loading, setLoading] = useState(true);
  152. const [error, setError] = useState(null);
  153. useEffect(() => {
  154. async function getTingyun() {
  155. setLoading(true);
  156. try {
  157. const response = await fetch(`${API_BASE}/posts`);
  158. if (!response.ok)
  159. throw new Error(
  160. `Failed to fetch posts: ${response.statusText}`,
  161. );
  162. const postsData = await response.json();
  163. setPosts(postsData);
  164. } catch (e) {
  165. console.error("Error fetching posts:", e);
  166. setError(
  167. "Failed to load posts. Please check if the backend server is running.",
  168. );
  169. } finally {
  170. setLoading(false);
  171. }
  172. }
  173. getTingyun();
  174. }, []);
  175. if (loading) {
  176. return (
  177. <Layout>
  178. <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
  179. {[1, 2, 3, 4, 5, 6].map((i) => (
  180. <SkeletonCard key={i} />
  181. ))}
  182. </div>
  183. </Layout>
  184. );
  185. }
  186. if (error) {
  187. return (
  188. <Layout>
  189. <div className="text-center">
  190. <div className="bg-red-50 border border-red-200 rounded-lg p-6 inline-block">
  191. <h2 className="text-red-800 font-semibold mb-2">
  192. Error
  193. </h2>
  194. <p className="text-red-600">{error}</p>
  195. </div>
  196. </div>
  197. </Layout>
  198. );
  199. }
  200. return (
  201. <Layout>
  202. <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
  203. {posts.map((post) => (
  204. <div
  205. key={post.slug}
  206. 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"
  207. >
  208. <div>
  209. <h2 className="text-xl font-semibold theme-text group-hover:theme-primary transition-colors duration-200 mb-2">
  210. <Link to={`/posts/${post.slug}`}>
  211. {post.title}
  212. </Link>
  213. </h2>
  214. </div>
  215. <div className="flex-grow mt-4">
  216. <div className="theme-text-secondary leading-relaxed">
  217. {post.description}
  218. </div>
  219. </div>
  220. <div className="mt-4">
  221. <Link
  222. to={`/posts/${post.slug}`}
  223. className="theme-primary font-medium hover:underline focus:outline-none"
  224. >
  225. Read more →
  226. </Link>
  227. </div>
  228. </div>
  229. ))}
  230. </div>
  231. </Layout>
  232. );
  233. }
  234. // Post View Component
  235. function PostView({ onImageClick }) {
  236. const { slug } = useParams();
  237. const [post, setPost] = useState(null);
  238. const [loading, setLoading] = useState(true);
  239. const [error, setError] = useState(null);
  240. useEffect(() => {
  241. async function fetchPost() {
  242. try {
  243. setLoading(true);
  244. const response = await fetch(`${API_BASE}/posts/${slug}`);
  245. if (!response.ok) throw new Error("Post not found");
  246. const postData = await response.json();
  247. setPost(postData);
  248. // Update document title
  249. document.title = postData.title || "GoonBlog";
  250. } catch (e) {
  251. console.error("Error fetching post:", e);
  252. setError(e.message);
  253. } finally {
  254. setLoading(false);
  255. }
  256. }
  257. if (slug) {
  258. fetchPost();
  259. }
  260. }, [slug]);
  261. useEffect(() => {
  262. if (post) {
  263. const setMeta = (name, content) => {
  264. let element = document.querySelector(`meta[name="${name}"]`);
  265. if (!element) {
  266. element = document.createElement("meta");
  267. element.setAttribute("name", name);
  268. document.head.appendChild(element);
  269. }
  270. element.setAttribute("content", content);
  271. };
  272. setMeta("og:title", post.title);
  273. setMeta("og:description", post.description);
  274. setMeta("og:type", "article");
  275. setMeta("og:url", window.location.href);
  276. setMeta("twitter:title", post.title);
  277. setMeta("twitter:description", post.description);
  278. setMeta("twitter:card", "summary");
  279. setMeta("twitter:url", window.location.href);
  280. }
  281. return () => {
  282. const metaTags = [
  283. "og:title",
  284. "og:description",
  285. "og:type",
  286. "og:url",
  287. "og:image",
  288. "twitter:title",
  289. "twitter:description",
  290. "twitter:card",
  291. "twitter:url",
  292. "twitter:image",
  293. ];
  294. metaTags.forEach((name) => {
  295. const element = document.querySelector(`meta[name="${name}"]`);
  296. if (element) {
  297. element.remove();
  298. }
  299. });
  300. };
  301. }, [post]);
  302. useEffect(() => {
  303. // Reset title when component unmounts
  304. return () => {
  305. document.title = "GoonBlog - A Retard's Thoughts";
  306. };
  307. }, []);
  308. // Effect to handle interactions (Comparison Slider, Zoom Reel & Lightbox)
  309. useEffect(() => {
  310. if (!post) return;
  311. // 1. Handle Comparison Sliders (Updated to use clip-path)
  312. const sliders = document.querySelectorAll(".comparison-slider");
  313. const handleSliderInput = (e) => {
  314. const container = e.target.closest(".comparison-wrapper");
  315. const topImage = container.querySelector(".comparison-top");
  316. const handle = container.querySelector(".slider-handle");
  317. const val = e.target.value;
  318. // Use clip-path inset(top right bottom left)
  319. // We want to clip the right side based on the slider value.
  320. // If slider is at 50%, we want to show 50% of the image from the left.
  321. // So we clip 50% from the right.
  322. // Inset right value = 100 - val
  323. if (topImage) topImage.style.clipPath = `inset(0 ${100 - val}% 0 0)`;
  324. if (handle) handle.style.left = `${val}%`;
  325. };
  326. sliders.forEach(slider => {
  327. slider.addEventListener("input", handleSliderInput);
  328. });
  329. // 2. Handle Zoom Reels
  330. const zoomReels = document.querySelectorAll(".interactive-zoom-reel");
  331. const cleanupZoomReels = []; // To store cleanup functions for each reel
  332. zoomReels.forEach(reel => {
  333. const images = reel.querySelectorAll(".zoom-reel-img");
  334. const viewports = reel.querySelectorAll(".zoom-reel-viewport");
  335. const slider = reel.querySelector(".zoom-slider");
  336. const resetBtn = reel.querySelector(".reset-zoom");
  337. let state = {
  338. zoom: 1,
  339. panX: 0,
  340. panY: 0,
  341. isDragging: false,
  342. startX: 0,
  343. startY: 0,
  344. initialPanX: 0,
  345. initialPanY: 0
  346. };
  347. const updateTransform = () => {
  348. images.forEach(img => {
  349. img.style.transform = `translate(${state.panX}px, ${state.panY}px) scale(${state.zoom})`;
  350. });
  351. };
  352. const handleZoomInput = (e) => {
  353. state.zoom = parseFloat(e.target.value);
  354. // Reset pan if zoom is 1
  355. if (state.zoom === 1) {
  356. state.panX = 0;
  357. state.panY = 0;
  358. }
  359. updateTransform();
  360. };
  361. const handleReset = () => {
  362. state.zoom = 1;
  363. state.panX = 0;
  364. state.panY = 0;
  365. if (slider) slider.value = 1;
  366. updateTransform();
  367. };
  368. // Drag Logic for Viewports
  369. const handleMouseDown = (e) => {
  370. if (state.zoom <= 1) return; // Only pan if zoomed in
  371. e.preventDefault(); // Prevent standard drag
  372. state.isDragging = true;
  373. state.startX = e.clientX;
  374. state.startY = e.clientY;
  375. state.initialPanX = state.panX;
  376. state.initialPanY = state.panY;
  377. viewports.forEach(vp => vp.style.cursor = "grabbing");
  378. };
  379. const handleMouseMove = (e) => {
  380. if (!state.isDragging) return;
  381. e.preventDefault();
  382. const dx = e.clientX - state.startX;
  383. const dy = e.clientY - state.startY;
  384. state.panX = state.initialPanX + dx;
  385. state.panY = state.initialPanY + dy;
  386. updateTransform();
  387. };
  388. const handleMouseUp = () => {
  389. state.isDragging = false;
  390. viewports.forEach(vp => vp.style.cursor = "grab");
  391. };
  392. if (slider) slider.addEventListener("input", handleZoomInput);
  393. if (resetBtn) resetBtn.addEventListener("click", handleReset);
  394. viewports.forEach(vp => {
  395. vp.addEventListener("mousedown", handleMouseDown);
  396. });
  397. // We listen to document for move/up to handle drag going outside viewport
  398. document.addEventListener("mousemove", handleMouseMove);
  399. document.addEventListener("mouseup", handleMouseUp);
  400. cleanupZoomReels.push(() => {
  401. if (slider) slider.removeEventListener("input", handleZoomInput);
  402. if (resetBtn) resetBtn.removeEventListener("click", handleReset);
  403. viewports.forEach(vp => vp.removeEventListener("mousedown", handleMouseDown));
  404. document.removeEventListener("mousemove", handleMouseMove);
  405. document.removeEventListener("mouseup", handleMouseUp);
  406. });
  407. });
  408. // 3. Handle Lightbox clicks
  409. const lightboxImages = document.querySelectorAll(".markdown-content img");
  410. const handleImageClick = (e) => {
  411. // Ignore images inside comparison slider or zoom reel
  412. if (e.target.closest(".comparison-wrapper") || e.target.closest(".zoom-reel-container")) return;
  413. onImageClick(e.target.src, e.target.alt);
  414. };
  415. lightboxImages.forEach(img => {
  416. img.addEventListener("click", handleImageClick);
  417. });
  418. return () => {
  419. sliders.forEach(slider => {
  420. slider.removeEventListener("input", handleSliderInput);
  421. });
  422. lightboxImages.forEach(img => {
  423. img.removeEventListener("click", handleImageClick);
  424. });
  425. cleanupZoomReels.forEach(cleanup => cleanup());
  426. };
  427. }, [post, onImageClick]);
  428. if (loading) {
  429. return (
  430. <Layout>
  431. <SkeletonPost />
  432. </Layout>
  433. );
  434. }
  435. if (error || !post) {
  436. return (
  437. <Layout>
  438. <div className="text-center">
  439. <h2 className="text-2xl font-bold theme-text mb-2">
  440. Post Not Found
  441. </h2>
  442. <p className="theme-text-secondary mb-4">
  443. {error || "The requested post could not be found."}
  444. </p>
  445. <Link
  446. to="/"
  447. className="theme-primary hover:theme-secondary font-medium"
  448. >
  449. ← Back to Home
  450. </Link>
  451. </div>
  452. </Layout>
  453. );
  454. }
  455. const conceiveFoxFromSemen = (rawMarkdown) => {
  456. let processedText = rawMarkdown;
  457. let tags = null;
  458. let imageCredit = null;
  459. let imageSrc = null;
  460. let imageAlt = null;
  461. let customQuestion = null;
  462. const tagsRegex = /tags: (.*)/;
  463. const tagsMatch = processedText.match(tagsRegex);
  464. if (tagsMatch) {
  465. tags = tagsMatch[1].split(",").map((tag) => tag.trim());
  466. processedText = processedText.replace(tagsRegex, "").trim();
  467. }
  468. const imageRegex = /!\[(.*?)\]\((.*?)\)\n_Image credit: (.*?)_/;
  469. const imageMatch = processedText.match(imageRegex);
  470. if (imageMatch) {
  471. imageAlt = imageMatch[1];
  472. imageSrc = imageMatch[2];
  473. imageCredit = imageMatch[3];
  474. processedText = processedText.replace(imageRegex, "").trim();
  475. }
  476. const questionRegex = /\?\?\? "(.*?)"/;
  477. const questionMatch = processedText.match(questionRegex);
  478. if (questionMatch) {
  479. customQuestion = questionMatch[1];
  480. processedText = processedText.replace(questionRegex, "").trim();
  481. }
  482. processedText = processedText
  483. .replace(/^title:.*$/m, "")
  484. .replace(/^desc:.*$/m, "");
  485. return {
  486. processedText,
  487. tags,
  488. imageSrc,
  489. imageAlt,
  490. imageCredit,
  491. customQuestion,
  492. };
  493. };
  494. const { processedText } = conceiveFoxFromSemen(post.content);
  495. const htmlContent = md.render(processedText);
  496. const sanitizedHtml = DOMPurify.sanitize(htmlContent, {
  497. ADD_TAGS: ["input"], // Allow input tags for the slider
  498. ADD_ATTR: ["type", "min", "max", "value", "step", "checked"],
  499. });
  500. return (
  501. <Layout>
  502. <div className="w-full">
  503. <div className="theme-surface theme-text border theme-border rounded-xl p-8 md:p-12 lg:p-16">
  504. <Link
  505. to="/"
  506. className="theme-text-secondary hover:theme-text transition-colors duration-200 mb-6 flex items-center"
  507. >
  508. ← Back to Home
  509. </Link>
  510. <div className="mb-8">
  511. <h1 className="text-3xl md:text-4xl font-bold theme-text mb-2 leading-tight">
  512. {post.title}
  513. </h1>
  514. <div className="text-lg italic font-light theme-text-secondary">
  515. {post.description}
  516. </div>
  517. </div>
  518. <hr className="theme-border mb-8" />
  519. <div
  520. className="markdown-content theme-text leading-relaxed text-lg"
  521. dangerouslySetInnerHTML={{
  522. __html: sanitizedHtml,
  523. }}
  524. />
  525. </div>
  526. </div>
  527. </Layout>
  528. );
  529. }
  530. function App() {
  531. const [lightboxOpen, setLightboxOpen] = useState(false);
  532. const [lightboxImage, setLightboxImage] = useState({ src: "", alt: "" });
  533. const openLightbox = (src, alt) => {
  534. setLightboxImage({ src, alt });
  535. setLightboxOpen(true);
  536. };
  537. const closeLightbox = () => {
  538. setLightboxOpen(false);
  539. };
  540. return (
  541. <Router>
  542. <AuthProvider>
  543. <ThemeProvider>
  544. {lightboxOpen && (
  545. <Lightbox
  546. src={lightboxImage.src}
  547. alt={lightboxImage.alt}
  548. onClose={closeLightbox}
  549. />
  550. )}
  551. <React.Suspense fallback={<LoadingSpinner />}>
  552. <Routes>
  553. <Route path="/" element={<BlogHome />} />
  554. <Route path="/posts/:slug" element={<PostView onImageClick={openLightbox} />} />
  555. <Route path="/login" element={<LoginForm />} />
  556. <Route
  557. path="/admin"
  558. element={
  559. <ProtectedRoute>
  560. <AdminDashboard />
  561. </ProtectedRoute>
  562. }
  563. />
  564. <Route
  565. path="/admin/post/new"
  566. element={
  567. <ProtectedRoute>
  568. <PostEditor />
  569. </ProtectedRoute>
  570. }
  571. />
  572. <Route
  573. path="/admin/post/:slug/edit"
  574. element={
  575. <ProtectedRoute>
  576. <PostEditor />
  577. </ProtectedRoute>
  578. }
  579. />
  580. <Route
  581. path="/admin/themes"
  582. element={
  583. <ProtectedRoute>
  584. <ThemesManager />
  585. </ProtectedRoute>
  586. }
  587. />
  588. <Route
  589. path="/admin/themes/new"
  590. element={
  591. <ProtectedRoute>
  592. <ThemeEditor />
  593. </ProtectedRoute>
  594. }
  595. />
  596. <Route
  597. path="/admin/themes/:themeId/edit"
  598. element={
  599. <ProtectedRoute>
  600. <ThemeEditor />
  601. </ProtectedRoute>
  602. }
  603. />
  604. <Route
  605. path="/admin/media"
  606. element={
  607. <ProtectedRoute>
  608. <MediaManager />
  609. </ProtectedRoute>
  610. }
  611. />
  612. </Routes>
  613. </React.Suspense>
  614. </ThemeProvider>
  615. </AuthProvider>
  616. </Router>
  617. );
  618. }
  619. export default App;