Привет, друзья!


В этой серии из 2 статей-туториалов мы с вами продолжаем разрабатывать клиент-серверное (фуллстек — fullstack) приложение с помощью Next.js и TypeScript.



  1. Наше приложение будет представлять собой блог — относительно полноценную платформу для публикации, редактирования и удаления постов.
  2. Мы реализовали собственный сервис аутентификации на основе JSON Web Tokens и HTTP-куки.
  3. Данные пользователей и постов будут храниться в реляционной базе данных SQLite.

В первом туториале мы подготовили и настроили проект, а также реализовали серверную часть приложения с помощью интерфейса роутов (API Routes), во втором — разработаем клиента и проверим работоспособность приложения.


Обратите внимание: данный туториал рассчитан на разработчиков, которые имеют некоторый опыт работы с React и Node.js.


Для тех, кого интересует только код, вот соответствующий репозиторий.


Интересно? Тогда прошу под кат.


Настройка проекта


Why Did You Render


Why Did You Render — утилита для отладки React-приложений, позволяющая определить причину повторного рендеринга компонента. Для того, чтобы иметь возможность использовать эту утилиту в Next.js-приложении необходимо сделать 2 вещи:


  • настроить пресет (preset) транспилятора Babel;
  • инициализировать утилиту и импортировать ее в основной компонент приложения.

Настраиваем пресет Babel в файле babel.config.js в корне проекта:


module.exports = function (api) {
  const isServer = api.caller((caller) => caller?.isServer)
  const isCallerDevelopment = api.caller((caller) => caller?.isDev)

  // пресеты
  const presets = [
    [
      'next/babel',
      {
        'preset-react': {
          runtime: 'automatic',
          importSource:
            // код wdyr должен выполняться только на клиенте
            // и только в режиме разработки
            !isServer && isCallerDevelopment
              ? '@welldone-software/why-did-you-render'
              : 'react'
        }
      }
    ]
  ]

  return { presets }
}

Инициализируем WDYR в файле utils/wdyr.ts:


import React from 'react'

// код выполняется только в режиме разработки
// и только на клиенте
if (process.env.NODE_ENV === 'development' && typeof document !== 'undefined') {
  const whyDidYouRender = require('@welldone-software/why-did-you-render')
  whyDidYouRender(React, {
    trackAllPureComponents: true
  })
}

export {}

Импортируем WDYR в файле _app.tsx:


import '@/utils/wdyr'

После этого для отладки в файле компонента достаточно добавить такую строчку:


SomeComponent.whyDidYouRender = true

Material UI


Material UI — самая популярная библиотека компонентов React. Для ее правильного использования в Next.js-приложении необходимо сделать 2 вещи:


  • настроить плагин (plugin) Babel;
  • настроить кэш Emotion — решения CSS-в-JS, которое используется MUI для стилизации компонентов.

Настраиваем плагин Babel в файле babel.config.js:


module.exports = function (api) {
  // пресеты
  // ...

  // плагины
  const plugins = [
    [
      'babel-plugin-import',
      {
        libraryName: '@mui/material',
        libraryDirectory: '',
        camel2DashComponentName: false
      },
      'core'
    ]
  ]

  return { presets, plugins }
}

Для чего нужен этот плагин? Для уменьшения размера клиентской сборки. Проблема в том, что при импорте компонента MUI по названию, например:


import { Button } from '@mui/material'

В сборку попадет весь пакет @mui/material, т.е. все компоненты MUI независимо от того, используются они в приложении или нет. babel-plugin-import преобразует именованный импорт в дефолтный, т.е. на выходе мы получаем, например:


import Button from '@mui/material/Button'

Таким образом, в сборку попадают только компоненты, которые используются в приложении.


Настройка кэша Emotion необходима для предотвращения вспышки нестилизованного контента (flash of unstyled content), например, когда сначала загружаются дефолтные стили браузера и только потом стили MUI, а также для обеспечения возможности легкой перезаписи стилей MUI, т.е. кастомизации компонентов (источник решения).


Определяем утилиту для создания кэша Emotion в файле utils/createEmotionCache.ts:


import createCache from '@emotion/cache'

// Создаем на клиенте тег `meta` с `name="emotion-insertion-point"` в начале  <head>.
// Это позволяет загружать стили MUI в первоочередном порядке.
// Это также позволяет разработчикам легко перезаписывать стили MUI, например, с помощью модулей CSS.
export default function createEmotionCache() {
  let insertionPoint

  if (typeof document !== 'undefined') {
    const emotionInsertionPoint = document.querySelector<HTMLMetaElement>(
      'meta[name="emotion-insertion-point"]'
    )
    insertionPoint = emotionInsertionPoint ?? undefined
  }

  return createCache({ key: 'mui-style', insertionPoint })
}

Кэш необходимо создавать при запуске приложения как на сервере, так и на клиенте. Настраиваем рендеринг документа в файле _document.tsx (создание кэша на сервере):


import createEmotionCache from '@/utils/createEmotionCache'
import createEmotionServer from '@emotion/server/create-instance'
import Document, {
  DocumentContext,
  Head,
  Html,
  Main,
  NextScript
} from 'next/document'

export default function MyDocument(props: any) {
  return (
    <Html lang='en'>
      <Head>
        <link rel='icon' href='data:.' />
        {/* дефолтным шрифтом MUI является Roboto, мы будем использовать Montserrat */}
        <link rel='preconnect' href='https://fonts.googleapis.com' />
        <link rel='preconnect' href='https://fonts.gstatic.com' />
        <link
          href='https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500&display=swap'
          rel='stylesheet'
        />
        {/* ! */}
        <meta name='emotion-insertion-point' content='' />
        {props.emotionStyleTags}
      </Head>
      <body>
        <Main />
        <NextScript />
      </body>
    </Html>
  )
}

// `getInitialProps` принадлежит `_document` (а не `_app`),
// это совместимо с генерацией статического контента (SSG).
MyDocument.getInitialProps = async (docContext: DocumentContext) => {
  // Порядок разрешения
  //
  // На сервере:
  // 1. app.getInitialProps
  // 2. page.getInitialProps
  // 3. document.getInitialProps
  // 4. app.render
  // 5. page.render
  // 6. document.render
  //
  // На сервере в случае ошибки:
  // 1. document.getInitialProps
  // 2. app.render
  // 3. page.render
  // 4. document.render
  //
  // На клиенте:
  // 1. app.getInitialProps
  // 2. page.getInitialProps
  // 3. app.render
  // 4. page.render

  const originalRenderPage = docContext.renderPage

  // Кэш Emotion можно распределять между всеми запросами SSR для повышения производительности.
  // Однако это может привести к глобальным побочным эффектам.
  const cache = createEmotionCache()
  const { extractCriticalToChunks } = createEmotionServer(cache)

  docContext.renderPage = () =>
    originalRenderPage({
      enhanceApp: (App: any) =>
        function EnhanceApp(props) {
          return <App emotionCache={cache} {...props} />
        }
    })

  const docProps = await Document.getInitialProps(docContext)
  // Важно. Это не позволяет Emotion рендерить невалидный HTML.
  // См. https://github.com/mui/material-ui/issues/26561#issuecomment-855286153
  const emotionStyles = extractCriticalToChunks(docProps.html)
  const emotionStyleTags = emotionStyles.styles.map((style) => (
    <style
      data-emotion={`${style.key} ${style.ids.join(' ')}`}
      key={style.key}
      // eslint-disable-next-line react/no-danger
      dangerouslySetInnerHTML={{ __html: style.css }}
    />
  ))

  return {
    ...docProps,
    emotionStyleTags
  }
}

