Blog

👭 Budowanie 2 stron Next.js w cenie 1, przez hackowanie trybu jasnego/ciemnego

Leonardo Losoviz
Autor: Leonardo Losoviz ·

Niedawno zespół Gato GraphQL uruchomił Gato Plugins — siostrzaną stronę do Gato GraphQL.

Zauważysz, że obie strony są takie same! Jedyna różnica między nimi to schemat kolorów: Gato GraphQL ma ciemny motyw, podczas gdy Gato Plugins ma jasny motyw.

Sekcja bloga na obu stronach jest dokładnie taka sama:

Sekcja bloga na gatographql.com
Sekcja bloga na gatographql.com
Sekcja bloga na gatoplugins.com
Sekcja bloga na gatoplugins.com

Sekcja dokumentacji jest również taka sama:

Sekcja docs na gatographql.com
Sekcja docs na gatographql.com
Sekcja docs na gatoplugins.com
Sekcja docs na gatoplugins.com

Czasem sekcja jest inna, jednak leżąca u jej podstaw fundacja jest taka sama.

Na przykład rozszerzenia Gato GraphQL i wtyczki Gato Plugins używają tego samego układu:

Sekcja rozszerzeń na gatographql.com
Sekcja rozszerzeń na gatographql.com
Sekcja wtyczek na gatoplugins.com
Sekcja wtyczek na gatoplugins.com

(Przy okazji, logo też są praktycznie takie same! 😜)

Logo na gatographql.com
Logo na gatographql.com
Logo na gatoplugins.com
Logo na gatoplugins.com

I tak, ten artykuł również jest na obu stronach! 😂

Przeczytaj na gatographql.com: Building 2 Nextjs websites at the price of 1, by hacking the dark/light mode.

Jednak jest dokładnie 7 różnic między artykułami na obu stronach. Czy możesz je wszystkie znaleźć? Jeśli tak, dam ci kupon ze zniżką na Gato GraphQL 🙏

Dlaczego użyliśmy trybów jasnego/ciemnego, aby stworzyć 2 strony

Jest kilka powodów:

Nie mam czasu ani energii, aby utrzymywać dwie oddzielne bazy kodu. Muszę dbać o prostotę.

Każda godzina poświęcona stronie to godzina, której nie poświęcam żadnemu ze swoich produktów.

Chcę, żeby wyglądały podobnie, aby użytkownicy rozpoznawali je jako część tej samej rodziny.

Nie jestem projektantem. Osiągnąwszy ten wygląd i styl, byłem zadowolony i nie chciałem zaczynać od zera.

Innymi słowy: dlatego że jest to tanie i proste. Zaoszczędziło mi to mnóstwo czasu i energii, które mogłem poświęcić własnemu produktowi.

Jako wadę, 2 strony nie obsługują przełącznika między trybem jasnym i ciemnym, więc ich styl jest stały — ale to coś, z czym mogę żyć.


No dobrze! Zakasajmy więc rękawy i zobaczmy, jak to zostało zrobione.

Stack: Aplikacja jest oparta na Next.js i używa Tailwind CSS do stylizacji.

Została stworzona jako połączenie kilku szablonów od Cruip, dostosowanych do naszych potrzeb. (Te szablony są piękne!)

Treścią zarządza się przez Contentlayer.

Wyodrębnij wspólny kod do pakietu współdzielonego i hostuj wszystko w monorepo

Ponieważ baza kodu obu stron jest taka sama, ma sens hostowanie ich razem w monorepo.

Moje repozytorium początkowo zawierało jeden projekt:

  • gatographql.com

Zostało ono zrestrukturyzowane w następujący sposób:

  • apps/gatographql.com: Strona Gato GraphQL
  • apps/gatoplugins.com: Strona Gato Plugins
  • packages/shared/gatoapp: Wspólny kod obu stron

To jest moje środowisko pracy w VSCode:

Moja struktura monorepo
Moja struktura monorepo

Nie używam niczego wymyślnego do monorepo — zwykłe workspaces doskonale spełnia zadanie.

Mój plik package.json w katalogu głównym monorepo wygląda teraz tak:

{
  "name": "gatowebsites",
  "version": "2.0.0",
  "private": true,
  "workspaces": [
    "apps/*",
    "packages/shared/*"
  ]
}

Ponadto dodałem skrypty do package.json, aby uruchamiać/budować/wdrażać oba projekty (w tym wdrażanie do Netlify, gdzie oba są hostowane):

