diff --git a/components/TableContents.tsx b/components/TableContents.tsx index 17906219..e19d860b 100644 --- a/components/TableContents.tsx +++ b/components/TableContents.tsx @@ -1,43 +1,64 @@ +"use client"; + import React, { useState, useEffect, useRef } from "react"; import { sanitizeStringForURL } from "../utils/sanitizeStringForUrl"; +/* ---------------- Types ---------------- */ + +type HeadingItem = { + id: string; + title: string; + type: "h1" | "h2" | "h3" | "h4"; +}; + +type TOCProps = { + headings: HeadingItem[]; + isList: boolean; + setIsList: React.Dispatch>; +}; + +type TOCItemProps = { + id: string; + title: string; + type: HeadingItem["type"]; + activeId: string; + onClick: (id: string) => void; +}; + +/* ---------------- TOC Item ---------------- */ + function TOCItem({ id, title, type, onClick, -}: { - id: string; - title: string; - type: string; - onClick: (id: string) => void; -}) { - const itemClasses = "mb-1 text-slate-600 space-y-1"; + activeId, +}: TOCItemProps) { + let marginLeft = "2rem"; - // Calculate margin left based on heading type - let marginLeft; switch (type) { case "h1": - marginLeft = 0; + marginLeft = "0"; break; case "h2": - marginLeft = "1rem"; // Adjust as needed + marginLeft = "1rem"; break; case "h3": - marginLeft = "1.5rem"; // Adjust as needed - break; - case "h4": - marginLeft = "2rem"; // Adjust as needed + marginLeft = "1.5rem"; break; - default: - marginLeft = "2rem"; // Default to h4 margin } + const isActive = activeId === id; + return ( -
  • +
  • @@ -45,178 +66,166 @@ function TOCItem({ ); } -export default function TOC({ headings, isList, setIsList }) { - const tocRef = useRef(null); +/* ---------------- TOC ---------------- */ + +export default function TOC({ headings, isList, setIsList }: TOCProps) { + const tocContainerRef = useRef(null); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [activeId, setActiveId] = useState(""); + const [isSmallScreen, setIsSmallScreen] = useState(false); + /* ---------------- Scroll Spy ---------------- */ useEffect(() => { - if (!tocRef.current) return; + if (!headings.length) return; + + const observer = new IntersectionObserver( + (entries) => { + const visible = entries.find((e) => e.isIntersecting); + if (visible) { + setActiveId(visible.target.id); + } + }, + { rootMargin: "-40% 0px -40% 0px", threshold: 0.1 } + ); + + headings.forEach((h) => { + const id = sanitizeStringForURL(h.id, true); + const el = document.getElementById(id); + if (el) observer.observe(el); + }); + + return () => observer.disconnect(); + }, [headings]); - const container = tocRef.current; + /* ---------------- Auto-switch list mode ---------------- */ + useEffect(() => { + if (!tocContainerRef.current) return; - function resizeHandler() { - setIsList(container.clientHeight > window.innerHeight * 0.8); - } + const resizeHandler = () => { + setIsList( + tocContainerRef.current!.clientHeight > + window.innerHeight * 0.8 + ); + }; - resizeHandler() - window.addEventListener("resize", resizeHandler) + resizeHandler(); + window.addEventListener("resize", resizeHandler); + return () => window.removeEventListener("resize", resizeHandler); + }, [setIsList]); - return () => { window.removeEventListener("resize", resizeHandler) } + /* ---------------- Auto-scroll TOC ---------------- */ + useEffect(() => { + if (!activeId || !tocContainerRef.current) return; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + const activeEl = tocContainerRef.current.querySelector( + `[data-toc-id="${activeId}"]` + ) as HTMLElement | null; + activeEl?.scrollIntoView({ block: "nearest", behavior: "smooth" }); + }, [activeId]); + + /* ---------------- Scroll on click ---------------- */ + const handleItemClick = (id: string) => { + const sanitizedId = sanitizeStringForURL(id, true); + const element = document.getElementById(sanitizedId); + if (!element) return; - const handleItemClick = (id) => { - const sanitizedId = sanitizeStringForURL(id, true); - const element = document.getElementById(sanitizedId); - if (element) { - const offset = 80; - const offsetPosition = element.offsetTop - offset; window.scrollTo({ - top: offsetPosition, + top: element.offsetTop - 80, behavior: "smooth", }); - window.history.replaceState(null, null, `#${sanitizedId}`); - } -}; - - // State to track screen width - const [isSmallScreen, setIsSmallScreen] = useState(false); - - // Function to check if screen width is small - const checkScreenSize = () => { - setIsSmallScreen(window.innerWidth < 1024); // Adjust breakpoint as needed + window.history.replaceState(null, "", `#${sanitizedId}`); + setActiveId(sanitizedId); }; + /* ---------------- Screen size ---------------- */ useEffect(() => { - checkScreenSize(); // Initial check - window.addEventListener("resize", checkScreenSize); // Event listener for screen resize - return () => { - window.removeEventListener("resize", checkScreenSize); // Cleanup on component unmount - }; + const resize = () => setIsSmallScreen(window.innerWidth < 1024); + resize(); + window.addEventListener("resize", resize); + return () => window.removeEventListener("resize", resize); }, []); - // Render dropdown if on a small screen, otherwise render regular TOC - return isSmallScreen ? ( - <> -
    -
    - -
    + /* ---------------- Mobile ---------------- */ + if (isSmallScreen) { + return ( +
    + {isDropdownOpen && ( -
    -
      - {headings.map((item, index) => { - let indent = ""; - switch (item.type) { - case "h1": - indent = "ml-0"; - break; - case "h2": - indent = "ml-4"; - break; - case "h3": - indent = "ml-8"; - break; - case "h4": - indent = "ml-12"; - break; - default: - indent = "ml-0"; - } - - return ( -
    • + {headings.map((item) => { + const id = sanitizeStringForURL(item.id, true); + return ( +
    • + -
    • - ); - })} -
    -
    + {item.title} + +
  • + ); + })} + )} - - ) : ( - <> -
    -
    Table of Contents
    + ); + } + + /* ---------------- Desktop ---------------- */ + return ( +
    +
    Table of Contents
    + + {isList ? ( -
    -
    -
    Table of Contents
    - {isList ? ( - - ) : ( -
    - + ); + })} + + + )} +
    ); } diff --git a/components/testimonials.tsx b/components/testimonials.tsx index 662d8fb3..1692bdc8 100644 --- a/components/testimonials.tsx +++ b/components/testimonials.tsx @@ -1,42 +1,55 @@ +"use client"; + import React from "react"; +import Image from "next/image"; import { useRouter } from "next/router"; import { Marquee } from "./Marquee"; import Tweets from "../services/Tweets"; + +const fallbackAvatar = + "https://abs.twimg.com/sticky/default_profile_images/default_profile_400x400.png"; + const firstRow = Tweets.slice(0, Tweets.length / 2); const secondRow = Tweets.slice(Tweets.length / 2); -const ReviewCard = ({ - avatar, - name, - id, - content, - post, -}: { - avatar: string; - name: string; - post: string; - id: string; - content: string; -}) => { +const ReviewCard = ({ avatar, name, id, content, post }) => { const { basePath } = useRouter(); - const isExternal = typeof avatar === "string" && /^https?:\/\//i.test(avatar); - const proxiedAvatar = isExternal ? `${basePath}/api/proxy-image?url=${encodeURIComponent(avatar)}` : avatar; + + // Validate post link + if (!post || !post.startsWith("https://")) return null; + + let imgSrc = avatar || fallbackAvatar; + + const isExternal = /^https?:\/\//i.test(imgSrc); + const isTwitterCDN = + imgSrc.includes("pbs.twimg.com") || imgSrc.includes("abs.twimg.com"); + + // Proxy only non-twitter external images + if (isExternal && !isTwitterCDN) { + imgSrc = `${basePath}/api/proxy-image?url=${encodeURIComponent(imgSrc)}`; + } + return ( - -
    + +
    - {`${name} { + e.currentTarget.src = fallbackAvatar; + }} /> +
    {name}
    -

    {id}

    +

    @{id}

    +
    {content}
    @@ -45,24 +58,23 @@ const ReviewCard = ({ const TwitterTestimonials = () => { return ( -
    -

    - What our community thinks -

    -
    - +
    +

    + What our community thinks +

    + +
    {firstRow.map((tweet) => ( ))} + {secondRow.map((tweet) => ( ))} -
    -
    ); diff --git a/components/tweets.tsx b/components/tweets.tsx index f7b81af6..ee46f48c 100644 --- a/components/tweets.tsx +++ b/components/tweets.tsx @@ -1,14 +1,32 @@ -import React from "react"; +"use client"; + +import React, { useState } from "react"; import Image from "next/image"; import { useRouter } from "next/router"; import Link from "next/link"; +const fallbackAvatar = + "https://abs.twimg.com/sticky/default_profile_images/default_profile_400x400.png"; + const Tweets = ({ avatar, name, id, post, content }) => { const { basePath } = useRouter(); - const isExternal = typeof avatar === "string" && /^https?:\/\//i.test(avatar); - const proxiedAvatar = isExternal - ? `${basePath}/api/proxy-image?url=${encodeURIComponent(avatar)}` - : avatar; + + let imgSrc = avatar || fallbackAvatar; + + const isExternal = /^https?:\/\//i.test(imgSrc); + const isTwitterCDN = + imgSrc.includes("pbs.twimg.com") || imgSrc.includes("abs.twimg.com"); + + if (isExternal && !isTwitterCDN) { + imgSrc = `${basePath}/api/proxy-image?url=${encodeURIComponent(imgSrc)}`; + } + + // ✅ HOOKS MUST COME BEFORE ANY RETURN + const [src, setSrc] = useState(imgSrc); + + // ✅ Conditional return AFTER hooks + if (!post || !post.startsWith("https://")) return null; + return ( {
    {`${name}'s setSrc(fallbackAvatar)} + loading="lazy" />

    {name}

    @@ -32,16 +53,15 @@ const Tweets = ({ avatar, name, id, post, content }) => {
    Twitter Icon
    -

    {content}

    ); }; -export default Tweets; \ No newline at end of file +export default Tweets; diff --git a/services/Tweets.tsx b/services/Tweets.tsx index 12492607..656e72fb 100644 --- a/services/Tweets.tsx +++ b/services/Tweets.tsx @@ -10,7 +10,7 @@ const Tweets = [ }, { avatar: - "https://pbs.twimg.com/profile_images/1422864637532332033/mC1Nx0vj_400x400.jpg", + "https://pbs.twimg.com/profile_images/2006390396847628288/cSHW1_MM_400x400.jpg", name: "matsuu@充電期間", id: "matsuu", post: "https://x.com/matsuu/status/1747448928575099236?s=20", @@ -38,7 +38,7 @@ const Tweets = [ }, { avatar: - "https://pbs.twimg.com/profile_images/1653250498127089665/x5RJbLq5_400x400.jpg", + "https://pbs.twimg.com/profile_images/1942049397032034304/rTAx_nAm_400x400.jpg", name: "きょん/kyong", id: "kyongshiii06", post: "https://x.com/kyongshiii06/status/1746532217336250821?s=20", @@ -47,7 +47,7 @@ const Tweets = [ }, { avatar: - "https://pbs.twimg.com/profile_images/1653250498127089665/x5RJbLq5_400x400.jpg", + "https://pbs.twimg.com/profile_images/1942049397032034304/rTAx_nAm_400x400.jpg", name: "きょん/kyong", id: "kyongshiii06", post: "https://x.com/kyongshiii06/status/1753030333128495470?s=20", @@ -74,7 +74,7 @@ const Tweets = [ }, { avatar: - "https://pbs.twimg.com/profile_images/1712175220176355329/sLXbk_PZ_400x400.jpg", + "https://pbs.twimg.com/profile_images/1984184086991138817/FlSYVd74_400x400.jpg", name: "TadasG", id: "JustADude404", post: "https://x.com/JustADude404/status/1746888711491424681?s=20", @@ -84,7 +84,7 @@ const Tweets = [ { avatar: - "https://pbs.twimg.com/profile_images/1482259385959464960/1pQMXwj7_400x400.jpg", + "https://pbs.twimg.com/profile_images/1949818869377556480/lhgs3XQk_400x400.jpg", name: "yadon", id: "Seipann11", post: "https://x.com/Seipann11/status/1755989987039064103?s=20", @@ -102,7 +102,7 @@ const Tweets = [ }, { avatar: - "https://pbs.twimg.com/profile_images/1604797450124144640/6G7KytX8_400x400.jpg", + "https://pbs.twimg.com/profile_images/1952462865153265664/w_wxnDll_400x400.jpg", name: "あんどーぼんばー", id: "AndooBomber", post: "https://x.com/AndooBomber/status/1747663021747691808?s=20",