Настраиваем рендеринг компонентов в файле _app.tsx (создание кэша на клиенте):


import '@/utils/wdyr'
// глобальные стили
import '@/global.scss'
import createEmotionCache from '@/utils/createEmotionCache'
import { CacheProvider, EmotionCache } from '@emotion/react'
// сброс CSS
import CssBaseline from '@mui/material/CssBaseline'
import { createTheme, ThemeProvider } from '@mui/material/styles'
import type { AppProps } from 'next/app'

// настраиваем тему MUI
const theme = createTheme({
  typography: {
    fontFamily: 'Montserrat, sans-serif'
  },
  components: {
    MuiListItem: {
      styleOverrides: {
        root: {
          width: 'unset'
        }
      }
    },
    MuiListItemButton: {
      styleOverrides: {
        root: {
          flexGrow: 'unset'
        }
      }
    }
  }
})

// создаем клиентский кэш
const clientSideEmotionCache = createEmotionCache()

export default function App({
  Component,
  pageProps,
  emotionCache = clientSideEmotionCache
}: AppProps & { emotionCache?: EmotionCache }) {
  return (
    <>
      {/* провайдер кэша */}
      <CacheProvider value={emotionCache}>
        {/* провайдер темы */}
        <ThemeProvider theme={theme}>
          {/* сброс стилей */}
          <CssBaseline />
          {/* ... */}
        </ThemeProvider>
      </CacheProvider>
    </>
  )
}

Формирование структуры компонентов


В нашем приложении будет использоваться несколько "глобальных" компонентов:



У нас будет общий макет (layout) для всех страниц приложения. Мы сформируем его прямо в _app.tsx.


Кроме того, мы будем анимировать переход между страницами с помощью @formkit/auto-animate (данную утилиту можно рассматривать как современную альтернативу React Transition Group).


Импортируем компоненты и стили:


// ...
import ErrorFallback from '@/components/ErrorFallback'
import Footer from '@/components/Footer'
import CustomHead from '@/components/Head'
import Header from '@/components/Header'
import { useAutoAnimate } from '@formkit/auto-animate/react'
import Box from '@mui/material/Box'
import Container from '@mui/material/Container'
import { ErrorBoundary } from 'react-error-boundary'
import { ToastContainer } from 'react-toastify'
import 'react-toastify/dist/ReactToastify.css'
import 'swiper/css'
import 'swiper/css/navigation'
import 'swiper/css/pagination'

Формируем структуру компонентов:


export default function App({
  Component,
  pageProps,
  emotionCache = clientSideEmotionCache
}: AppProps & { emotionCache?: EmotionCache }) {
  // ссылка на анимируемый элемент
  const [animationParent] = useAutoAnimate()

  return (
    <>
      <CacheProvider value={emotionCache}>
        <ThemeProvider theme={theme}>
          <CssBaseline />
          {/* компонент для добавления метаданных в `head` */}
          <CustomHead
            title='Default Title'
            description='This is default description'
          />
          {/* предохранитель */}
          <ErrorBoundary
            // резервный компонент
            FallbackComponent={ErrorFallback}
            onReset={() => window.location.reload()}
          >
            <Container
              maxWidth='xl'
              sx={{
                minHeight: '100vh',
                display: 'flex',
                flexDirection: 'column',
                overflow: 'hidden'
              }}
            >
              <Header />
              <Box component='main' flexGrow={1} ref={animationParent}>
                {/* компонент страницы */}
                <Component {...pageProps} />
              </Box>
              <Footer />
            </Container>
            {/* компонент уведомлений */}
            <ToastContainer autoClose={2000} hideProgressBar theme='colored' />
          </ErrorBoundary>
        </ThemeProvider>
      </CacheProvider>
    </>
  )
}

Компонент для добавления метаданных в раздел head документа (components/head.tsx):


import Head from 'next/head'

type Props = {
  title: string
  description: string
  children?: JSX.Element
}

export default function CustomHead({ title, description, children }: Props) {
  return (
    <Head>
      <title>{title}</title>
      <meta name='description' content={description} />
      {children}
    </Head>
  )
}

Резервный компонент (components/ErrorFallback.tsx):


import {
  Button,
  Card,
  CardActions,
  CardContent,
  CardHeader,
  Typography
} from '@mui/material'

type Props = {
  error: Error
  resetErrorBoundary: (...args: Array<unknown>) => void
}

export default function ErrorFallback({ error, resetErrorBoundary }: Props) {
  return (
    <Card
      role='alert'
      sx={{
        display: 'flex',
        flexDirection: 'column',
        alignItems: 'center',
        justifyContent: 'center',
        width: 320,
        mt: 2,
        mx: 'auto',
        pb: 2
      }}
    >
      <CardHeader title='Something went wrong' />
      <CardContent>
        <Typography variant='body1' color='error'>
          {/* сообщение об ошибке */}
          {error.message || 'Unknown error'}
        </Typography>
      </CardContent>
      <CardActions>
        {/* предлагаем пользователю перезагрузить страницу */}
        <Button
          variant='contained'
          color='success'
          onClick={resetErrorBoundary}
        >
          Reload
        </Button>
      </CardActions>
    </Card>
  )
}

Подвал сайта (components/Footer.tsx):


import { Box, Typography } from '@mui/material'

export default function Footer() {
  return (
    <Box component='footer' p={1} bgcolor='primary.main'>
      <Typography variant='body2' textAlign='center' color='white'>
        {new Date().getFullYear()}. &copy; All rights reserved
      </Typography>
    </Box>
  )
}

Шапка сайта (components/Header.tsx):


import { AppBar } from '@mui/material'
import DesktopMenu from './Menu/Desktop'
import MobileMenu from './Menu/Mobile'

export type PageLinks = { title: string; href: string }[]

// наше приложение состоит из 3 страниц:
// Главной, Блога и Контактов
const PAGE_LINKS = [
  { title: 'Home', href: '/' },
  { title: 'Posts', href: '/posts' },
  { title: 'About', href: '/about' }
]

export default function Header() {
  return (
    <AppBar position='relative'>
      {/* в зависимости от ширины экрана рендерится либо десктопное меню, любо мобильное */}
      <DesktopMenu links={PAGE_LINKS} />
      <MobileMenu links={PAGE_LINKS} />
    </AppBar>
  )
}

Десктопное меню (components/Menu/Desktop.tsx):


import { List, ListItem } from '@mui/material'
import { useTheme } from '@mui/material/styles'
import ActiveLink from '../ActiveLink'
import ProfileButton from '../Buttons/Profile'
import type { PageLinks } from '../Header'

type Props = {
  links: PageLinks
}

export default function DesktopMenu({ links }: Props) {
  const theme = useTheme()

  return (
    <List
      sx={{
        // управляем видимостью элемента на основе ширины экрана
        display: { xs: 'none', sm: 'flex' },
        justifyContent: 'flex-end',
        paddingInline: theme.spacing(1)
      }}
    >
      {links.map((link, i) => (
        <ListItem key={i}>
          <ActiveLink href={link.href} activeClassName='current'>
            {link.title}
          </ActiveLink>
        </ListItem>
      ))}
      <ProfileButton />
    </List>
  )
}

Данный компонент представляет собой список ссылок и кнопку профиля.


Мобильное меню (components/Menu/Mobile.tsx):


import MenuIcon from '@mui/icons-material/Menu'
import { Box, Drawer, List, ListItem, ListItemButton } from '@mui/material'
import { useTheme } from '@mui/material/styles'
import { useState } from 'react'
import ActiveLink from '../ActiveLink'
import ProfileButton from '../Buttons/Profile'
import type { PageLinks } from '../Header'

