Разработка клиент-серверного приложения с помощью Next.js и TypeScript. Часть 2. Разработка клиента
Привет, друзья!
В этой серии из 2 статей-туториалов мы с вами продолжаем разрабатывать клиент-серверное (фуллстек — fullstack) приложение с помощью Next.js и TypeScript.
- Наше приложение будет представлять собой блог — относительно полноценную платформу для публикации, редактирования и удаления постов.
- Мы реализовали собственный сервис аутентификации на основе JSON Web Tokens и HTTP-куки.
- Данные пользователей и постов будут храниться в реляционной базе данных 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"` в начале .
// Это позволяет загружать стили MUI в первоочередном порядке.
// Это также позволяет разработчикам легко перезаписывать стили MUI, например, с помощью модулей CSS.
export default function createEmotionCache() {
let insertionPoint
if (typeof document !== 'undefined') {
const emotionInsertionPoint = document.querySelector(
'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 (
{/* дефолтным шрифтом MUI является Roboto, мы будем использовать Montserrat */}
{/* ! */}
{props.emotionStyleTags}
)
}
// `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
}
})
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) => (
))
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 (
<>
{/* провайдер кэша */}
{/* провайдер темы */}
{/* сброс стилей */}
{/* ... */}
>
)
}
Формирование структуры компонентов
В нашем приложении будет использоваться несколько «глобальных» компонентов:
У нас будет общий макет (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 (
<>
{/* компонент для добавления метаданных в `head` */}
{/* предохранитель */}
window.location.reload()}
>
{/* компонент страницы */}
{/* компонент уведомлений */}
>
)
}
Компонент для добавления метаданных в раздел 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 (
{title}
{children}
)
}
Резервный компонент (components/ErrorFallback.tsx
):
import {
Button,
Card,
CardActions,
CardContent,
CardHeader,
Typography
} from '@mui/material'
type Props = {
error: Error
resetErrorBoundary: (...args: Array) => void
}
export default function ErrorFallback({ error, resetErrorBoundary }: Props) {
return (
{/* сообщение об ошибке */}
{error.message || 'Unknown error'}
{/* предлагаем пользователю перезагрузить страницу */}
)
}
Подвал сайта (components/Footer.tsx
):
import { Box, Typography } from '@mui/material'
export default function Footer() {
return (
{new Date().getFullYear()}. © All rights reserved
)
}
Шапка сайта (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 (
{/* в зависимости от ширины экрана рендерится либо десктопное меню, любо мобильное */}
)
}
Десктопное меню (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 (
{links.map((link, i) => (
{link.title}
))}
)
}
Данный компонент представляет собой список ссылок и кнопку профиля.
Мобильное меню (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(null)
// индикатор открытости меню
const open = Boolean(anchorEl)
// метод для открытия меню
const openMenu = (e: React.MouseEvent) => {
setAnchorEl(e.currentTarget)
}
// метод для закрытия меню
const closeMenu = () => {
setAnchorEl(null)
}
return (
{links.map((link, i) => (
{link.title}
))}
)
}
Данный компонент представляет собой боковую панель со списком ссылок (+ кнопка для открытия меню) и кнопку профиля. О 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) {
// данные информационных блоков
const { blocks } = data
// об этом позже
const { user } = useUser()
return (
<>
Welcome, {user ? user.username || user.email : 'stranger'}
{/* слайдер */}
{/* информационные блоки */}
{blocks.map((block, i) => (
{/* самописная библиотека анимации */}
{i % 2 ? (
<>
{block.title}
{block.description}
>
) : (
<>
{block.title}
{block.description}
>
)}
))}
>
)
}
// функция генерации статического контента с данными
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) {
// данные новостных блоков
const { news } = data
return (
<>
About
{/* блок с приветствием */}
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.
News
{/* новостные блоки */}
{/* превью новости содержит ссылку на соответствующую страницу */}
{news.map((n) => (
))}
>
)
}
// функция генерации статического контента с данными
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) {
return (
<>
{news.author.slice(0, 1)}
}
action={
}
title={news.title}
subheader={new Date(news.datePublished).toDateString()}
/>
{news.text}
>
)
}
// функция генерации путей статических страниц
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(
input: RequestInfo | URL,
init?: RequestInit | undefined
): Promise {
return fetch(input, init).then((res) => res.json())
}
// запрос на получение данных пользователя выполняется один раз
export function useUser() {
// утилита возвращает данные пользователя и токен доступа, ошибку и
// функцию инвалидации кэша (метод для мутирования данных, хранящихся в кэше)
const { data, error, mutate } = useSWRImmutable(
'/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 ? :
return (
}
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 (
<>
{triggerComponent}
{content}
>
)
}
При отсутствии данных пользователя содержимым модалки являются вкладки аутентификации (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 (
{value === index && children}
)
}
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 (
<>
>
)
}
Форма регистрации (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 = async (e) => {
e.preventDefault()
// данные пользователя в виде объета
const formData = Object.fromEntries(
new FormData(e.target as HTMLFormElement)
) as unknown as Pick & {
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 = () => {
// сбрасываем ошибки при наличии
if (Object.keys(errors).length) {
setErrors({})
}
}
return (
Register
Username
}
/>
Email
}
/>
{errors.email && Email already in use }
Password
}
/>
Password must be at least 6 characters long
Confirm password
}
/>
{errors?.passwordConfirm && (
Passwords must be the same
)}
)
}
Форма авторизации почти идентична форме регистрации.
Результат:
Форма регистрации
Форма авторизации
Пользовательская панель
При наличии данных пользователя содержимым модалки, которая рендерится при нажатии кнопки профиля, является пользовательская панель (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 (
<>
>
)
}
Форма загрузки аватара (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(null)
// состояние файла
const [file, setFile] = useState()
const { user, accessToken, mutate } = useUser()
if (!user) return null
// обработчик отправки формы
const handleSubmit: React.FormEventHandler = 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 = (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 (
Avatar
)
}
Кнопка для выхода из системы (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 (
)
}
Результат:
Без превью
С превью
После загрузки аватар пользователя отображается в шапке сайте на месте кнопки профиля.
Создание, обновление, удаление и лайк постов
Для генерации страницы блога и страниц постов используется рендеринг на стороне сервера с помощью функции 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) {
return (
<>
{/* кнопка для создания поста */}
Posts
{/* список постов или сообщение об их отсутствии */}
{posts.length ? (
{posts.map((post) => (
))}
) : (
There are no posts yet
)}
>
)
}
// функция серверного рендеринга
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 { toas