From dc0803a4bdd4db273fbc861d9ce81f231d240312 Mon Sep 17 00:00:00 2001 From: zzq0826 <770166635@qq.com> Date: Mon, 21 Sep 2026 16:26:20 +0800 Subject: [PATCH] feat(blog): the blog is back at /blog, in the new brand (#1625) --- scripts/build-landing.mjs | 14 + src/app/_blog/[blogId]/MoreBlogs.tsx | 52 -- src/app/_blog/[blogId]/actions.ts | 17 - .../[blogId]/components/tableOfContents.tsx | 124 ----- src/app/_blog/[blogId]/detail.tsx | 132 ----- src/app/_blog/[blogId]/page.tsx | 49 -- src/app/_blog/layout.tsx | 16 - src/app/_blog/page.tsx | 288 ---------- src/app/_components/LandingFooter.tsx | 20 +- src/app/_components/LandingNav.tsx | 8 +- src/app/_components/landing.module.css | 4 +- src/app/blog/PostCard.tsx | 26 + src/app/blog/PostList.tsx | 51 ++ src/app/blog/[blogId]/components/BackLink.tsx | 14 + .../[blogId]/components/tableOfContents.tsx | 80 +++ src/app/blog/[blogId]/page.tsx | 112 ++++ src/app/blog/blog.module.css | 513 ++++++++++++++++++ src/app/blog/layout.tsx | 38 ++ src/app/blog/page.tsx | 24 + src/app/blog/posts.ts | 37 ++ src/app/sitemap.ts | 9 + src/components/ScrollToTop/index.tsx | 3 +- 22 files changed, 946 insertions(+), 685 deletions(-) delete mode 100644 src/app/_blog/[blogId]/MoreBlogs.tsx delete mode 100644 src/app/_blog/[blogId]/actions.ts delete mode 100644 src/app/_blog/[blogId]/components/tableOfContents.tsx delete mode 100644 src/app/_blog/[blogId]/detail.tsx delete mode 100644 src/app/_blog/[blogId]/page.tsx delete mode 100644 src/app/_blog/layout.tsx delete mode 100644 src/app/_blog/page.tsx create mode 100644 src/app/blog/PostCard.tsx create mode 100644 src/app/blog/PostList.tsx create mode 100644 src/app/blog/[blogId]/components/BackLink.tsx create mode 100644 src/app/blog/[blogId]/components/tableOfContents.tsx create mode 100644 src/app/blog/[blogId]/page.tsx create mode 100644 src/app/blog/blog.module.css create mode 100644 src/app/blog/layout.tsx create mode 100644 src/app/blog/page.tsx create mode 100644 src/app/blog/posts.ts diff --git a/scripts/build-landing.mjs b/scripts/build-landing.mjs index 84c94a04a..b1503a43b 100644 --- a/scripts/build-landing.mjs +++ b/scripts/build-landing.mjs @@ -45,6 +45,20 @@ once( 'Terms of service\n Support', ) once("white paper link", 'href="/scroll-whitepaper.pdf"', 'href="/files/whitepaper.pdf"') +// the blog is a page of its own again (Zhengqi, 2026-09-21): it went off the site with the +// rest of the old pages in the redesign, and the link people were sent to it with has been +// dead since. It sits in the nav after the product sections and opens the footer's +// Resources column, here and in LandingFooter.tsx (the other pages' footer) alike. +once( + "blog nav link", + '
  • AI hardware
  • ', + '
  • AI hardware
  • \n
  • Blog
  • ', +) +once( + "blog footer link", + 'Documentation', + 'Blog\n Documentation', +) // ---- the Compass button on the Compass API panel: his file points it at his own Compass // prototype (index.html, 2026-09-11); here it opens the Compass site ---- diff --git a/src/app/_blog/[blogId]/MoreBlogs.tsx b/src/app/_blog/[blogId]/MoreBlogs.tsx deleted file mode 100644 index 972084839..000000000 --- a/src/app/_blog/[blogId]/MoreBlogs.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { Pagination } from "swiper/modules" -import { Swiper, SwiperSlide } from "swiper/react" - -import { Box, Stack, Typography } from "@mui/material" - -import ArticleCard from "@/components/ArticleCard" - -const MoreBlogs = props => { - const { blogs, title, ...restProps } = props - - return ( - - - {title} - - - - {props.blogs.map(blog => ( - - - - - - ))} - - - ) -} - -export default MoreBlogs diff --git a/src/app/_blog/[blogId]/actions.ts b/src/app/_blog/[blogId]/actions.ts deleted file mode 100644 index 65e05e043..000000000 --- a/src/app/_blog/[blogId]/actions.ts +++ /dev/null @@ -1,17 +0,0 @@ -"use server" - -import { fetchBlogDetailURL } from "@/apis/blog" - -export const fetchBlogContent = async (blogId: string) => { - const response = await fetch(fetchBlogDetailURL(blogId)) - - if (response.ok) { - const blogContent = await response.text() - if (!blogContent) { - throw new Error("Not found") - } - return blogContent - } else { - throw new Error("Failed to fetch blog content") - } -} diff --git a/src/app/_blog/[blogId]/components/tableOfContents.tsx b/src/app/_blog/[blogId]/components/tableOfContents.tsx deleted file mode 100644 index e14e02e36..000000000 --- a/src/app/_blog/[blogId]/components/tableOfContents.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import { default as RouterLink } from "next/link" -import { usePathname } from "next/navigation" -import type { FC } from "react" -import { useEffect, useRef, useState } from "react" - -import { SvgIcon, Typography } from "@mui/material" -import { styled } from "@mui/system" - -import BackSvg from "@/assets/svgs/common/back.svg" - -const Link = styled(RouterLink)({ - display: "flex", - alignItems: "center", - "& *": { - fontWeight: 500, - }, -}) - -export interface Heading { - depth: string - text: string - slug: string -} - -const TableOfContents: FC = () => { - const pathname = usePathname() - - const [headings, setHeadings] = useState([]) - const tableOfContents = useRef(null) - const [currentID, setCurrentID] = useState("") - const scrolledRef = useRef(false) - - const hash = pathname!.split("#")[1] - const hashRef = useRef(hash) - - useEffect(() => { - if (hash) { - // We want to reset if the hash has changed - if (hashRef.current !== hash) { - hashRef.current = hash - scrolledRef.current = false - } - - // only attempt to scroll if we haven't yet (this could have just reset above if hash changed) - if (!scrolledRef.current) { - const id = hash.replace("#", "") - const element = document.getElementById(id) - if (element) { - element.scrollIntoView() - scrolledRef.current = true - } - } - } - }, [tableOfContents.current]) - - useEffect(() => { - if (!tableOfContents.current) return - const setCurrent: IntersectionObserverCallback = entries => { - for (const entry of entries) { - if (entry.isIntersecting) { - setCurrentID(entry.target.id) - break - } - } - } - - const observerOptions: IntersectionObserverInit = { - // Negative top margin accounts for `scroll-margin`. - // Negative bottom margin means heading needs to be towards top of viewport to trigger intersection. - rootMargin: "0px 0% -66%", - } - - const headingsObserver = new IntersectionObserver(setCurrent, observerOptions) - // Observe all the h2 in the main page content. - document.querySelectorAll("h2").forEach((heading: HTMLHeadElement) => { - const id = (heading.textContent as string) - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/[^a-z0-9]+/g, "-") - heading.id = id - headingsObserver.observe(heading) - }) - // Stop observing when the component is unmounted. - return () => headingsObserver.disconnect() - }, [tableOfContents.current]) - - useEffect(() => { - if (!tableOfContents.current) return - refreshHeadings() - }, [tableOfContents.current]) - - const refreshHeadings = () => { - const headingList: Heading[] = [] - document.querySelectorAll("h2").forEach((heading: HTMLHeadElement) => { - if (heading.className) return - headingList.push({ - depth: heading.nodeName.charAt(1), - text: heading.textContent as string, - slug: heading.id, - }) - }) - setHeadings(headingList) - } - - return ( - <> - - - ) -} - -export default TableOfContents diff --git a/src/app/_blog/[blogId]/detail.tsx b/src/app/_blog/[blogId]/detail.tsx deleted file mode 100644 index 235fcf11c..000000000 --- a/src/app/_blog/[blogId]/detail.tsx +++ /dev/null @@ -1,132 +0,0 @@ -"use client" - -import { shuffle } from "lodash" -import { useRouter } from "next/navigation" -import { useEffect, useMemo, useState } from "react" -import ReactMarkdown from "react-markdown" -import rehypeKatex from "rehype-katex" -import rehypeRaw from "rehype-raw" -import remarkGfm from "remark-gfm" -import remarkMath from "remark-math" - -import { Box } from "@mui/material" -import { styled } from "@mui/system" - -import blogSource from "@/assets/blog/main.data.json" -import LoadingPage from "@/components/LoadingPage" -import { LANGUAGE_MAP } from "@/constants" -import useCheckViewport from "@/hooks/useCheckViewport" -import useUserLanguage from "@/hooks/useUserLanguage" -import { filterBlogsByLanguage } from "@/utils" - -import MoreBlogs from "./MoreBlogs" -import { fetchBlogContent } from "./actions" -import TOC from "./components/tableOfContents" - -const BlogContainer = styled(Box)( - ({ theme }) => ` - max-width: 140rem; - padding: 6rem 4rem 14rem; - overflow: visible; - display: flex; - width: 100%; - margin: auto; - ${theme.breakpoints.down("md")} { - padding: 4rem 2rem; - display: block; - overflow: hidden; - }; - `, -) as typeof Box - -const BlogNavbar = styled(Box)(({ theme }) => ({ - position: "sticky", - top: "14rem", - maxWidth: "32vw", - [theme.breakpoints.down("md")]: { - display: "none", - }, -})) as typeof Box - -const BlogDetail = props => { - const { blogId } = props - const router = useRouter() - - const [language] = useUserLanguage() - const [blogContent, setBlogContent] = useState(null) - const [moreBlog, setMoreBlog] = useState([]) - - const [loading, setLoading] = useState(true) - - const blogsWithLang = useMemo(() => filterBlogsByLanguage(blogSource, language), [blogSource, language]) - - useEffect(() => { - // TODO: no _lang_ in blogId - async function fetchCurrentBlog() { - const regex = /([^_]*?)_lang_[^_]+/g - const blogIdMatch = blogId?.match(regex) - - const blogItemWithLang = blogSource.find(item => item.id === `${blogId}_lang_${language}`) - - if ((!blogIdMatch && language === "en") || (blogIdMatch && language !== "en") || (!blogIdMatch && language !== "en" && !blogItemWithLang)) { - let anchors = [...document.querySelectorAll("a")] - anchors.map(anchor => { - if (anchor.href.includes("/Content/")) { - anchor.setAttribute("target", "") - } - return anchor - }) - try { - setLoading(true) - const text = await fetchBlogContent(blogId) - setBlogContent(text) - } catch (_error) { - router.push("/404") - } finally { - setLoading(false) - } - } else if (blogIdMatch && language === "en") { - const nextBlogId = blogId.replace(regex, "$1") - router.push(`/blog/${nextBlogId}`) - } else if (blogItemWithLang) { - router.push(`/blog/${blogId}_lang_${language}`) - } - } - fetchCurrentBlog() - }, [blogId, language]) - - useEffect(() => { - const blogs = shuffle(blogsWithLang.filter(blog => blog.id !== blogId)).slice(0, 3) - setMoreBlog(blogs) - }, [blogId, blogsWithLang]) - - const { isPortrait } = useCheckViewport() - - return ( - - {loading ? ( - - ) : ( - - - - - - - - - - - {!!isPortrait && } - - )} - - ) -} - -export default BlogDetail diff --git a/src/app/_blog/[blogId]/page.tsx b/src/app/_blog/[blogId]/page.tsx deleted file mode 100644 index b4f99a0f1..000000000 --- a/src/app/_blog/[blogId]/page.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { notFound } from "next/navigation" - -import blogSource from "@/assets/blog/main.data.json" -import { isSepolia } from "@/utils" -import { genMeta } from "@/utils/route" - -import Detail from "./detail" - -export const generateMetadata = genMeta(async ({ params }) => { - const { blogId } = await params - const currentBlog = blogSource.find(blog => blog.id === blogId) - const imgUrl = currentBlog?.ogImg || currentBlog?.posterImg || "" - - return { - titleSuffix: currentBlog?.title, - relativeURL: currentBlog?.canonical || `https://scroll.io/blog/${currentBlog?.id}`, - description: currentBlog?.summary, - ogImg: imgUrl, - twitterImg: imgUrl, - alternates: { - canonical: currentBlog?.canonical, - }, - } -}) - -const BlogDetail = async ({ params }) => { - if (isSepolia) { - notFound() - } - const { blogId } = await params - return ( - <> - - - - - ) -} - -export default BlogDetail diff --git a/src/app/_blog/layout.tsx b/src/app/_blog/layout.tsx deleted file mode 100644 index 8ee03bbd4..000000000 --- a/src/app/_blog/layout.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { notFound } from "next/navigation" - -import { isSepolia } from "@/utils" -import { genMeta } from "@/utils/route" - -export const generateMetadata = genMeta(() => ({ - titleSuffix: "Blog", - relativeURL: "/blog", -})) - -export default function Layout({ children }) { - if (isSepolia) { - notFound() - } - return <>{children} -} diff --git a/src/app/_blog/page.tsx b/src/app/_blog/page.tsx deleted file mode 100644 index ab6613692..000000000 --- a/src/app/_blog/page.tsx +++ /dev/null @@ -1,288 +0,0 @@ -"use client" - -import { orderBy } from "lodash" -import { useSearchParams } from "next/navigation" -import { useEffect, useMemo, useState } from "react" - -import { Tune as TuneIcon } from "@mui/icons-material" -import { Box, Modal, Typography } from "@mui/material" -import { styled } from "@mui/system" - -import blogSource from "@/assets/blog/main.data.json" -import ArticleCard from "@/components/ArticleCard" -import SectionWrapper from "@/components/SectionWrapper" -import { LANGUAGE_MAP, getBlogCategoryList, getBlogSortList } from "@/constants" -import useCheckViewport from "@/hooks/useCheckViewport" -import { filterBlogsByLanguage } from "@/utils" - -const BlogContainer = styled(Box)(({ theme }) => ({ - padding: "0 6rem 14rem", - [theme.breakpoints.down("md")]: { - padding: "0 2rem 9rem", - }, -})) as typeof Box - -const BlogBox = styled(Box)(({ theme }) => ({ - marginBottom: "9rem", - [theme.breakpoints.down("md")]: { - marginBottom: "0", - padding: "3rem 0", - "&:not(:last-of-type)": { - borderBottom: `1px solid ${theme.vars.palette.themeBackground.highlight}`, - }, - "&:first-of-type": { - padding: "0 0 3rem", - }, - }, -})) as typeof Box - -const Header = styled(Box)(({ theme }) => ({ - padding: "15.5rem 0", - [theme.breakpoints.down("md")]: { - padding: "6.8rem 0 8rem", - }, -})) as typeof Box - -const Title = styled(Typography)(({ theme }) => ({ - fontSize: "6.4rem", - lineHeight: 1, - fontWeight: 600, - [theme.breakpoints.down("sm")]: { - fontSize: "3.6rem", - }, -})) - -const Summary = styled(Typography)(({ theme }) => ({ - fontSize: "2.6rem", - maxWidth: "56rem", - marginTop: "2.4rem", - [theme.breakpoints.down("md")]: { - marginTop: "2rem", - }, - [theme.breakpoints.down("sm")]: { - fontSize: "2rem", - }, -})) - -const FilterContainer = styled(Box)(({ theme }) => ({ - [theme.breakpoints.down("lg")]: { - display: "flex", - justifyContent: "flex-end", - }, -})) as typeof Box - -const MobileFilter = styled(Box)(({ theme }) => ({ - marginBottom: "1.7rem", - fontSize: "1.6rem", - fontWeight: 500, - color: theme.vars.palette.text.primary, - cursor: "pointer", - borderRadius: "20px", - border: `1px solid ${theme.vars.palette.text.primary}`, - width: "fit-content", - padding: "0.5rem 1.2rem", - [theme.breakpoints.between("sm", "lg")]: { - marginBottom: "3rem", - }, -})) as typeof Box - -const FilterModal = styled(Box)({ - display: "flex", - justifyContent: "center", - alignItems: "center", - height: "100vh", -}) as typeof Box - -const FilterModalContent = styled(Box)(({ theme }) => ({ - background: theme.vars.palette.background.default, - borderRadius: "2rem", - width: "35.8rem", - padding: "1.4rem 1.8rem", -})) as typeof Box - -const BlogBody = styled(Box)(({ theme }) => ({ - display: "grid", - gap: "3rem", - gridTemplateColumns: "1fr 4fr", - [theme.breakpoints.down("lg")]: { - gridTemplateColumns: "1fr", - gap: "0", - }, -})) as typeof Box - -const FilterTypeName = styled(Typography)(({ theme }) => ({ - color: theme.vars.palette.text.primary, - fontSize: "1.6rem", - fontWeight: 600, - marginBottom: "2rem", - "&:nth-of-type(2)": { - marginTop: "6.8rem", - }, - [theme.breakpoints.down("lg")]: { - height: "4rem", - lineHeight: "4rem", - marginBottom: 0, - fontSize: "2rem", - "&:nth-of-type(2)": { - marginTop: "5rem", - }, - }, -})) - -const FilterItem = styled(Typography)(({ theme }) => ({ - color: theme.vars.palette.text.primary, - cursor: "pointer", - fontSize: "1.6rem", - marginBottom: "1.2rem", - "&.active": { - fontWeight: 700, - }, - "&:hover": { - fontWeight: 700, - }, - [theme.breakpoints.down("lg")]: { - fontWeight: 500, - height: "3.6rem", - lineHeight: "3.6rem", - marginBottom: 0, - fontSize: "1.8rem", - }, -})) - -const BlogList = styled("ul")(({ theme }) => ({ - display: "flex", - flexDirection: "column", - justifyContent: "space-between", - width: "100%", - [theme.breakpoints.down("md")]: { - borderRight: "none", - marginBottom: "0", - justifyContent: "center", - }, -})) - -const Blog = () => { - const searchParams = useSearchParams() - const { isDesktop } = useCheckViewport() - const language = "en" - const BLOG_CATEGORY_LIST = useMemo(() => getBlogCategoryList(language), [language]) - const BLOG_SORT_LIST = useMemo(() => getBlogSortList(language), [language]) - const BLOG_COPY = useMemo(() => LANGUAGE_MAP[language], [language]) - const [filterOpen, setFilterOpen] = useState(false) - const handleFilterOpen = () => setFilterOpen(true) - const handleFilterClose = () => setFilterOpen(false) - - const [blogs, setBlogs] = useState(blogSource) - const [queryForm, setQueryForm] = useState({ - sort: "Newest", - category: searchParams?.get("category") ?? "All", - }) - - const blogsWithLang = useMemo(() => filterBlogsByLanguage(blogSource, language), [blogSource, language]) - - useEffect(() => { - const blogs = orderBy( - blogsWithLang.filter(blog => blog.type === queryForm.category || queryForm.category === "All"), - "date", - queryForm.sort === "Newest" ? "desc" : "asc", - ) - setBlogs(blogs) - }, [queryForm, blogsWithLang]) - - const hanleFilter = (attr: string, value: string) => { - handleFilterClose() - setQueryForm({ - ...queryForm, - [attr]: value, - }) - } - - const renderBlogs = () => { - return ( - - {blogs.map(blog => ( - - - - ))} - - ) - } - - const renderFilter = () => { - if (isDesktop) { - return ( - - {BLOG_COPY.category} - {BLOG_CATEGORY_LIST.map(({ label, key }) => ( - hanleFilter("category", key)} key={key} className={key === queryForm.category ? "active" : ""}> - {label} - - ))} - - {BLOG_COPY.sort} - {BLOG_SORT_LIST.map(({ label, key }) => ( - hanleFilter("sort", key)} key={key} className={key === queryForm.sort ? "active" : ""}> - {label} - - ))} - - ) - } - return ( - - - - {BLOG_COPY.filters} - - - - - - Filters - - - - - - Category - {BLOG_CATEGORY_LIST.map(({ label, key }) => ( - hanleFilter("category", key)} key={key} className={key === queryForm.category ? "active" : ""}> - {label} - - ))} - Order by - {BLOG_SORT_LIST.map(({ label, key }) => ( - hanleFilter("sort", key)} key={key} className={key === queryForm.sort ? "active" : ""}> - {label} - - ))} - - - - - ) - } - - return ( - - -
    - {BLOG_COPY.title} - {BLOG_COPY.sub_title} -
    - - {renderFilter()} - {renderBlogs()} - -
    -
    - ) -} - -export default Blog diff --git a/src/app/_components/LandingFooter.tsx b/src/app/_components/LandingFooter.tsx index b33544d15..2b9e804ee 100644 --- a/src/app/_components/LandingFooter.tsx +++ b/src/app/_components/LandingFooter.tsx @@ -1,6 +1,7 @@ import Link from "next/link" import ScrollMarkSvg from "@/assets/svgs/landingpage/scroll-mark.svg" +import { DOC_URL } from "@/constants/link" import AnchorLink from "./AnchorLink" import FooterRidge from "./FooterRidge" @@ -28,6 +29,17 @@ const FOOTER_COLUMNS = [ { label: "AI hardware", href: "/#hardware" }, ], }, + { + title: "Resources", + links: [ + // the blog, back on the site and opening the column (Zhengqi, 2026-09-21); the three + // under it are the ones his file carries, so the two footers finally say the same thing + { label: "Blog", href: "/blog" }, + { label: "Documentation", href: DOC_URL, external: true }, + { label: "White paper", href: "/files/whitepaper.pdf", external: true }, + { label: "Scroll swap", href: "https://swap.scroll.io/swap?input=0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", external: true }, + ], + }, ] interface LandingFooterProps { @@ -55,8 +67,12 @@ const LandingFooter = ({ ridge = false }: LandingFooterProps) => ( {FOOTER_COLUMNS.map(({ title, links }) => (