{
  "scripts": {
    "dev-gatographql": "npm run dev --workspace=apps/gatographql",
    "build-gatographql": "npm run build --workspace=apps/gatographql",
    "deploy-gatographql": "npm run deploy-staging-gatographql",
    "deploy-dev-gatographql": "netlify dev --filter gatographql",
    "deploy-staging-gatographql": "netlify deploy --build --context deploy-preview --filter gatographql",
    "deploy-prod-gatographql": "netlify deploy --build --prod --context production --filter gatographql",
 
    "dev-gatoplugins": "npm run dev --workspace=apps/gatoplugins",
    "build-gatoplugins": "npm run build --workspace=apps/gatoplugins",
    "deploy-gatoplugins": "npm run deploy-staging-gatoplugins",
    "deploy-dev-gatoplugins": "netlify dev --filter gatoplugins",
    "deploy-staging-gatoplugins": "netlify deploy --build --context deploy-preview --filter gatoplugins",
    "deploy-prod-gatoplugins": "netlify deploy --build --prod --context production --filter gatoplugins"
  }
}

Przekształć komponenty tak, aby przyjmowały props z niestandardowymi danymi

W miarę możliwości przenosimy kod z każdej ze stron do pakietu współdzielonego, a następnie dostosowujemy zachowanie za pomocą props.

Na przykład pakiet współdzielony gatoapp zawiera komponent BlogSection (do wyświetlania strony /blog na obu stronach):

import PopularPosts from 'gatoapp/components/blog/popular-posts'
import PageHeader from 'gatoapp/components/page-header'
import { BlogPostProps } from 'gatoapp/types/list-types'
import BlogSectionPostList from './blog-section-post-list'
import { useEffect, useState, Suspense } from "react";
 
export default function BlogSection({
  blogPosts,
  title = "Our Blog",
  description,
  campaignBanner,
}: {
  blogPosts: BlogPostProps[],
  title?: string,
  description: string,
  campaignBanner?: React.ReactNode
}) {
  const sidebar = (
    <aside className="hidden sm:block relative mt-12 md:mt-0 md:w-64 md:ml-12 lg:ml-20 md:shrink-0">
      <PopularPosts
        blogPosts={blogPosts}
      />
    </aside>
  )
 
  return (
    <div className="max-w-6xl mx-auto px-4 sm:px-6">
      <div className="pt-32 pb-12 md:pt-40 md:pb-20">
 
        {campaignBanner}
 
        {/* Page header */}
        <PageHeader
          title={title}
          description={description}
        />
 
        {/* Main content */}
        <BlogSectionPostList
          blogPosts={blogPosts}
          sidebar={sidebar}
        />
 
      </div>
    </div>
  )
}

Cała zawartość jest taka sama, z wyjątkiem:

  • Nagłówka strony (tytuł/opis)
  • Artykułów na blogu
  • Banera kampanii

Ponieważ obie strony mogą niezależnie od siebie prowadzić własne kampanie, przekazanie campaignBanner jako React.ReactNode nie ogranicza możliwości dostosowania kampanii.

Na przykład w momencie publikowania tego artykułu prowadzę kampanię w Gato GraphQL, ale nie w Gato Plugins:

Baner kampanii na gatographql.com
Baner kampanii na gatographql.com

Aby wstrzyknąć artykuły blogu, potrzeba nieco więcej logiki.

Wstrzykiwanie artykułów blogu

Dane artykułów blogu są wstrzykiwane do BlogSection za pomocą prop blogPosts.

Ponieważ używam Contentlayer, każda strona będzie miała plik contentlayer.config.js w katalogu głównym, definiujący typy na stronie.

Ten plik konfiguracyjny nie może zostać przeniesiony do współdzielonego pakietu gatoapp. Dlatego tworzymy moduł eksportowy, aby dostarczyć konfigurację dla współdzielonych typów, a następnie importujemy je w contentlayer.config.js każdej strony, zachowując logikę DRY.

gatoapp ma moduł eksportowy contentlayer.config.js dostarczający współdzielony typ BlogPost:

import { defineDocumentType } from 'contentlayer2/source-files'
 