type Props = {
  links: PageLinks
}

export default function MobileMenu({ links }: Props) {
  const theme = useTheme()
  // ссылка на якорь для меню
  const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null)
  // индикатор открытости меню
  const open = Boolean(anchorEl)

  // метод для открытия меню
  const openMenu = (e: React.MouseEvent<HTMLDivElement>) => {
    setAnchorEl(e.currentTarget)
  }

  // метод для закрытия меню
  const closeMenu = () => {
    setAnchorEl(null)
  }

  return (
    <Box
      // управляем видимостью элемента на основе ширины экрана
      sx={{ display: { xs: 'flex', sm: 'none' } }}
      alignItems='center'
      justifyContent='space-between'
    >
      <ListItemButton
        id='menu-button'
        sx={{ borderRadius: '50%', px: theme.spacing(1) }}
        aria-controls={open ? 'mobile-menu' : undefined}
        aria-haspopup='true'
        aria-expanded={open ? 'true' : undefined}
        onClick={openMenu}
      >
        <MenuIcon />
      </ListItemButton>
      <Drawer anchor='left' open={open} onClose={closeMenu} id='mobile-menu'>
        <List sx={{ minWidth: '128px' }}>
          {links.map((link, i) => (
            <ListItem
              onClick={closeMenu}
              key={i}
              sx={{ justifyContent: 'center' }}
            >
              <ActiveLink href={link.href} activeClassName='current'>
                {link.title}
              </ActiveLink>
            </ListItem>
          ))}
        </List>
      </Drawer>
      <ProfileButton />
    </Box>
  )
}

Данный компонент представляет собой боковую панель со списком ссылок (+ кнопка для открытия меню) и кнопку профиля. О ProfileButton мы поговорим в разделе про аутентификацию и авторизацию.


С вашего позволения, в дальнейшем мы не будет рассматривать каждый используемый компонент.


Результат:


Десктоп





Мобайл (меню закрыто)





Мобайл (меню открыто)





Генерация статического контента


Генерация статического контента (или статической страницы) (static-site generation, SSG) — это процесс, в результате которого сервер генерирует готовую к использованию разметку (HTML) на этапе сборки приложения. Готовность к использованию означает, что, во-первых, клиент мгновенно получает страницу в ответ на запрос, во-вторых, такие страницы хорошо индексируются поисковыми ботами (SEO).


Статический контент бывает 2 видов: с данными и без. Статика без данных — это просто разметка. Статика с данными — это разметка, для генерации которой используются данные, доступные на этапе сборки (данные могут храниться как локально, так и удаленно). Еще раз: страница генерируется на основе данных, актуальных на момент сборки. По общему правилу, это означает невозможность обновления страницы свежими данными без создания новой сборки. Next.js позволяет обойти это ограничение с помощью генерации статического контента с инкрементальной (частичной) регенерацией.


В нашем приложении статическими являются главная страница и страница контактов. Для генерации обеих этих страниц используются данные. Данные для главной страницы хранятся локально. Предполагается, что они обновляются между сборками. Данные для страницы контактов хранятся удаленно (на JSONBin.io). Предполагается, что они обновляются каждые 12 часов. Для обновления страницы контактов каждые 12 часов запускается процесс инкрементальной регенерации.


Главная страница


Главная страница (pages/index.tsx) состоит из слайдера и 4 информационных блоков и генерируется с помощью данных, которые находятся в файле public/data/home.json. Для передачи данных компоненту страницы используется функция getStaticProps, а для чтения данных — модуль Node.js fs:


import Animate, { SLIDE_DIRECTION } from '@/components/AnimateIn'
import CustomHead from '@/components/Head'
import Slider from '@/components/Slider'
import type { Blocks } from '@/types'
import { useUser } from '@/utils/swr'
import { Box, Grid } from '@mui/material'
import Typography from '@mui/material/Typography'
import type { GetStaticPropsContext, InferGetStaticPropsType } from 'next'
import Image from 'next/image'
// модули Node.js
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'

// компонент статической страницы
export default function Home({
  data
}: InferGetStaticPropsType<typeof getStaticProps>) {
  // данные информационных блоков
  const { blocks } = data
  // об этом позже
  const { user } = useUser()

  return (
    <>
      <CustomHead title='Home Page' description='This is Home Page' />
      <Typography variant='h4' textAlign='center' py={2}>
        Welcome, {user ? user.username || user.email : 'stranger'}
      </Typography>
      {/* слайдер */}
      <Slider slides={blocks} />
      {/* информационные блоки */}
      <Box my={2}>
        {blocks.map((block, i) => (
          {/* самописная библиотека анимации */}
          <Animate.SlideIn
            key={block.id}
            direction={i % 2 ? SLIDE_DIRECTION.RIGHT : SLIDE_DIRECTION.LEFT}
          >
            <Grid container spacing={2} my={4}>
              {i % 2 ? (
                <>
                  <Grid item md={6}>
                    <Typography variant='h5'>{block.title}</Typography>
                    <Typography variant='body1' mt={2}>
                      {block.description}
                    </Typography>
                  </Grid>
                  <Grid item md={6}>
                    <Image
                      width={1024}
                      height={320}
                      src={block.imgSrc}
                      alt={block.imgAlt}
                      style={{
                        borderRadius: '6px'
                      }}
                    />
                  </Grid>
                </>
              ) : (
                <>
                  <Grid item md={6}>
                    <Image
                      width={1024}
                      height={320}
                      src={block.imgSrc}
                      alt={block.imgAlt}
                      style={{
                        borderRadius: '6px'
                      }}
                    />
                  </Grid>
                  <Grid item md={6}>
                    <Typography variant='h5'>{block.title}</Typography>
                    <Typography variant='body1' mt={2}>
                      {block.description}
                    </Typography>
                  </Grid>
                </>
              )}
            </Grid>
          </Animate.SlideIn>
        ))}
      </Box>
    </>
  )
}

// функция генерации статического контента с данными
export async function getStaticProps(ctx: GetStaticPropsContext) {
  let data = {
    blocks: [] as Blocks
  }

  // путь к данным
  const dataPath = join(process.cwd(), 'public/data/home.json')

  try {
    // читаем файл
    const dataJson = await readFile(dataPath, 'utf-8')
    if (dataJson) {
      // преобразуем данные из строки JSON в объект JS
      data = JSON.parse(dataJson)
    }
  } catch (e) {
    console.error(e)
  }

  // передаем данные компоненту страницы в виде пропа
  return {
    props: {
      data
    }
  }
}

Результат:





Страница контактов


Страница контактов (pages/about.tsx) состоит из блока с приветствием и 6 новостных блоков и генерируется на основе данных, хранящихся на JSONBin.io. Для получения данных используется fetch. У каждой новости имеется собственная страница (pages/news/[id].tsx). Для передачи данных компоненту страницы контактов используется функция getStaticProps. А для передачи данных странице новости — функции getStaticProps и getStaticPaths. getStaticPaths сообщает Next.js о том, сколько у нас новостей, т.е. сколько новостных страниц необходимо сгенерировать на этапе сборки приложения.


Начнем со страницы контактов (pages/about.tsx):


import Animate from '@/components/AnimateIn'
import CustomHead from '@/components/Head'
import NewsPreview from '@/components/NewsPreview'
import type { NewsArr } from '@/types'
import { Grid, Typography } from '@mui/material'
import type { GetStaticPropsContext, InferGetStaticPropsType } from 'next'

