| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682 |
- import React, { useState, useEffect } 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 { ThemeProvider } from "./contexts/ThemeContext";
- // Lazy load components for performance
- const AdminDashboard = React.lazy(() => import("./components/AdminDashboard"));
- const PostEditor = React.lazy(() => import("./components/PostEditor"));
- const LoginForm = React.lazy(() => import("./components/LoginForm"));
- const ThemesManager = React.lazy(() => import("./components/ThemesManager"));
- 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 = () => (
- <div className="flex justify-center items-center p-12">
- <div className="animate-spin rounded-full h-8 w-8 border-b-2 theme-primary"></div>
- </div>
- );
- // Lightbox Component
- const Lightbox = ({ src, alt, onClose }) => {
- if (!src) return null;
- return (
- <div className="lightbox-overlay" onClick={onClose}>
- <span className="lightbox-close" onClick={onClose}>
- ×
- </span>
- <img
- src={src}
- alt={alt}
- className="lightbox-content"
- onClick={(e) => e.stopPropagation()} // Prevent closing when clicking the image
- />
- </div>
- );
- };
- // 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: "" });
- const openLightbox = (src, alt) => {
- setLightboxImage({ src, alt });
- setLightboxOpen(true);
- };
- const closeLightbox = () => {
- setLightboxOpen(false);
- };
- return (
- <Router>
- <AuthProvider>
- <ThemeProvider>
- {lightboxOpen && (
- <Lightbox
- src={lightboxImage.src}
- alt={lightboxImage.alt}
- onClose={closeLightbox}
- />
- )}
- <React.Suspense fallback={<LoadingSpinner />}>
- <Routes>
- <Route path="/" element={<BlogHome />} />
- <Route path="/posts/:slug" element={<PostView onImageClick={openLightbox} />} />
- <Route path="/login" element={<LoginForm />} />
- <Route
- path="/admin"
- element={
- <ProtectedRoute>
- <AdminDashboard />
- </ProtectedRoute>
- }
- />
- <Route
- path="/admin/post/new"
- element={
- <ProtectedRoute>
- <PostEditor />
- </ProtectedRoute>
- }
- />
- <Route
- path="/admin/post/:slug/edit"
- element={
- <ProtectedRoute>
- <PostEditor />
- </ProtectedRoute>
- }
- />
- <Route
- path="/admin/themes"
- element={
- <ProtectedRoute>
- <ThemesManager />
- </ProtectedRoute>
- }
- />
- <Route
- path="/admin/themes/new"
- element={
- <ProtectedRoute>
- <ThemeEditor />
- </ProtectedRoute>
- }
- />
- <Route
- path="/admin/themes/:themeId/edit"
- element={
- <ProtectedRoute>
- <ThemeEditor />
- </ProtectedRoute>
- }
- />
- <Route
- path="/admin/media"
- element={
- <ProtectedRoute>
- <MediaManager />
- </ProtectedRoute>
- }
- />
- </Routes>
- </React.Suspense>
- </ThemeProvider>
- </AuthProvider>
- </Router>
- );
- }
- export default App;
|