const BlogPost = defineDocumentType(() => ({
  name: 'BlogPost',
  filePathPattern: `blog/**/*.mdx`,
  contentType: 'mdx',
  fields: {
    title: {
      type: 'string',
      required: true
    },
    publishedAt: {
      type: 'date',
      required: true
    },
    description: {
      type: 'string',
      required: true,
    },
    image: {
      type: 'string',
    },
  },
  computedFields: {
    slug: {
      type: 'string',
      resolve: (doc) => doc._raw.flattenedPath.replace(new RegExp('^blog/?'), ''),
    },
    urlPath: {
      type: 'string',
      resolve: (doc) => `/blog/${doc._raw.flattenedPath.replace(new RegExp('^blog/?'), '')}`,
    },
  },
}))
 
module.exports = {
  types: {
    BlogPost: BlogPost,
  },
}

Plik contentlayer.config.js zarówno w apps/gatographql.com, jak i w apps/gatoplugins.com może następnie zaimportować ten typ:

import { makeSource } from 'contentlayer2/source-files'
import ContentLayerConfig from '../../packages/shared/gatoapp/contentlayer.config.js'
 
const BlogPost = ContentLayerConfig.types.BlogPost
 
export default makeSource({
  documentTypes: [BlogPost],
})

Normalnie, aby odwołać się do typu BlogPost w naszym kodzie, importowalibyśmy go w taki sposób:

import { BlogPost } from '@/.contentlayer/generated'

Jednak typ BlogPost żyje wewnątrz strony, a nie wewnątrz pakietu współdzielonego, więc współdzielony kod nie może bezpośrednio odwołać się do tego typu.

Rozwiązujemy to za pomocą triku: kopiujemy definicję tego typu ze skompilowanego pliku Contentlayer (w apps/gatographql/.contentlayer/generated/types.d.ts) i wklejamy ją do nowego pliku types.tsx w pakiecie współdzielonym:

import type { MDX, IsoDateTimeString } from 'contentlayer2/core'
 
export type BlogPost = {
  // _id: string // not needed
  // _raw: Local.RawDocumentData // not needed
  type: 'BlogPost'
  title: string
  publishedAt: IsoDateTimeString
  description: string
  image?: string | undefined
  body: MDX
  slug: string,
  urlPath: string,
}

Następnie odwołujemy się do tego współdzielonego typu w kodzie współdzielonym:

import { BlogPost } from 'gatoapp/types'

Ponieważ właściwości między typami BlogPost ze strony i z pakietu współdzielonego są takie same, możemy przekazać pierwszy do komponentu oczekującego drugiego.

Stwórz kontekst do wstrzykiwania globalnych props

Komponenty menu nawigacyjnego będą renderowane w kodzie współdzielonym, ale muszą być dostarczane przez kod strony, ponieważ każda strona będzie miała własne menu.

Menu pojawiają się na wszystkich stronach i nie chcemy za każdym razem przekazywać ich przez props. Dlatego używamy kontekstu React, który pozwala nam wstrzyknąć komponenty menu nawigacyjnego tylko raz.

Tworzymy kontekst o nazwie AppComponent w pakiecie współdzielonym:

'use client'
 
import React from 'react'
import { createContext, useContext } from 'react'
import { StaticImageData } from 'next/image'
 
type ContextProps = {
  header: {
    menu: React.ReactNode,
    mobileMenu: React.ReactNode,
  },
}
 
const AppComponentContext = createContext<ContextProps>({
  header: {
    menu: <div></div>,
    mobileMenu: <div></div>,
  },
})
 
export interface AppComponentProviderInterface extends ContextProps {
  children: React.ReactNode,
}
 
export default function AppComponentProvider({
  children,
  header,
}: AppComponentProviderInterface) {  
  return (
    <AppComponentContext.Provider value={{ header }}>
      {children}
    </AppComponentContext.Provider>
  )
}
 
export const useAppComponentProvider = () => useContext(AppComponentContext)

Odwołujemy się do niego w naszym pakiecie współdzielonym:

'use client'
 
import Logo from './logo'
import HeaderMobile from './header-mobile'
import { useAppComponentProvider } from 'gatoapp/app/appcomponent-provider'
 
export default function Header() {
  const AppComponent = useAppComponentProvider()
  return (
    <header className="fixed w-full z-50">
      <div className={`absolute inset-0 bg-opacity-70 backdrop-blur -z-10 bg-white border-slate-200 border-b dark:border-b-0 dark:bg-transparent dark:border-slate-800`} aria-hidden="true"/>
      <div className="max-w-6xl mx-auto px-4 sm:px-6">
        <div className="flex items-center justify-between h-16">
 
          {/* Site branding */}
          <div className="flex-1">
            <Logo />
          </div>
 
          <nav className="hidden md:flex md:grow">
            {/* Desktop menu links */}
            {AppComponent.header.menu}
          </nav>
 
          <HeaderMobile />
 
        </div>
      </div>
    </header>
  )
}