// компонент статической страницы
export default function About({
  data
}: InferGetStaticPropsType<typeof getStaticProps>) {
  // данные новостных блоков
  const { news } = data

  return (
    <>
      <CustomHead title='About Page' description='This is About Page' />
      <Typography variant='h4' textAlign='center' py={2}>
        About
      </Typography>
      {/* блок с приветствием */}
      <Typography variant='body1'>
        Lorem ipsum dolor, sit amet consectetur adipisicing elit. Doloribus,
        obcaecati necessitatibus! Doloremque numquam magni culpa atque omnis
        ipsa sequi, nostrum, provident repudiandae sint aperiam temporibus nulla
        minima quas rem ex autem dolores consequuntur! Officia laborum autem ex
        eius cumque non aspernatur blanditiis commodi quae magnam ipsa qui sunt
        dolor quos dolorum eveniet, nobis excepturi voluptatum quasi, dicta sit
        aut, corporis hic. Magni numquam, accusamus, quasi consectetur facere
        quod consequuntur aliquid illo commodi ducimus id tenetur ea molestiae
        suscipit itaque assumenda ex. Expedita rem architecto itaque, ad
        voluptate nesciunt nisi veniam modi cupiditate, amet id velit deserunt
        soluta? Ex, voluptate libero.
      </Typography>
      <Typography variant='h5' textAlign='center' py={2}>
        News
      </Typography>
      {/* новостные блоки */}
      {/* превью новости содержит ссылку на соответствующую страницу */}
      <Grid container spacing={2} pb={2}>
        {news.map((n) => (
          <Grid item md={6} lg={4} key={n.id}>
            <Animate.FadeIn>
              <NewsPreview news={n} />
            </Animate.FadeIn>
          </Grid>
        ))}
      </Grid>
    </>
  )
}

// функция генерации статического контента с данными
export async function getStaticProps(ctx: GetStaticPropsContext) {
  let data = {
    news: [] as NewsArr
  }

  try {
    const response = await fetch(
      `https://api.jsonbin.io/v3/b/${process.env.JSONBIN_BIN_ID}?meta=false`,
      {
        headers: {
          'X-Master-Key': process.env.JSONBIN_X_MASTER_KEY
        }
      }
    )
    if (!response.ok) {
      throw response
    }
    data = await response.json()
  } catch (e) {
    console.error(e)
  }

  return {
    props: {
      data
    },
    // данная настройка включает инкрементальную регенерацию
    // значением является время в секундах - 12 часов
    revalidate: 60 * 60 * 12
  }
}

Благодаря настройке revalidate страница генерируется на этапе сборки и обновляется каждые 12 часов. Это означает следующее:


  • Ответ на любой запрос к странице контактов до истечения 12 часов мгновенно возвращается (доставляется) из кэша;
  • по истечении 12 часов следующий запрос также получает в ответ кэшированную версию страницы;
  • после этого в фоновом режиме запускается процесс регенерации страницы (вызывается getStaticProps() и формируется новая разметка);
  • после успешной регенерации кэш инвалидируется и отображается новая страница. При провале регенерации старая страница остается неизменной.

Страница новости (pages/news/[id].tsx):


import CustomHead from '@/components/Head'
import type { News, NewsArr } from '@/types'
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew'
import {
  Avatar,
  Box,
  Button,
  Card,
  CardContent,
  CardHeader,
  CardMedia,
  Typography
} from '@mui/material'
import { blue, red } from '@mui/material/colors'
import type {
  GetStaticPathsContext,
  GetStaticPropsContext,
  InferGetStaticPropsType
} from 'next'
import Link from 'next/link'

// компонент статической страницы
export default function ArticlePage({
  news
}: InferGetStaticPropsType<typeof getStaticProps>) {
  return (
    <>
      <CustomHead title={news.title} description={news.text.slice(0, 10)} />
      <Box py={2}>
        <Card>
          <CardHeader
            avatar={
              <Avatar
                sx={{ bgcolor: news.id % 2 === 0 ? red[500] : blue[500] }}
                aria-label='author avatar'
              >
                {news.author.slice(0, 1)}
              </Avatar>
            }
            action={
              <Link href='/about'>
                <Button aria-label='return to about page'>
                  <ArrowBackIosNewIcon fontSize='small' />
                  <Typography variant='body2'>Back</Typography>
                </Button>
              </Link>
            }
            title={news.title}
            subheader={new Date(news.datePublished).toDateString()}
          />
          <CardMedia
            component='img'
            height='300'
            image={news.imgSrc}
            alt={news.imgAlt}
          />
          <CardContent>
            <Typography variant='body1'>{news.text}</Typography>
          </CardContent>
        </Card>
      </Box>
    </>
  )
}

// функция генерации путей статических страниц
export async function getStaticPaths(ctx: GetStaticPathsContext) {
  let data = {
    news: [] as NewsArr
  }

  try {
    // здесь нас интересуют данные всех новостей
    const response = await fetch(
      `https://api.jsonbin.io/v3/b/${process.env.JSONBIN_BIN_ID}?meta=false`,
      {
        headers: {
          'X-Master-Key': process.env.JSONBIN_X_MASTER_KEY
        }
      }
    )
    if (!response.ok) {
      throw response
    }
    data = await response.json()
  } catch (e) {
    console.error(e)
  }

  // пути страниц
  const paths = data.news.map((n) => ({
    params: { id: String(n.id) }
  }))

  // Во время сборки будут предварительно отрендерены только страницы с указанными путями
  // `{ fallback: 'blocking' }` означает, что Next.js попытается
  // отрендерить страницу по отсутствующему пути на сервере
  return {
    paths,
    fallback: 'blocking'
  }
}

export async function getStaticProps({
  params
}: GetStaticPropsContext<{ id: string }>) {
  let news = {} as News

  try {
    // здесь нас интересуют данные только одной новости
    const response = await fetch(
      `https://api.jsonbin.io/v3/b/${process.env.JSONBIN_BIN_ID}?meta=false`,
      {
        headers: {
          'X-Master-Key': process.env.JSONBIN_X_MASTER_KEY,
          'X-JSON-Path': `news[${Number(params?.id) - 1}]`
        }
      }
    )
    if (!response.ok) {
      throw response
    }
    const data = await response.json()
    news = data[0]
    // важно!
    // если данные новости с указанным id отсутствуют,
    // рендерим страницу 404
    if (!news) {
      return {
        notFound: true
      }
    }
  } catch (e) {
    console.error(e)
  }

  return {
    props: {
      news
    },
    // инкрементальная регенерация
    revalidate: 60 * 60 * 12
  }
}

Результат:


Страница контактов





Страница новости





Аутентификация, авторизация и загрузка файлов


При запуске приложение запрашивает у сервера данные пользователя. Это единственные данные, за изменением которых "наблюдает" приложение. Запрос данных пользователя реализован с помощью SWR. SWR позволяет кэшировать данные и мутировать их при необходимости, например, после регистрации пользователя. Благодаря SWR мы можем обойтись без инструмента для управления состоянием приложения (state manager).


Определяем абстракцию над SWR для получения данных пользователя в файле utils/swr.ts:


import type { User } from '@prisma/client'
import useSWRImmutable from 'swr/immutable'

async function fetcher<T>(
  input: RequestInfo | URL,
  init?: RequestInit | undefined
): Promise<T> {
  return fetch(input, init).then((res) => res.json())
}

