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

Published on
Currently displaying Chinese version content. This article doesn't have a English version yet. Please stay tuned!

上一篇我們成功在 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 items-center py-28 lg:flex-row"> <h3 className="mb-10 text-center text-4xl font-bold leading-tight tracking-tighter lg:mb-0 lg:w-1/2 lg:pr-4 lg:text-left lg:text-5xl"> 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="mx-auto w-full max-w-7xl px-5">{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="mt-16 mb-16 flex flex-col items-center md:mb-12 md:flex-row md:justify-between"> <h1 className="text-6xl font-bold leading-tight tracking-tighter md:pr-8 md:text-8xl"> Oh. So. Pro. </h1> <h4 className="mt-5 text-center text-lg md:pl-8 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="mb-20 gap-4 md:mb-28 md:grid md:grid-cols-2"> <div> <h3 className="line-clamp-3 mb-4 text-4xl leading-tight lg:text-6xl"> <Link href={uri}> <a className="hover:underline">{title}</a> </Link> </h3> <div className="mb-4 text-lg md:mb-0"> <Date dateString={date} /> </div> </div> <div> <p className="line-clamp-6 mb-4 text-lg leading-relaxed">{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="aspect-w-16 aspect-h-9 w-full sm:mx-0"> <Link href={uri}> <a> <Image layout="fill" objectFit="cover" alt={featuredImage?.altText} src={featuredImage?.sourceUrl} className="shadow transition-shadow duration-200 hover:shadow-lg" /> </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 font-bold leading-tight tracking-tighter md:text-7xl"> More Stories </h2> <div className="mb-32 grid grid-cols-1 gap-6 md:grid-cols-2"> {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="line-clamp-3 mb-3 text-3xl leading-snug"> <Link href={uri}> <a className="hover:underline">{title}</a> </Link> </h3> <div className="mb-4 text-lg"> <Date dateString={date} /> </div> <p className="line-clamp-5 mb-4 text-lg leading-relaxed">{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 完成首頁文章列表的切版了,下一篇我們會繼續切版文章內頁!

本文同步發佈在 iT 邦幫忙 2021 iThome 鐵人賽