I wstrzykujemy go przez kod strony, w apps/gatographql/app/(default)/layout.tsx:

import AppComponentProvider from 'gatoapp/app/appcomponent-provider'
import HeaderMenu from '@/components/menu/header-menu'
import HeaderMobileMenu from '@/components/menu/header-mobile-menu'
import DefaultLayout from 'gatoapp/app/(default)/layout'
 
export default function AppDefaultLayout({
  children,
}: {
  children: React.ReactNode
}) {  
  return (
    <AppComponentProvider
      header={{
        menu: <HeaderMenu />,
        mobileMenu: <HeaderMobileMenu />,
      }}
    >
      <DefaultLayout>
        {children}
      </DefaultLayout>
    </AppComponentProvider>
  )
}

Na koniec strona implementuje własny komponent HeaderMenu:

import Link from 'next/link'
import Dropdown from 'gatoapp/components/utils/dropdown'
 
export default function HeaderMenu() {
  return (
    <ul className="flex grow justify-center flex-wrap items-center">
      <li>
        <Link href="/pricing">Pricing</Link>
      </li>
      <li>
        <Link href='/extensions'>Extensions</Link>
      </li>      
      <Dropdown title="Product">
        <li>
          <Link href='/features'>Features</Link>
        </li>
        <li>
          <Link href='/highlights'>Highlights</Link>
        </li>
        <li>
          <Link href='/demos'>Demos</Link>
        </li>
        <li>
          <Link href='/comparisons'>Comparisons</Link>
        </li>
        <li>
          <Link href='/roadmap'>Roadmap</Link>
        </li>
      </Dropdown>
    </ul>
  )
}

Style dla trybu jasnego i ciemnego

W Tailwind poprzedzamy klasę prefiksem dark:, aby użyć jej, gdy tryb ciemny jest włączony.

Zatem kod naszego pakietu współdzielonego musi zawierać style dla obu wariantów — jasnego i ciemnego.

Na przykład komponent PageHeader wyświetla opis z różnymi kolorami dla trybu jasnego (text-gray-600) i trybu ciemnego (dark:text-slate-400):

export default function PageHeader({
  title,
  description,
  children,
}: {
  title: string,
  description?: string,
  children?: React.ReactNode,
}) {
  return (
    <div className="max-w-3xl mx-auto text-center">
      <h1 className="h1 pb-4">{title}</h1>
      {description && (
        <div className="max-w-3xl mx-auto">
          <p className="text-gray-600 dark:text-slate-400">{description}</p>
        </div>
      )}
      {children}
    </div>
  )
}

Ustaw tryb jasny lub ciemny na stronie

gatographql.com używa trybu ciemnego. Definiuje go przez dodanie klasy dark do <body> w pliku apps/gatographql/app/layout.tsx (plus klasy stylizacji: bg-slate-900 text-slate-100):

import { Inter } from 'next/font/google'
import RootLayoutHeader from 'gatoapp/app/layout-header'
 
const inter = Inter({
  subsets: ['latin'],
  variable: '--font-inter',
  display: 'swap'
})
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <RootLayoutHeader />
      <body className={`${inter.variable} dark bg-slate-900 text-slate-100`}>
        {children}
      </body>
    </html>
  )
}

gatoplugins.com używa trybu jasnego. Jest to tryb domyślny, więc nie ma potrzeby dodawania żadnej specjalnej klasy do <body> (tylko klasy stylizacji: bg-white text-slate-700):

import { Inter } from 'next/font/google'
import RootLayoutHeader from 'gatoapp/app/layout-header'
 
const inter = Inter({
  subsets: ['latin'],
  variable: '--font-inter',
  display: 'swap'
})
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <RootLayoutHeader />
      <body className={`${inter.variable} bg-white text-slate-700`}>
        {children}
      </body>
    </html>
  )
}

To wszystko

Mam teraz 2 strony, które zdobyłem w cenie 1. I jestem z tego bardzo zadowolony.

Teraz idź i znajdź 7 różnic i odbierz swoją nagrodę! 😅


Dowiedz się, co będzie dalej

Zapisz się do naszego newslettera: dowiedz się, gdy wydamy nową wersję, uruchomimy nową wtyczkę lub będziemy mieli nowości do przekazania.