// запрос на получение данных пользователя выполняется один раз
export function useUser() {
  // утилита возвращает данные пользователя и токен доступа, ошибку и
  // функцию инвалидации кэша (метод для мутирования данных, хранящихся в кэше)
  const { data, error, mutate } = useSWRImmutable<any>(
    '/api/auth/user',
    (url) => fetcher(url, { credentials: 'include' }),
    {
      onErrorRetry(err, key, config, revalidate, revalidateOpts) {
        return false
      }
    }
  )

  // `error` - обычная ошибка (необработанное исключение)
  // `data.message` - сообщение о кастомной ошибке, например:
  // res.status(404).json({ message: 'User not found' })
  if (error || data?.message) {
    console.log(error || data?.message)

    return {
      user: undefined,
      accessToken: undefined,
      mutate
    }
  }

  return {
    user: data?.user as User,
    accessToken: data?.accessToken as string,
    mutate
  }
}

Аутентификация и авторизация


В шапке сайте имеется кнопка профиля (ProfileButton):


import { useUser } from '@/utils/swr'
import { Avatar, ListItemButton } from '@mui/material'
import { useTheme } from '@mui/material/styles'
import AuthTabs from '../AuthTabs'
import Modal from '../Modal'
import UserPanel from '../UserPanel'

export default function ProfileButton() {
  // запрашиваем данные пользователя
  const { user } = useUser()
  const theme = useTheme()

  // содержимое модального окна зависит от наличия данных пользователя
  const modalContent = user ? <UserPanel /> : <AuthTabs />

  return (
    <Modal
      // компонент, взаимодействие с которым приводит к открытию модального окна
      triggerComponent={
        <ListItemButton sx={{ borderRadius: '50%', px: theme.spacing(1) }}>
          <Avatar
            // источником аватара является либо файл, загруженный пользователей, либо дефолтное изображение
            src={user && user.avatarUrl ? user.avatarUrl : '/img/user.png'}
          />
        </ListItemButton>
      }
      modalContent={modalContent}
    />
  )
}

Функционал регистрации, авторизации, загрузки аватаров и выхода из системы инкапсулирован в модальном окне (components/Modal.tsx):


import CloseIcon from '@mui/icons-material/Close'
import { Box, IconButton, Modal as MuiModal } from '@mui/material'
import { cloneElement, useMemo, useState } from 'react'

type Props = {
  triggerComponent: JSX.Element
  modalContent: JSX.Element
  size?: 'S' | 'M'
}

export default function Modal({
  triggerComponent,
  modalContent,
  size = 'S'
}: Props) {
  // состояние открытости модалки
  const [open, setOpen] = useState(false)

  // метод для открытия модалки
  const handleOpen = () => setOpen(true)
  // метод для закрытия модалки
  const handleClose = () => setOpen(false)

  // содержимому модалки в качестве пропа передается метод для закрытия модалки
  const content = cloneElement(modalContent, { closeModal: handleClose })

  const modalStyles = useMemo(
    () => ({
      bgcolor: 'background.paper',
      borderRadius: 1,
      boxShadow: 24,
      left: '50%',
      maxWidth: size === 'S' ? 425 : 576,
      p: 2,
      position: 'absolute' as 'absolute',
      top: '50%',
      transform: 'translate(-50%, -50%)',
      width: '100%',
      outline: 'none'
    }),
    [size]
  )

  return (
    <>
      <Box onClick={handleOpen}>{triggerComponent}</Box>
      <MuiModal open={open} onClose={handleClose}>
        <Box sx={modalStyles}>
          <IconButton
            sx={{
              position: 'absolute',
              top: '1rem',
              right: '1rem'
            }}
            onClick={handleClose}
          >
            <CloseIcon />
          </IconButton>
          {content}
        </Box>
      </MuiModal>
    </>
  )
}

При отсутствии данных пользователя содержимым модалки являются вкладки аутентификации (components/AuthTabs.tsx):


import storageLocal from '@/utils/storageLocal'
import { Box, Tab, Tabs } from '@mui/material'
import { useEffect, useState } from 'react'
import LoginForm from './Forms/Login'
import RegisterForm from './Forms/Register'

type TabPanelProps = {
  children?: React.ReactNode
  index: number
  value: number
}

function TabPanel({ children, value, index, ...otherProps }: TabPanelProps) {
  return (
    <Box
      aria-labelledby={`auth-tab-${index}`}
      display={value === index ? 'block' : 'none'}
      hidden={value !== index}
      id={`auth-tabpanel-${index}`}
      role='tabpanel'
      {...otherProps}
    >
      {value === index && children}
    </Box>
  )
}

function a11yProps(index: number) {
  return {
    id: `auth-tab-${index}`,
    'aria-controls': `auth-tabpanel-${index}`
  }
}

type Props = { closeModal?: () => void }

export default function AuthTabs({ closeModal }: Props) {
  // состояние индекса открытой вкладки
  const [tabIndex, setTabIndex] = useState(0)
  // состояние индикатора загрузки
  const [loading, setLoading] = useState(true)

  // метод для переключения вкладок
  const handleChange = (event: React.SyntheticEvent, value: number) => {
    setTabIndex(value)
  }

  // после регистрации мы не только записываем данные пользователя в БД,
  // но также фиксируем факт регистрации в локальном хранилище
  // если пользователь зарегистрирован, мы показываем ему вкладку авторизации,
  // если нет - вкладку регистрации
  useEffect(() => {
    if (storageLocal.get('user_has_been_registered')) {
      setTabIndex(1)
    }
    setLoading(false)
  }, [])

  if (loading) return null

  return (
    <>
      <Box display='flex'>
        <Tabs
          value={tabIndex}
          onChange={handleChange}
          aria-label='auth tabs'
        >
          <Tab label='Register' {...a11yProps(0)} />
          <Tab label='Login' {...a11yProps(1)} />
        </Tabs>
      </Box>
      <TabPanel value={tabIndex} index={0}>
        <RegisterForm closeModal={closeModal} />
      </TabPanel>
      <TabPanel value={tabIndex} index={1}>
        <LoginForm closeModal={closeModal} />
      </TabPanel>
    </>
  )
}

Форма регистрации (components/Forms/Register.tsx):


import type { UserResponseData } from '@/types'
import storageLocal from '@/utils/storageLocal'
import { useUser } from '@/utils/swr'
import MailOutlineIcon from '@mui/icons-material/MailOutline'
import PersonOutlineIcon from '@mui/icons-material/PersonOutline'
import VpnKeyIcon from '@mui/icons-material/VpnKey'
import {
  Button,
  FormControl,
  FormHelperText,
  Input,
  InputLabel,
  Typography
} from '@mui/material'
import { useTheme } from '@mui/material/styles'
import type { User } from '@prisma/client'
import { useRouter } from 'next/router'
import { useState } from 'react'
import FormFieldsWrapper from './Wrapper'

type Props = {
  closeModal?: () => void
}

