Day12 用 TailwindCSS 切版部落格首页,显示 WordPress 文章列表

上一篇我们成功在 Next.js 安装 TailwindCSS,今天我们要实际来切版首页,显示文章列表!

切版目标

这个系列文章主要在呈现用 Next.js 当作 WordPress 前端会遇到的各种眉眉角角,切出好看的版不是这个系列重点,因此这边主要 demo TailwindCSS 的范例用法,并且会参照现成的 cms-wordpress example,稍做修改来当作我们部落格模板。

最後呈现的画面如下面两张图,支援手机版和桌面版的 RWD。

桌面版:

Imgur

手机版:

Imgur

实作

这边主要参照 Next.js 官方 cms-wordpress 范例来实作,这个范例也是用 TailwindCSS 作为 CSS framework,并且里面已经切出了首页和文章内页的样式范例。

我们这一篇会先把首页相关样式挑出来,稍做修改来使用。

前置说明

这篇的完整程序码改动可以在这个 commit 看到。

而我在自己的 oh-so-pro-blog 范例专案有先做一些影响档案架构的设计与改动,包含:

  • 将 pages、components 等页面逻辑程序码收纳在 /src 目录底下(Next.js 官方文件
  • 使用原子设计(Atomic Design)方式组织 components,将元件依照粒度分类放进 atoms、molecules、organisms、templates 资料夹(原子设计参考文章
  • 设定 Absolute imports,改用绝对路径来 import 各个 JS 档案(例如:import IndexPage from '@/components/templates/IndexPage'),而非超多层相对路径(例如:import PostPreview from '../../organisms/PostPreview'
  • 安装 Storybook 用来独立开发各个 component,所以看 commit 时会看到很多 XXX.stories.js 档案,下面文章不会涵盖这部分

有机会的话我会在後续文章详细说明,下面的程序码我会以我的档案架构为主,如果你自己的专案档案架构不一样,需要根据你的状况调整档案放置位置和 import 路径。

开始切版罗!

首先是首页进入点 /src/pages/index.js,精简了 return 区块,将全站 Layout 和首页样式 IndexPage 独立成 component,完整 code 如下:

import { useMemo } from 'react'
import { useQuery } from '@apollo/client'

import { initializeApollo, addApolloState } from '@/lib/apolloClient'
import { allPostsQueryVars, ALL_POSTS_QUERY, transformAllPostsData } from '@/graphql/allPostsQuery'
import Layout from '@/components/layout'
import IndexPage from '@/components/templates/IndexPage'

export default function Home() {
  const { data } = useQuery(ALL_POSTS_QUERY, {
    variables: allPostsQueryVars,
  })
  const allPosts = useMemo(() => transformAllPostsData(data), [data]) || []

  return (
    <Layout>
      <IndexPage posts={allPosts} />
    </Layout>
  )
}

export async function getStaticProps() {
  const apolloClient = initializeApollo()

  await apolloClient.query({
    query: ALL_POSTS_QUERY,
    variables: allPostsQueryVars,
  })

  return addApolloState(apolloClient, {
    props: {},
    revalidate: 1,
  })
}

接着先看 /src/components/layout.js,这将来会是全站各页面共通的 layout,完整 code 如下:

import Head from 'next/head'

import Footer from '@/components/organisms/footer'
import Meta from '@/components/meta'

export default function Layout({ children }) {
  return (
    <>
      <Head>
        <title>Oh. So. Pro. blog</title>
      </Head>
      <div className="min-h-screen">
        <main>{children}</main>
      </div>
      <Footer />
    </>
  )
}

layout 里面的 Footer 则在 /src/components/organisms/Footer.js

import Container from '@/components/molecules/Container'

export default function Footer() {
  return (
    <footer>
      <Container>
        <div className="flex flex-col lg:flex-row items-center py-28">
          <h3 className="lg:pr-4 mb-10 lg:mb-0 lg:w-1/2 text-4xl lg:text-5xl font-bold tracking-tighter leading-tight text-center lg:text-left">
            A pro blog for productive professional programmers
          </h3>
        </div>
      </Container>
    </footer>
  )
}

Footer 用到的 Container 则在 /src/components/molecules/Container.js

export default function Container({ children }) {
  return <div className="px-5 mx-auto w-full max-w-7xl">{children}</div>
}

共用 Layout 到此全部实作完毕,接着实际进到首页的内容,/src/components/templates/IndexPage.js

import Container from '@/components/molecules/Container'
import Intro from '@/components/molecules/Intro'
import HeroPost from '@/components/organisms/HeroPost'
import PostList from '@/components/organisms/PostList'

export default function IndexPage({ posts }) {
  const heroPost = posts?.[0]
  const morePosts = posts?.slice(1) || []

  return (
    <Container>
      <Intro />
      {heroPost && (
        <HeroPost
          title={heroPost.title}
          featuredImage={heroPost.featuredImage}
          date={heroPost.date}
          uri={heroPost.uri}
          excerpt={heroPost.excerpt}
        />
      )}
      {morePosts.length > 0 && <PostList posts={morePosts} />}
    </Container>
  )
}

/src/components/molecules/Intro.js

export default function Intro() {
  return (
    <section className="flex flex-col md:flex-row md:justify-between items-center mt-16 mb-16 md:mb-12">
      <h1 className="md:pr-8 text-6xl md:text-8xl font-bold tracking-tighter leading-tight">
        Oh. So. Pro.
      </h1>
      <h4 className="md:pl-8 mt-5 text-lg text-center md:text-left">A pro blog for productive professional programmers</h4>
    </section>
  )
}

/src/components/organisms/HeroPost.js

import Link from 'next/link'

import CoverImage from '@/components/atoms/CoverImage/CoverImage'
import Date from '@/components/atoms/Date/Date'
export default function HeroPost({ title, featuredImage, date, excerpt, uri }) {
  return (
    <section>
      <div className="mb-8 md:mb-16">
        {featuredImage && <CoverImage title={title} featuredImage={featuredImage} uri={uri} />}
      </div>
      <div className="md:grid md:grid-cols-2 mb-20 md:mb-28 gap-4">
        <div>
          <h3 className="mb-4 text-4xl lg:text-6xl leading-tight line-clamp-3">
            <Link href={uri}>
              <a className="hover:underline">{title}</a>
            </Link>
          </h3>
          <div className="mb-4 md:mb-0 text-lg">
            <Date dateString={date} />
          </div>
        </div>
        <div>
          <p className="mb-4 text-lg leading-relaxed line-clamp-6">{excerpt}</p>
        </div>
      </div>
    </section>
  )
}

/src/components/atoms/CoverImage.js

import Image from 'next/image'
import Link from 'next/link'

export default function CoverImage({ featuredImage, uri }) {
  if (!uri || !featuredImage?.sourceUrl) return null

  return (
    <div className="sm:mx-0 w-full aspect-w-16 aspect-h-9">
      <Link href={uri}>
        <a>
          <Image
            layout="fill"
            objectFit="cover"
            alt={featuredImage?.altText}
            src={featuredImage?.sourceUrl}
            className="shadow hover:shadow-lg transition-shadow duration-200"
          />
        </a>
      </Link>
    </div>
  )
}

/src/components/atoms/Date.js

import { parseISO, format } from 'date-fns'

export default function Date({ dateString }) {
  if (!dateString) return null

  const date = parseISO(dateString)

  return <time dateTime={dateString}>{format(date, 'LLLL	d, yyyy')}</time>
}

/src/components/organisms/PostList.js

import PostPreview from '@/components/organisms/PostPreview'

export default function PostList({ posts }) {
  return (
    <section>
      <h2 className="mb-8 text-6xl md:text-7xl font-bold tracking-tighter leading-tight">
        More Stories
      </h2>
      <div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-32">
        {posts.map((post) => (
          <PostPreview
            key={post?.id}
            title={post?.title}
            featuredImage={post?.featuredImage}
            date={post?.date}
            uri={post?.uri}
            excerpt={post?.excerpt}
          />
        ))}
      </div>
    </section>
  )
}

/src/components/organisms/PostPreview.js

import Link from 'next/link'

import CoverImage from '@/components/atoms/CoverImage'
import Date from '@/components/atoms/Date'

export default function PostPreview({ title, featuredImage, date, excerpt, uri }) {
  return (
    <div>
      <div className="mb-5">
        {featuredImage && <CoverImage title={title} featuredImage={featuredImage} uri={uri} />}
      </div>
      <h3 className="mb-3 text-3xl leading-snug line-clamp-3">
        <Link href={uri}>
          <a className="hover:underline">{title}</a>
        </Link>
      </h3>
      <div className="mb-4 text-lg">
        <Date dateString={date} />
      </div>
      <p className="mb-4 text-lg leading-relaxed line-clamp-5">{excerpt}</p>
    </div>
  )
}

安装 TailwindCSS line-clamp plugin

TailwindCSS 也是有 plugin 的,可以加入更多 class 支援更复杂的 CSS 效果。

因为文章区块我希望文章 title 和 excerpt 文字最多只显示三行和五行,超过行数的话要用 ... 截断,这很适合用 line-clamp css 技巧来实现,但 TailwindCSS 官方没有内建对应 class 能用,而是做成 plugin 的方式,需要时再额外安装,因此我们这边来把它安装起来。

@tailwindcss/line-clamp 相关连结:

安装首先输入下面指令:

yarn add @tailwindcss/line-clamp

接着修改 /tailwind.config.js,在 plugins 阵列多加这行:

module.exports = {
  mode: 'jit',
  purge: ['./src/**/*.{js,ts,jsx,tsx}'],
  darkMode: false, // or 'media' or 'class'
  theme: {},
  variants: {
    extend: {},
  },
  plugins: [
    require('@tailwindcss/line-clamp'), // <=== Add this
  ],
}

安装完就会多出 line-clamp-3line-clamp-5 等等的 class 可以直接套用了,非常方便!

安装 TailwindCSS aspect-ratio plugin

在实作 CoverImage 时,我也用到了 TailwindCSS 的 aspect-ratio plugin,来指定图片的长宽比例,因此我们也要安装它:

yarn add @tailwindcss/aspect-ratio

然後一样修改 /tailwind.config.js,在 plugins 阵列多加一行:

module.exports = {
  // ...
  plugins: [
    require('@tailwindcss/line-clamp'),
    require('@tailwindcss/aspect-ratio'), // <=== Add this
  ],
}

安装完後一样会多 class 可以用,像是 aspect-w-16 aspect-h-9 可以指定长宽比为 16:9。

aspect-ratio plugin 相关连结:

完成!首页切版!

最後再次执行 yarn dev,应该就会看到首页变得比较漂亮了!恭喜你!

这篇的完整程序码改动可以在这个 commit 看到。

今天我们成功使用 TailwindCSS 完成首页文章列表的切版了,下一篇我们会继续切版文章内页!


<<:  Day11 iPhone捷径-媒体Part1

>>:  第 10 天 阶段达成继续奋斗( leetcode 003 )

可以代替GoogleChrome的4个浏览器

##1:Firefox -可以阻挡大量追踪器,保护你的隐私 -对你警告数据泄漏 -友善的UI ##2...

【Day 10】 讨论 Data Analytics Pipeline - Google Analytics on AWS (整体)

大家好~ 昨天我们已经成功拉取 Google Analytics 资料到 AWS,可是我们发现『抓取...

Day5-自制网站卷轴(下)_我就特立独行

今天要介绍的是「如果我就想把卷轴放在不是最右边的位置怎麽办?」 这是自制网站卷轴的最後一篇啦~ 我知...

[C 语言笔记--Day14] Pointers to Functions II

这篇文章接续上一篇的话题 来纪录一些更加奇怪的写法以及如何解读 最後也会纪录一下我看过的一个 poi...

Material UI in React [ Day 11] Date & Time picker 日期时间输入

Date / Time pickers 可以透过官方文件知道有两种做法,一种是利用原生 input ...