export default function RegisterForm({ closeModal }: Props) {
  const theme = useTheme()
  const router = useRouter()
  // метод для мутирования данных пользователя
  const { mutate } = useUser()

  // состояние ошибок
  const [errors, setErrors] = useState<{
    email?: boolean
    password?: boolean
    passwordConfirm?: boolean
  }>({})

  // обработчик отправки формы
  const handleSubmit: React.FormEventHandler<HTMLFormElement> = async (e) => {
    e.preventDefault()
    // данные пользователя в виде объета
    const formData = Object.fromEntries(
      new FormData(e.target as HTMLFormElement)
    ) as unknown as Pick<User, 'username' | 'email' | 'password'> & {
      passwordConfirm?: string
    }

    // валидация формы
    const _errors: typeof errors = {}
    if (formData.password.length < 6) {
      _errors.password = true
    }
    if (formData.password !== formData.passwordConfirm) {
      _errors.passwordConfirm = true
    }
    // если имеются ошибки
    if (Object.keys(_errors).length) {
      return setErrors({ ..._errors })
    }

    // удаляем лишние данные
    delete formData.passwordConfirm

    try {
      // отправляем данные на сервер
      const res = await fetch('/api/auth/register', {
        method: 'POST',
        body: JSON.stringify(formData)
      })

      // если ответ имеет статус-код 409,
      // значит, пользователь уже зарегистрирован
      if (res.status === 409) {
        return setErrors({ email: true })
      } else if (!res.ok) {
        throw res
      }

      // извлекаем данные пользователя и токен доступа из ответа
      const data = await res.json() as UserResponseData
      // инвалидируем кэш
      mutate(data)
      // фиксируем факт регистрации пользователя в локальном хранилище
      storageLocal.set('user_has_been_registered', true)

      // закрываем модалку
      if (closeModal) {
        closeModal()
      }

      // перенаправляем пользователя на главную страницу
      if (router.pathname !== '/') {
        router.push('/')
      }
    } catch (e) {
      console.error(e)
    }
  }

  // обработчик ввода
  const handleInput: React.FormEventHandler<HTMLFormElement> = () => {
    // сбрасываем ошибки при наличии
    if (Object.keys(errors).length) {
      setErrors({})
    }
  }

  return (
    <FormFieldsWrapper handleSubmit={handleSubmit} handleInput={handleInput}>
      <Typography variant='h4'>Register</Typography>
      <FormControl required>
        <InputLabel htmlFor='username'>Username</InputLabel>
        <Input
          sx={{ gap: theme.spacing(1) }}
          id='username'
          name='username'
          startAdornment={<PersonOutlineIcon />}
        />
      </FormControl>
      <FormControl required error={errors.email}>
        <InputLabel htmlFor='email'>Email</InputLabel>
        <Input
          sx={{ gap: theme.spacing(1) }}
          id='email'
          type='email'
          name='email'
          startAdornment={<MailOutlineIcon />}
        />
        {errors.email && <FormHelperText>Email already in use</FormHelperText>}
      </FormControl>
      <FormControl required error={errors.password}>
        <InputLabel htmlFor='password'>Password</InputLabel>
        <Input
          sx={{ gap: theme.spacing(1) }}
          id='password'
          type='password'
          name='password'
          startAdornment={<VpnKeyIcon />}
        />
        <FormHelperText>
          Password must be at least 6 characters long
        </FormHelperText>
      </FormControl>
      <FormControl required error={errors.passwordConfirm}>
        <InputLabel htmlFor='password-confirm'>Confirm password</InputLabel>
        <Input
          sx={{ gap: theme.spacing(1) }}
          id='password-confirm'
          type='password'
          name='passwordConfirm'
          startAdornment={<VpnKeyIcon />}
        />
        {errors?.passwordConfirm && (
          <FormHelperText>Passwords must be the same</FormHelperText>
        )}
      </FormControl>
      <Button type='submit' variant='contained' color='success'>
        Register
      </Button>
    </FormFieldsWrapper>
  )
}

Форма авторизации почти идентична форме регистрации.


Результат:


Форма регистрации





Форма авторизации





Пользовательская панель


При наличии данных пользователя содержимым модалки, которая рендерится при нажатии кнопки профиля, является пользовательская панель (components/UserPanel.tsx), содержащая форму для загрузки аватара и кнопку для выхода пользователя из системы:


import { Divider } from '@mui/material'
import LogoutButton from './Buttons/Logout'
import UploadForm from './Forms/Upload'

type Props = {
  closeModal?: () => void
}

export default function UserPanel({ closeModal }: Props) {
  return (
    <>
      <UploadForm closeModal={closeModal} />
      <Divider />
      <LogoutButton closeModal={closeModal} />
    </>
  )
}

Форма загрузки аватара (components/Forms/Upload.tsx):


import { useUser } from '@/utils/swr'
import { Avatar, Box, Button, Typography } from '@mui/material'
import { useRef, useState } from 'react'
import FormFieldsWrapper from './Wrapper'

type Props = {
  closeModal?: () => void
}

export default function UploadForm({ closeModal }: Props) {
  // ссылка на элемент для превью загруженного файла
  const previewRef = useRef<HTMLImageElement | null>(null)
  // состояние файла
  const [file, setFile] = useState<File>()
  const { user, accessToken, mutate } = useUser()

  if (!user) return null

  // обработчик отправки формы
  const handleSubmit: React.FormEventHandler<HTMLFormElement> = async (e) => {
    if (!file) return

    e.preventDefault()

    const formData = new FormData()

    // создаем экземпляр `File`, названием которого является id пользователя + расширение файла
    const _file = new File([file], `${user.id}.${file.type.split('/')[1]}`, {
      type: file.type
    })
    formData.append('avatar', _file)

    try {
      // отправляем файл на сервер
      const res = await fetch('/api/upload', {
        method: 'POST',
        body: formData,
        headers: {
          // роут для загрузки аватара является защищенным
          Authorization: `Bearer ${accessToken}`
        }
      })

      if (!res.ok) {
        throw res
      }

      // извлекаем обновленные данные пользователя
      const user = await res.json()
      // инвалидируем кэш
      mutate({ user })

      // закрываем модалку
      if (closeModal) {
        closeModal()
      }
    } catch (e) {
      console.error(e)
    }
  }

  // обработчик изменения состояния инпута для загрузки файла
  const handleChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
    if (e.target.files && previewRef.current) {
      // извлекаем файл
      const _file = e.target.files[0]
      // обновляем состояние
      setFile(_file)
      // получаем ссылку на элемент `img`
      const img = previewRef.current.children[0] as HTMLImageElement
      // формируем и устанавливаем источник изображения
      img.src = URL.createObjectURL(_file)
      img.onload = () => {
        // очищаем память
        URL.revokeObjectURL(img.src)
      }
    }
  }

  return (
    <FormFieldsWrapper handleSubmit={handleSubmit}>
      <Typography variant='h4'>Avatar</Typography>
      <Box display='flex' alignItems='center' gap={2}>
        <input
          accept='image/*'
          style={{ display: 'none' }}
          id='avatar'
          name='avatar'
          type='file'
          onChange={handleChange}
        />
        <label htmlFor='avatar'>
          <Button component='span'>Choose file</Button>
        </label>
        <Avatar alt='preview' ref={previewRef} src='/img/user.png' />
        <Button
          type='submit'
          variant='contained'
          color='success'
          disabled={!file}
        >
          Upload
        </Button>
      </Box>
    </FormFieldsWrapper>
  )
}

Кнопка для выхода из системы (components/Buttons/Logout.tsx):


import { useUser } from '@/utils/swr'
import { Box, Button } from '@mui/material'

type Props = {
  closeModal?: () => void
}

export default function LogoutButton({ closeModal }: Props) {
  const { accessToken, mutate } = useUser()

  // обработчик нажатия кнопки
  const onClick = async () => {
    try {
      // сообщаем серверу о выходе пользователя из системы
      const response = await fetch('/api/auth/logout', {
        headers: {
          // роут является защищенным
          Authorization: `Bearer ${accessToken}`
        }
      })

      if (!response.ok) {
        throw response
      }

      // инвалидируем кэш
      mutate({ user: undefined, accessToken: undefined })

      // закрываем модалку
      if (closeModal) {
        closeModal()
      }
    } catch (e) {
      console.error(e)
    }
  }

  return (
    <Box display='flex' justifyContent='flex-end' pt={2} pr={2}>
      <Button color='error' variant='contained' onClick={onClick}>
        Logout
      </Button>
    </Box>
  )
}

Результат:


Без превью





С превью





После загрузки аватар пользователя отображается в шапке сайте на месте кнопки профиля.


Создание, обновление, удаление и лайк постов


Для генерации страницы блога и страниц постов используется рендеринг на стороне сервера с помощью функции getServerSideProps. Данная функция позволяет выполнять серверный код и вызывается при каждом запросе страницы.


На странице блога (pages/posts/index.tsx) рендерится кнопка для создания нового поста и список постов (при наличии):


import Animate from '@/components/AnimateIn'
import CreatePostButton from '@/components/Buttons/CreatePost'
import CustomHead from '@/components/Head'
import PostPreview from '@/components/PostPreview'
import prisma from '@/utils/prisma'
import { Divider, Grid, Typography } from '@mui/material'
import type {
  GetServerSidePropsContext,
  InferGetServerSidePropsType
} from 'next'

// компонент динамической страницы
export default function Posts({
  posts
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
  return (
    <>
      <CustomHead title='Blog Page' description='This is Blog Page' />
      {/* кнопка для создания поста */}
      <CreatePostButton />
      <Divider />
      <Typography variant='h4' textAlign='center' py={2}>
        Posts
      </Typography>
      {/* список постов или сообщение об их отсутствии */}
      {posts.length ? (
        <Grid container spacing={2} pb={2}>
          {posts.map((post) => (
            <Grid item md={6} lg={4} key={post.id}>
              <Animate.FadeIn>
                <PostPreview post={post} />
              </Animate.FadeIn>
            </Grid>
          ))}
        </Grid>
      ) : (
        <Typography mt={2}>There are no posts yet</Typography>
      )}
    </>
  )
}

// функция серверного рендеринга
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
  try {
    // получаем все посты из БД
    const posts = await prisma.post.findMany({
      select: {
        id: true,
        title: true,
        content: true,
        author: true,
        authorId: true,
        likes: true,
        createdAt: true
      }
    })
    return {
      props: {
        posts: posts.map((post) => ({
          ...post,
          // предотвращаем ошибку, связанную с несериализуеомстью объекта `Date`
          createdAt: new Date(post.createdAt).toLocaleDateString()
        }))
      }
    }
  } catch (e) {
    console.log(e)
    return {
      props: {
        posts: []
      }
    }
  }
}

Кнопка создания поста (components/Button/CreatePost.tsx):


import { useUser } from '@/utils/swr'
import { Button } from '@mui/material'
import { toast } from 'react-toastify'
import CreatePostForm from '../Forms/CreatePost'
import Modal from '../Modal'

// при наличии данных пользователя рендерится модалка с формой для создания поста
// при отсутствии данных пользователя рендерится уведомление о необходимости авторизации
export default function CreatePostButton() {
  const { user } = useUser()

  const onClick = () => {
    toast('Authorization required', {
      type: 'warning'
    })
  }

  return user ? (
    <Modal
      triggerComponent={
        <Button variant='contained' sx={{ my: 2 }}>
          Create new post
        </Button>
      }
      modalContent={<CreatePostForm />}
      size='M'
    />
  ) : (
    <Button variant='contained' sx={{ my: 2 }} onClick={onClick}>
      Create new post
    </Button>
  )
}

Форма создания поста (components/Forms/CreatePost.tsx):


import { useUser } from '@/utils/swr'
import { CssVarsProvider } from '@mui/joy/styles'
import Textarea from '@mui/joy/Textarea'
import {
  Box,
  Button,
  FormControl,
  FormHelperText,
  Input,
  InputLabel,
  Typography
} from '@mui/material'
import { red } from '@mui/material/colors'
import { useTheme } from '@mui/material/styles'
import type { Post } from '@prisma/client'
import { useRouter } from 'next/router'
import { useState } from 'react'
import FormFieldsWrapper from './Wrapper'

type Props = {
  closeModal?: () => void
}

export default function CreatePostForm({ closeModal }: Props) {
  const theme = useTheme()
  const { user, accessToken } = useUser()
  const router = useRouter()

  // состояние ошибок
  const [errors, setErrors] = useState<{
    content?: number
  }>({})

  if (!user) return null

  // обработка отправки формы
  const handleSubmit: React.FormEventHandler<HTMLFormElement> = async (e) => {
    if (!user) return
    e.preventDefault()
    // данные поста в виде объекта
    const formData = Object.fromEntries(
      new FormData(e.target as HTMLFormElement)
    ) as unknown as Pick<Post, 'title' | 'content'>

    // валидация формы
    if (formData.content.length < 50) {
      return setErrors({ content: formData.content.length })
    }

    try {
      // отправляем данные поста на сервер
      const response = await fetch('/api/post', {
        method: 'POST',
        body: JSON.stringify(formData),
        headers: {
          // роут является защищенным
          Authorization: `Bearer ${accessToken}`
        }
      })

      if (!response.ok) {
        throw response
      }

      // извлекаем данные поста из ответа
      const post = await response.json()

      // выполняем перенаправление на страницу поста
      router.push(`/posts/${post.id}`)

      // закрываем модалку
      if (closeModal) {
        closeModal()
      }
    } catch (e) {
      console.error(e)
    }
  }

  // обработчик ввода
  const onInput = () => {
    if (Object.keys(errors).length) {
      setErrors({ content: undefined })
    }
  }

  return (
    <FormFieldsWrapper handleSubmit={handleSubmit}>
      <Typography variant='h4'>Create post</Typography>
      <FormControl required>
        <InputLabel htmlFor='title'>Title</InputLabel>
        <Input
          sx={{ gap: theme.spacing(1) }}
          id='title'
          type='text'
          name='title'
          inputProps={{
            minLength: 3
          }}
        />
      </FormControl>
      <Box>
        <InputLabel>
          Content * <Typography variant='body2'>(50 symbols min)</Typography>
          <CssVarsProvider>
            <Textarea
              name='content'
              required
              minRows={5}
              sx={{ mt: 1 }}
              onInput={onInput}
              defaultValue='Lorem ipsum dolor sit amet consectetur adipisicing elit. Soluta sed dicta eos ratione dolores doloribus magni repellendus aliquid sit dolor harum nemo porro voluptate incidunt quidem, molestias quia cum sequi minima debitis quae magnam est eius quas! Similique, enim non ad facilis dolores nulla corrupti assumenda, harum, ipsa consequuntur pariatur!'
            />
          </CssVarsProvider>
        </InputLabel>
        {errors.content && (
          <FormHelperText sx={{ color: red[500] }}>
            {50 - errors.content} symbols left
          </FormHelperText>
        )}
      </Box>
      <Button type='submit' variant='contained' color='success'>
        Create
      </Button>
    </FormFieldsWrapper>
  )
}

Страница поста (pages/posts/[id].tsx):


import EditPostButton from '@/components/Buttons/EditPost'
import LikePostButton from '@/components/Buttons/LikePost'
import RemovePostButton from '@/components/Buttons/RemovePost'
import CustomHead from '@/components/Head'
import prisma from '@/utils/prisma'
import { useUser } from '@/utils/swr'
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew'
import {
  Avatar,
  Box,
  Button,
  Card,
  CardActions,
  CardContent,
  CardHeader,
  CardMedia,
  Typography
} from '@mui/material'
import type {
  GetServerSidePropsContext,
  InferGetServerSidePropsType
} from 'next'
import Link from 'next/link'

// компонент динамической страницы
export default function PostPage({
  post
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
  const { user } = useUser()
  // определяем принадлежность поста пользователю
  const isPostBelongsToUser = user && user.id === post.authorId

  return (
    <>
      <CustomHead title={post.title} description={post.content.slice(0, 10)} />
      <Box py={2}>
        <Card>
          <CardHeader
            avatar={<Avatar src={post.author.avatarUrl || '/img/user.png'} />}
            action={
              <Link href='/posts'>
                <Button aria-label='return to about page'>
                  <ArrowBackIosNewIcon fontSize='small' />
                  <Typography variant='body2'>Back</Typography>
                </Button>
              </Link>
            }
            title={post.title}
            subheader={post.createdAt}
          />
          <CardMedia
            component='img'
            height='200'
            // у нет роута для загрузки изображений поста
            image='https://images.unsplash.com/photo-1486312338219-ce68d2c6f44d?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1172&q=80'
            alt=''
          />
          <CardContent>
            <Typography variant='body1'>{post.content}</Typography>
          </CardContent>
          {/* лайкать посты могут только авторизованные пользователи */}
          <CardActions>
            <Box display='flex' justifyContent='flex-end' gap={2} width='100%'>
              <LikePostButton post={post} />
              {/* редактировать и удалять посты могут только создавшие их пользователи */}
              {isPostBelongsToUser && (
                <>
                  <EditPostButton post={post} icon={false} />
                  <RemovePostButton
                    postId={post.id}
                    authorId={post.authorId}
                    icon={false}
                  />
                </>
              )}
            </Box>
          </CardActions>
        </Card>
      </Box>
    </>
  )
}

// функция серверного рендеринга
export async function getServerSideProps({
  params
}: GetServerSidePropsContext<{ id: string }>) {
  try {
    // получаем данные поста по id
    const post = await prisma.post.findUnique({
      where: {
        id: params?.id
      },
      select: {
        id: true,
        title: true,
        content: true,
        author: true,
        authorId: true,
        likes: true,
        createdAt: true
      }
    })
    // если данные поста отсутствуют,
    // возвращаем страницу 404
    if (!post) {
      return {
        notFound: true
      }
    }
    return {
      props: {
        post: {
          ...post,
          // предотвращаем ошибку, связанную с несериализуемостью объекта `Date`
          createdAt: new Date(post.createdAt).toLocaleDateString()
        }
      }
    }
  } catch (e) {
    console.error(e)
  }
}

Кнопка лайка поста (components/Buttons/LikePost.tsx):


import { useUser } from '@/utils/swr'
import FavoriteIcon from '@mui/icons-material/Favorite'
import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder'
import { Badge, IconButton } from '@mui/material'
import type { Like, Post } from '@prisma/client'
import { useRouter } from 'next/router'

type Props = {
  post: Omit<Post, 'createdAt' | 'updatedAt'> & {
    likes: Like[]
    createdAt: string
  }
}

export default function LikePostButton({ post }: Props) {
  const router = useRouter()
  const { user, accessToken } = useUser()
  if (!user) return null
  // определяем, лайкал ли пользователь этот пост
  const like = post.likes.find((l) => l.userId === user.id)
  const isLiked = Boolean(like)

  // если пользователь лайкал пост, удаляем лайк
  // если нет, создаем лайк
  // оба роута являются защищенными
  const likePost = async () => {
    let res: Response
    try {
      if (isLiked) {
        res = await fetch(`/api/like?likeId=${like?.id}&postId=${post.id}`, {
          method: 'DELETE',
          headers: {
            Authorization: `Bearer ${accessToken}`
          }
        })
      } else {
        res = await fetch('/api/like', {
          method: 'POST',
          body: JSON.stringify({ postId: post.id }),
          headers: {
            Authorization: `Bearer ${accessToken}`
          }
        })
      }
      if (!res.ok) throw res
      // перезагружаем страницу для повторного вызова `getServerSideProps`
      router.push(router.asPath)
    } catch (e) {
      console.log(e)
    }
  }

  return (
    <Badge
      badgeContent={post.likes.length}
      color='error'
      anchorOrigin={{
        vertical: 'top',
        horizontal: 'left'
      }}
    >
      <IconButton onClick={likePost}>
        {isLiked ? <FavoriteIcon color='error' /> : <FavoriteBorderIcon />}
      </IconButton>
    </Badge>
  )
}

Кнопка удаления поста (components/Buttons/RemovePost.tsx):


import { useUser } from '@/utils/swr'
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'
import { Button, IconButton } from '@mui/material'
import { useRouter } from 'next/router'

type Props = {
  postId: string
  authorId: string
  icon?: boolean
}

export default function RemovePostButton({
  postId,
  authorId,
  icon = true
}: Props) {
  const router = useRouter()
  const { user, accessToken } = useUser()

  // проверяем наличие пользователя и его полномочия на удаление поста
  if (!user || user.id !== authorId) return null

  const removePost = async () => {
    try {
      // сообщаем серверу о необходимости удаления поста
      await fetch(`/api/post?id=${postId}`, {
        method: 'DELETE',
        headers: {
          // роут является защищенным
          Authorization: `Bearer ${accessToken}`
        }
      })
      // выполняем перенаправление на страницу блога
      router.push('/posts')
    } catch (e: unknown) {
      console.error(e)
    }
  }

  return icon ? (
    <IconButton onClick={removePost} color='error'>
      <DeleteOutlineIcon />
    </IconButton>
  ) : (
    <Button variant='contained' color='error' onClick={removePost}>
      Remove
    </Button>
  )
}

Кнопка редактирования поста (components/Buttons/EditPost.tsx):


import { useUser } from '@/utils/swr'
import DriveFileRenameOutlineIcon from '@mui/icons-material/DriveFileRenameOutline'
import { Button, IconButton } from '@mui/material'
import type { Post } from '@prisma/client'
import EditPostForm from '../Forms/EditPost'
import Modal from '../Modal'

type Props = {
  post: Omit<Post, 'createdAt' | 'updatedAt'> & {
    createdAt: string
  }
  icon?: boolean
}

export default function EditPostButton({ post, icon = true }: Props) {
  const { user } = useUser()

  // проверяем наличие пользователя и его полномочия на редактирование поста
  if (!user || user.id !== post.authorId) return null

  return (
    <Modal
      triggerComponent={
        icon ? (
          <IconButton color='info'>
            <DriveFileRenameOutlineIcon />
          </IconButton>
        ) : (
          <Button variant='contained' color='info'>
            Edit
          </Button>
        )
      }
      modalContent={<EditPostForm post={post} />}
      size='M'
    />
  )
}

При нажатии этой кнопки рендерится модалка с формой для редактирования поста (components/Forms/EditPost.tsx), которая почти идентична форме создания поста.


Кнопки лайка, редактирования и удаления поста дублируются на странице блога в карточках превью постов в виде иконок.


Результат:


Форма создания поста





Страница поста





Форма редактирования поста





Страница блога





На этом разработку нашего приложения можно считать завершенной. Надеюсь, вы узнали что-то новое и не зря потратили время.


Приветствуются любые замечания и предложения.


Благодарю за внимание и happy coding!




Комментарии (2)


  1. petrov_engineer
    00.00.0000 00:00
    -1

    Статья, мягко говоря, с душком, уже давно Babel в проектах nextjs не используется, в том числе настройка для emotion идет из коробки


  1. victor-homyakov
    00.00.0000 00:00
    +1

    <link rel='preconnect' href='https://fonts.googleapis.com' />
    <link rel='preconnect' href='https://fonts.gstatic.com' />
    <link
      href='https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500&display=swap'
      rel='stylesheet'
    />
    

    Преконнект до fonts.googleapis.com лишний - через одну строку идёт запрос стилей с того же origin, то есть никакого выигрыша по скорости от заранее установленного соединения.