от Чудо Оньенма

Создание современных полнофункциональных приложений стало проще для фронтенд-разработчиков благодаря новым технологиям и инструментам. С их помощью мы можем создавать интерфейсные приложения с возможностями на стороне сервера с такими фреймворками, как Next.js. Мы также можем абстрагироваться от сложных деталей взаимодействия с базой данных, используя ORM, например Prisma. В этом руководстве мы узнаем, как создать полнофункциональное приложение с функциями аутентификации и CRUD с использованием Next.js, Prisma и MongoDB. Мы объясним эти технологии и связанные с ними концепции и посмотрим, как мы можем объединить эти технологии для создания приложения.

Призма

Prisma — это объектно-реляционный преобразователь следующего поколения (ORM), который можно использовать для доступа к базе данных в приложениях Node.js и TypeScript. Мы будем использовать Prisma для взаимодействия с нашей базой данных MongoDB для создания, чтения, обновления и удаления заметок, создания пользователей в нашей базе данных и определения схемы для пользователей и заметок.

Круто, но что такое ORM и зачем нам Prisma для доступа к нашей базе данных? Object Relational Mapping — это метод, используемый для доступа и управления реляционными базами данных с использованием объектно-ориентированного языка программирования (например, JavaScript). Prisma ORM также работает с MongoDB, базой данных на основе документов, которую мы будем использовать в статье. ORM позволяют моделировать данные приложения путем сопоставления классов с таблицами в базе данных, а экземпляры классов сопоставляются со строками в таблицах. Однако в Prisma, которая использует подход, несколько отличающийся от других традиционных ORM, модели приложений определяются в вашей схеме Prisma.

Далее давайте взглянем на MongoDB.

MongoDB

По данным Гуру99:

MongoDB — это документно-ориентированная база данных NoSQL, используемая для хранения больших объемов данных. Вместо использования таблиц и строк, как в традиционных реляционных базах данных, MongoDB использует коллекции и документы. Документы состоят из пар ключ-значение, основной единицы данных в MongoDB. Коллекции содержат наборы документов, которые эквивалентны таблицам реляционных баз данных. →

MongoDB — это база данных No-SQL, которая хранит данные в документах в формате, подобном JSON. Коллекции — это набор документов, эквивалентный таблице в реляционной базе данных. Здесь мы будем хранить наши данные. Вы можете прочитать немного больше о MongoDB в этом посте от Guru99.

Next.js

Next.js — это фреймворк React с открытым исходным кодом, который упрощает создание готовых к работе реагирующих приложений, предоставляя множество функций, таких как рендеринг на стороне сервера и генерация статического сайта, которые отображают приложение на сервере и увлажняют (добавьте функциональность JavaScript) на клиенте (браузере). Next.js также включает такие функции, как маршруты API, маршрутизация файловой системы, быстрое обновление, встроенную поддержку CSS, метатеги и многие другие.

Давайте погрузимся в создание нашего приложения.

Предпосылки

Чтобы следовать этому руководству и завершить его, вам потребуется следующее:

  • Базовые знания JavaScript, React и Next.js
  • Node.js установлен; Я буду использовать v16.13.0
  • База данных MongoDB. Убедитесь, что ваше развертывание MongoDB настроено с использованием наборов реплик для возможности интеграции с Prisma. Вы можете запустить бесплатный экземпляр MongoDB на MongoDB Atlas, который имеет встроенную поддержку набора реплик. Вы также можете преобразовать автономный набор в набор реплик, если хотите работать с MongoDB локально.
  • Аккаунт Google и доступ к Google Cloud Console
  • Если вы используете VSCode, я рекомендую скачать Prisma VSCode Extension

Настройка внешнего интерфейса

Чтобы сэкономить время и понять основную цель этого руководства, мы будем использовать простую программу для начинающих с базовыми функциями и встроенными компонентами. Этот стартер поставляется с TailwindCSS и библиотекой heroicons. Перед интеграцией с NextAuth, Prisma и MongoDB мы изучим структуру, функции и компоненты начального проекта. На вашем компьютере клонируйте начальный репозиторий и установите зависимости:

git clone https://github.com/miracleonyenma/next-notes-app-starter.git

# cd into the directory
cd next-notes-app

# install dependencies
yarn install

После установки наша структура каталогов должна выглядеть примерно так:

📦next-notes-app
  ┣ 📂node_modules
  ┣ 📂components
  ┃ ┣ 📜Editor.js
  ┃ ┣ 📜NotesList.js
  ┃ ┗ 📜SiteHeader.js
  ┣ 📂layouts
  ┃ ┗ 📜default.js
  ┣ 📂modules
  ┃ ┣ 📜AppContext.js
  ┃ ┗ 📜RandomID.js
  ┣ 📂pages
  ┃ ┣ 📂api
  ┃ ┃ ┗ 📜hello.js
  ┃ ┣ 📂[user]
  ┃ ┃ ┗ 📜index.js
  ┃ ┣ 📜index.js
  ┃ ┗ 📜_app.js
  ┣ 📂public
  ┃ ┣ 📜favicon.ico
  ┃ ┗ 📜vercel.svg
  ┣ 📂styles
  ┃ ┣ 📜Editor.css
  ┃ ┣ 📜globals.css
  ┃ ┣ 📜Home.module.css
  ┃ ┣ 📜NoteList.css
  ┃ ┗ 📜SiteHeader.css
  ┣ 📜.eslintrc.json
  ┣ 📜.gitignore
  ┣ 📜next.config.js
  ┣ 📜package.json
  ┣ 📜postcss.config.js
  ┣ 📜README.md
  ┣ 📜tailwind.config.js
  ┗ 📜yarn.lock

Чтобы запустить приложение, запустите yarn dev, и приложение должно начать работать с http://localhost:3000/.

Откройте в браузере, чтобы увидеть работающее приложение заметок.

Вы можете просмотреть стартовый пример, размещенный на Netlify. Прямо сейчас мы можем создавать, обновлять и удалять заметки. Однако все это на стороне клиента. Когда мы обновляем страницу, мы теряем все данные, потому что состояние управляется в нашем приложении с помощью Context API.

Управление состоянием с помощью Context API

Context API — это инструмент управления состоянием, связанный с самой библиотекой React.js. Обычно нам нужно передавать данные между компонентами в любом приложении. Например, чтобы передать состояние от родительского компонента к дочернему компоненту, мы будем использовать props. Однако передача состояния между одноуровневыми компонентами и от дочернего компонента к его родительскому может стать сложной. Вот где вступает в действие управление состоянием, позволяющее любому компоненту в любом месте приложения иметь доступ к состоянию приложения. Мы могли бы использовать Redux или Recoil, но мы будем использовать Context API. Чтобы увидеть, как работает наш контекстный API, откройте файл ./modules/AppContext.js. Мы не будем подробно рассказывать о том, как работает Context API в приложении Next.js, но вы можете обратиться к этому примеру GitHub, чтобы увидеть, как использовать Context API в приложении Next.js.

// ./modules/AppContext.js

    const { createContext, useState, useContext, useReducer } = require("react");

    // context data getter
    const NoteStateContext = createContext();
    const NotesStateContext = createContext();

    // context data setter
    const NoteDispatchContext = createContext();
    const NotesDispatchContext = createContext();

    // reducer function to modify state based on action types
    const notesReducer = (state, action) => {
      const { note, type } = action;
      if (type === "add") {...}
      if (type === "remove") {...}
      if (type === "edit") {...}
      return state;
    };

    // NoteProvider, which will wrap the application
    // providing all the nested state and dispatch context
    export const NoteProvider = ({ children }) => {
      // useState for note, to get and set a single note
      const [note, setNote] = useState({});

      // use Reducer for notes, to get all notes
      // and add, edit or remove a note from the array
      const [notes, setNotes] = useReducer(notesReducer, []);
      return (
        <NoteDispatchContext.Provider value={setNote}>
          <NoteStateContext.Provider value={note}>
            <NotesDispatchContext.Provider value={setNotes}>
              <NotesStateContext.Provider value={notes}>
                {children}
              </NotesStateContext.Provider>
            </NotesDispatchContext.Provider>
          </NoteStateContext.Provider>
        </NoteDispatchContext.Provider>
      );
    };

    // export state contexts
    export const useDispatchNote = () => useContext(NoteDispatchContext);
    export const useNote = () => useContext(NoteStateContext);
    export const useDispatchNotes = () => useContext(NotesDispatchContext);
    export const useNotes = () => useContext(NotesStateContext);

Посмотреть полный код здесь

Здесь у нас есть два основных типа контекста: контекст state и контекст dispatch, созданный с помощью хука createContext.
Контекст state будет действовать как получатель данных, а контекст dispatch — как установщик.
Для этого в функции NoteProvider, которая оборачивает реквизит children вокруг провайдеров DispatchContext и StateContext, мы используем хуки useState и useReducer, например, для создания notes и setNotes.

const [notes, setNotes] = useReducer(notesReducer, []);

setNotes использует хук useReducer и функцию notesReducer для добавления, редактирования или удаления заметки из массива notes на основе action.type, переданного в функцию setNotes, и передаются их поставщикам контекста:

<NotesDispatchContext.Provider value={setNotes}>
  <NotesStateContext.Provider value={notes}>

В конце файла контекст экспортируется с помощью хука useContext:

export const useDispatchNotes = () => useContext(NotesDispatchContext);
export const useNotes = () => useContext(NotesStateContext);

Чтобы все приложение имело доступ к контексту, нам нужно включить его в наш файл ./pages/app.js.

// ./pages/app.js

    import { NoteProvider } from "../modules/AppContext";
    import DefaultLayout from "../layouts/default";

    function MyApp({ Component, pageProps }) {
      return (
        <NoteProvider>
          <DefaultLayout>
            <Component {...pageProps} />
          </DefaultLayout>
        </NoteProvider>
      );
    }
    export default MyApp;

Просмотреть полный код на Github

Здесь мы оборачиваем наше приложение — <Component {...pageProps} /> вокруг NoteProvider, которое мы импортировали в файл import { NoteProvider } from "../modules/AppContext";
Это делает любое состояние в пределах NoteProvider доступным во всех компонентах приложения.

Компонент «Список заметок»

Чтобы получить доступ к этому глобальному состоянию и изменить его, мы сначала импортируем нужный нам контекст из ./modules/AppContext.js.

// ./components/NotesList.js
import {  PencilAltIcon, TrashIcon, ExternalLinkIcon } from "@heroicons/react/solid";
import { useNote, useDispatchNote, useNotes, useDispatchNotes } from "../modules/AppContext";

const NotesList = ({ showEditor }) => {
  // this is where we assign the context to constants
  // which we will use to read and modify our global state
  const notes = useNotes();
  const setNotes = useDispatchNotes();
  const currentNote = useNote();
  const setCurrentNote = useDispatchNote();

  // function to edit note by setting it to the currentNote state
  // and adding the "edit" action
  // which will then be read by the <Editor /> component
  const editNote = (note) => {
    note.action = "edit";
    setCurrentNote(note);
  };

  // function to delete note by using the setNotes Dispatch notes function
  const deleteNote = (note) => {
    let confirmDelete = confirm("Do you really want to delete this note?");
    confirmDelete ? setNotes({ note, type: "remove" }) : null;
  };

  return (
    <div className="notes">
      {notes.length > 0 ? (
        <ul className="note-list">
          {notes.map((note) => (
            <li key={note.id} className="note-item">
              <article className="note">
                <header className="note-header">
                  <h2 className="text-2xl">{note.title}</h2>
                </header>
                <main className=" px-4">
                  <p className="">{note.body}</p>
                </main>
                <footer className="note-footer">
                  <ul className="options">
                    <li onClick={() => editNote(note)} className="option">
                      <button className="cta cta-w-icon">
                        <PencilAltIcon className="icon" />
                        <span className="">Edit</span>
                      </button>
                    </li>
                    <li className="option">
                      <button className="cta cta-w-icon">
                        <ExternalLinkIcon className="icon" />
                        <span className="">Open</span>
                      </button>
                    </li>
                    <li className="option">
                      <button onClick={() => deleteNote(note)} className="cta cta-w-icon">
                        <TrashIcon className="icon" />
                        <span className="">Delete</span>
                      </button>
                    </li>
                  </ul>
                </footer>
              </article>
            </li>
          ))}
        </ul>
      ) : (
        <div className="fallback-message">
          <p>Oops.. no notes yet</p>
        </div>
      )}
    </div>
  );
};
export default NotesList;

Просмотреть полный код на Github

Компонент редактора

Функция компонента <Editor /> проста: он получает текущие данные заметки из состояния приложения, импортируя useNote() из ./modules/AppContext.js и назначая его currentNote. Он добавляет новую заметку в глобальный массив notes или обновляет существующую заметку в зависимости от типа действия.

import { useEffect, useState } from "react";
import { CheckCircleIcon } from "@heroicons/react/solid";
import { useNote, useDispatchNote, useNotes, useDispatchNotes } from "../modules/AppContext";
import RandomID from "../modules/RandomID";
const Editor = () => {
  // the current note
  const currentNote = useNote();
  const setCurrentNote = useDispatchNote();
  // the array of saved notes
  const notes = useNotes();
  const setNotes = useDispatchNotes();
  // editor note states
  const [title, setTitle] = useState("Hola");
  const [body, setBody] = useState(
    `There once was a ship that put to sea
and the name of the ship was the billy old tea`
  );  const [noteID, setNoteID] = useState(null);
  const [noteAction, setNoteAction] = useState("add");
  const [isSaved, setIsSaved] = useState(false);
  // function to update textarea content and height
  const updateField = (e) => {
    // get textarea
    let field = e.target;
    //set body state to textarea value
    setBody(field.value);
    // reset textarea height
    field.style.height = "inherit";
    // Get the computed styles for the textarea
    let computed = window?.getComputedStyle(field);
    // calculate the height
    let height =
      parseInt(computed.getPropertyValue("border-top-width"), 10) +
      parseInt(computed.getPropertyValue("padding-top"), 10) +
      field.scrollHeight +
      parseInt(computed.getPropertyValue("padding-bottom"), 10) +
      parseInt(computed.getPropertyValue("border-bottom-width"), 10);
    // set the new height
    field.style.height = `${height}px`;
  };

  // function to save note to saved notes array
  const saveNote = () => {
    // check if the title input & body textarea actually contain text
    if (title && body) {
      // check if note already has an ID, if it does asign the current id to the note object,
      // if not, assign a new random ID to the note object
      let id = noteID || RandomID(title.slice(0, 5), 5);
      // the note object
      let note = {
        id,
        title,
        body,
      };
      try {
        if (noteAction == "edit") {
          // edit in notes list
          setNotes({ note, type: "edit" });
          console.log({ note, noteAction, noteID, notes });
        } else {
          // add to notes list
          setNotes({ note, type: "add" });
        }
        // change isSaved state to true, thereby disabling the save button
        setIsSaved(true);
        // clear note content
        note = { title: "", body: "" };
        // clear editor
        setTitle(note.title);
        setBody(note.body);
        // clear current note state
        setCurrentNote(note);
      } catch (error) {
        console.log({ error });
      }
    }
  };
  // enable the button whenever the content of title & body changes
  useEffect(() => {
    if (title && body) setIsSaved(false);
    else setIsSaved(true);
  }, [title, body]);
  // update the editor content whenever the note context changes
  // this acts like a listener whenever the user clicks on edit note
  // since the edit note funtion, sets
  useEffect(() => {
    if (currentNote.title && currentNote.body) {
      setTitle(currentNote.title);
      setBody(currentNote.body);
      setNoteID(currentNote.id);
      setNoteAction(currentNote.action);
    }
  }, [currentNote]);
  return (
    <div className={"editor"}>
      <div className={"wrapper"}>
        <div className="editing-area">
          <div className="title">
            <input value={title} onChange={(e) => setTitle(e.target.value)} type="text" className={"form-input"} placeholder="Title" />
          </div>
          <div className="body">
            <textarea
              value={body}
              onChange={(e) => updateField(e)}
              name="note-body"
              id="note-body"
              className="form-textarea"
              cols="10"
              rows="2"
              placeholder="Write something spec ✨"
            ></textarea>
          </div>
        </div>
        <ul className={"options"}>
          <li className={"option"}>
            <button onClick={saveNote} disabled={isSaved} className="cta flex gap-2 items-end">
              <CheckCircleIcon className="h-5 w-5 text-blue-500" />
              <span className="">{isSaved ? "Saved" : "Save"}</span>
            </button>
          </li>
        </ul>
      </div>
    </div>
  );
};
export default Editor;

Просмотреть полный код на Github

Надеюсь, теперь мы знаем основы работы приложения. Перейдем к NextAuth, Prisma и MongoDB.

Повтор сеанса с открытым исходным кодом

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

Начните получать удовольствие от отладки — начните использовать OpenReplay бесплатно.

Настройка NextAuth, Prisma и MongoDB

Адаптер в NextAuth.js подключает ваше приложение к любой базе данных или серверной системе, которую вы хотите использовать для хранения данных о пользователях, их учетных записях, сеансах и т. д. Мы будем использовать адаптер Prisma. Чтобы использовать этот адаптер, вам необходимо установить Prisma Client, Prisma CLI и отдельный пакет @next-auth/prisma-adapter:

yarn add next-auth @prisma/client @next-auth/prisma-adapter
yarn add prisma --dev

Давайте настроим наш кластер MongoDB, чтобы мы могли настроить наш NextAuth.js для использования адаптера Prisma. Войдите в MongoDB и настройте кластер MongoDB на Atlas
Перейдите в раздел Проекты и создайте новый проект:

Затем мы добавим разрешения для нашего проекта и назначим владельца проекта. Затем создадим базу данных для нашего проекта.

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

Нажмите Создать кластер. Далее нам нужно настроить безопасность нашей базы данных. Выберите имя пользователя и пароль, создайте нового пользователя, в разделе "Откуда вы хотите подключиться?" выберите "Моя локальная среда". > и Добавить мой текущий IP-адрес. Нажмите Готово.

Затем нам нужно получить URL-адрес нашего подключения для подключения к нашему кластеру. Нажмите Подключить приложение.

Скопируйте предоставленную строку подключения

Теперь, когда мы получили строку подключения к базе данных, добавьте ее в файл .env. С несколькими другими переменными среды, требуемыми NextAuth.

// .env

DATABASE_URL=mongodb+srv://username:[email protected]/myFirstDatabase?retryWrites=true&w=majority
NEXTAUTH_SECRET=somesecret
NEXTAUTH_URL=http://localhost:3000

🚨 Убедитесь, что вы включили имя базы данных в свой URL-адрес MongoDB. Например, здесь используется база данных myFirstDatabase.

Работа с Призмой

Мы можем использовать инструмент Prisma CLI для создания нескольких файлов, связанных с Prisma. Бегать:

npx prisma init

Это создает базовый файл /prisma/schema.prisma. Эта схема адаптирована для использования в Prisma и основана на основной схеме NextAuth. Мы изменим схему для работы с NextAuth; введите в схему следующий код:

// ./prisma/schema.prisma

datasource db {
  provider = "mongodb"
  url      = env("DATABASE_URL")
}
generator client {
  provider        = "prisma-client-js"
}
model Account {
  id                String  @id @default(auto()) @map("_id") @db.ObjectId
  userId            String
  type              String
  provider          String
  providerAccountId String
  refresh_token     String? @db.String
  access_token      String? @db.String
  expires_at        Int?
  token_type        String?
  scope             String?
  id_token          String? @db.String
  session_state     String?
  user User @relation(fields: [userId], references: [id], onDelete: Cascade)
  @@unique([provider, providerAccountId])
}
model Session {
  id           String   @id @default(auto()) @map("_id") @db.ObjectId
  sessionToken String   @unique
  userId       String
  expires      DateTime
  user         User     @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model User {
  id            String    @id @default(auto()) @map("_id") @db.ObjectId
  name          String?
  email         String?   @unique
  emailVerified DateTime?
  image         String?
  accounts      Account[]
  sessions      Session[]
}
model VerificationToken {
  identifier String   @id @default(auto()) @map("_id") @db.ObjectId
  token      String   @unique
  expires    DateTime
  @@unique([identifier, token])
}

Теперь, когда мы написали нашу схему, мы можем создавать коллекции в нашей базе данных. Используя Prisma с помощью инструмента CLI, запустите npx prisma db push, и вы должны увидеть:

Мы должны увидеть наши новые коллекции, если проверим нашу коллекцию базы данных в Atlas.

Потрясающий! Теперь, чтобы взаимодействовать с нашей базой данных, мы должны сначала сгенерировать Prisma Client. Используйте CLI Prisma для создания клиента Prisma:

npx prisma generate

Эта команда считывает нашу схему Prisma и создает версию Prisma Client, адаптированную к нашим моделям.

Мы можем начать использовать PrismaClient для взаимодействия с нашей базой данных. Мы будем использовать один экземпляр PrismaClient, который мы можем импортировать в любой файл, где это необходимо. Создайте новый файл ./prisma/prisma.js:

// prisma/prisma.js
import { PrismaClient } from '@prisma/client'
let prisma
if (process.env.NODE_ENV === 'production') {
  prisma = new PrismaClient()
} else {
  if (!global.prisma) {
    global.prisma = new PrismaClient()
  }
  prisma = global.prisma
}
export default prisma

Теперь давайте приступим к завершению нашей интеграции NextAuth.

Настройте наше приложение Google

Мы будем использовать провайдера Google для этого проекта, поэтому нам нужно получить переменные среды для настройки нашего провайдера Google. Сначала заходим в Google Cloud console,

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

Введите название проекта и нажмите создать. После создания проекта нажмите ВЫБЕРИТЕ ПРОЕКТ в модальном окне уведомлений.

Теперь в нашем новом приложении мы можем открыть боковую панель, перейти в API и службы > Учетные данные. Первое, что нам нужно сделать, это настроить экран согласия:

Затем выберите Внешний тип пользователя.

На следующем экране мы добавляем информацию о нашем приложении.

Затем мы добавляем информацию о разработчике и нажимаем Сохранить и продолжить на этом экране и на следующих экранах, пока не закончите, и мы вернемся к панели инструментов. В боковом меню в разделе API & CREDENTIALS нажмите Учетные данные, чтобы создать новый идентификатор клиента **OAuth.

На следующем экране мы выберем тип и имя приложения,

и добавьте авторизованный URI Google для разработкиhttp://localhost:3000/api/auth/callback/google

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

// .env

GOOGLE_CLIENT_ID=<client_ID_here>
GOOGLE_CLIENT_SECRET=<client_secret_here>
DATABASE_URL=mongodb+srv://username:[email protected]/myFirstDatabase?retryWrites=true&w=majority
NEXTAUTH_SECRET=somesecret
NEXTAUTH_URL=http://localhost:3000

Настройте NextAuth с помощью адаптера Prisma.

Чтобы добавить NextAuth.js в проект, создайте файл с именем [...nextauth].js в pages/api/auth. Он содержит обработчик динамического маршрута для NextAuth.js, который также будет содержать все ваши глобальные конфигурации NextAuth.js. Все запросы к /api/auth/* ( signIn, callback, signOut и т. д.) будут автоматически обрабатываться NextAuth.js. Мы также настроим ваш NextAuth.js для использования адаптера Prisma и клиента Prisma.

// ./pages/api/auth/[...nextauth].js

import NextAuth from "next-auth"
import GoogleProvider from "next-auth/providers/google"
import { PrismaAdapter } from "@next-auth/prisma-adapter"
import prisma from "../../../prisma/prisma";

export default NextAuth({
  adapter: PrismaAdapter(prisma),
  providers: [
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET,
    }),
  ],
  callbacks: {
    async session({ session, token, user }) {
      session.user.id = user.id;
      return session;
    },
  },
});
})

Обратите внимание на объект callbacks; именно здесь мы заполняем сеанс пользователя их id, поскольку NextAuth не предоставляет это значение по умолчанию.

Настройте состояние сеанса NextAuth Shared

Перехватчик useSession() React в клиенте NextAuth.js — это самый простой способ проверить, вошел ли кто-то в систему.
Чтобы иметь возможность использовать useSession, сначала вам нужно предоставить контекст сеанса, ‹SessionProvider /›, на верхнем уровне вашего приложения:

// ./pages/_app.js

import { SessionProvider } from "next-auth/react";
import { NoteProvider } from "../modules/AppContext";
import DefaultLayout from "../layouts/default";

function MyApp({ Component, pageProps: { session, ...pageProps } }) {
  return (
    <SessionProvider session={session}>
      <NoteProvider>
        <DefaultLayout>
          <Component {...pageProps} />
        </DefaultLayout>
      </NoteProvider>
    </SessionProvider>
  );
}
export default MyApp;

После этого экземпляры useSession будут иметь доступ к данным и статусу сеанса. <SessionProvider /> также обновляет сеанс и синхронизирует его между вкладками и окнами браузера.

Добавить функцию входа в систему

Чтобы добавить действия входа и выхода, мы создадим компонент ./components/AuthBtn.js, который будет помещен в наш компонент ./components/SiteHeader.js.

// ./components/AuthBtn.js
import { ChevronDownIcon, RefreshIcon } from "@heroicons/react/solid";
import { useSession, signIn, signOut } from "next-auth/react";
import Image from "next/image";
const AuthBtn = () => {
  const { data: session, status } = useSession();
  if (status === "loading") {
    return (
      <div className="auth-btn">
        <div className="auth-info">
          <RefreshIcon className="icon animate-spin" />
        </div>
      </div>
    );
  }
  if (status === "unauthenticated") {
    return (
      <div className="auth-btn">
        <button onClick={() => signIn()}>Login</button>
      </div>
    );
  }
  return (
    <div className="auth-btn">
      <div className="auth-info pr-2">
        <Image src={session.user.image} alt={session.user.name} width={30} height={30} className="rounded-full" />
        <p>Hi, {session.user.name}</p>
      </div>
      <div className="dropdown">
        <button className="dropdown-btn !py-1">
          <ChevronDownIcon className="icon" />
        </button>
        <ul className="dropdown-list opacity-0 invisible">
          <li className="dropdown-item">
            <button onClick={() => signOut()} className="cta">
              Logout
            </button>
          </li>
        </ul>
      </div>
    </div>
  );
};
export default AuthBtn;

Здесь мы отображаем наш пользовательский интерфейс на основе состояния, возвращаемого status, которое соответствует трем возможным состояниям сеанса:

  • "loading" - Вращающаяся иконка
  • "authenticated" - имя пользователя, изображение и раскрывающийся список выхода
  • "unauthenticated" - кнопка входа

Небольшое примечание: чтобы отображать изображения из другого домена с помощью Next.js <Image />, нам нужно добавить его в список доменов в нашем файле ./next.config.js

// next.config.js

const nextConfig = {
  reactStrictMode: true,
  images: {
    domains: ['lh3.googleusercontent.com'],
  },
}
module.exports = nextConfig

Давайте посмотрим на это в действии. Запустите yarn dev, чтобы получить:

Вы можете просмотреть развернутое приложение, размещенное на Netlify и текущий код в [basic-auth](https://github.com/miracleonyenma/next-notes-app-starter/tree/basic-crud) ветке репозитория на GitHub.

Затем мы обновляем нашу схему Prisma моделью Note, чтобы мы могли управлять заметками в нашей базе данных MongoDB.

Добавить модель заметки в схему Prisma

В наш файл ./prisma/schema.prisma мы собираемся добавить модель Note:

// ./prisma/schema.prisma
model Note {
  id    String @id @default(auto()) @map("_id") @db.ObjectId
  title String
  body  String
  userId String?
  user   User?   @relation(fields: [userId], references: [id], onDelete: Cascade)
  @@unique([id, userId])
}

Нам также нужно добавить ссылку Note к нашей модели User:

// ./prisma/schema.prisma
model User {
  id            String    @id @default(auto()) @map("_id") @db.ObjectId
  name          String?
  email         String?   @unique
  emailVerified DateTime?
  image         String?
  accounts      Account[]
  sessions      Session[]
  notes          Note[] //add note reference
}

Еще раз, чтобы синхронизироваться с нашей базой данных, запустите npx prisma db push

Мы должны увидеть новую коллекцию Note, если проверим нашу базу данных MongoDB.

Добавить функцию создания новой заметки

Давайте создадим наши CRUD-функции. Во-первых, функция createNote добавит заметку в нашу базу данных с Prisma. По мере продвижения мы будем создавать функции чтения, обновления и удаления. Создайте новый файл ./prisma/note.js, который будет содержать все наши функции CRUD:

// ./prisma/Note.js
import prisma from "./prisma";
// READ
//get unique note by id
export const getNoteByID = async (id) => {
  const note = await prisma.note.findUnique({
    where: {
      id,
    },
    include: {
      user: true,
    },
  });
  return note;
};
// CREATE
export const createNote = async (title, body, session) => {
  const newNote = await prisma.note.create({
    data: {
      title,
      body,
      user: { connect: { email: session?.user?.email } },
    },
  });
  const note = await getNoteByID(newNote.id);
  return note;
};

Здесь наша функция создания принимает несколько параметров — title, body и session. session будет содержать данные текущего сеанса, включая информацию о пользователе, в частности user.email. Мы используем prisma.note.create() для создания новой заметки, передавая объект с ключом data, который представляет собой объект, содержащий title, body и user. Поскольку поле user является реляционным, мы используем connect для подключения новой заметки к существующему пользователю с предоставленным email. Затем мы должны создать конечную точку API для запуска этой функции.

Добавить конечную точку API создания заметок

Создайте новый файл ./pages/api/note.js.

// pages/api/note.js

import { createNote } from "../../prisma/Note";
import { getSession } from "next-auth/react";
export default async function handle(req, res) {
  // Get the current session data with {user, email, id}
  const session = await getSession({ req });
  // Run if the request was a post request
  if (req.method == "POST") {
    // Get note title & body from the request body
    const { title, body } = req.body;
    // Create a new note
    // also pass the session which would be use to get the user information
    const note = await createNote(title, body, session);
    // return created note
    return res.json(note);
  }
}

Здесь мы создаем конечную точку API. Всякий раз, когда запрос поступает на /api/note, мы обрабатываем его здесь. Во-первых, мы проверяем, что получили сеанс аутентифицированного пользователя с помощью вспомогательной функции getSession из NextAuth.js. Для запроса POST мы получаем title и body из запроса. Затем мы запускаем функцию createNote, которую создали ранее. Далее отправим запрос на создание из нашего интерфейса в компоненте <Editor />.

Отправить запрос на создание из Frontend

🚨 Чтобы гарантировать, что только аутентифицированные пользователи могут создавать заметки, в компоненте редактора мы должны отображать только тогда, когда статус session «аутентифицирован».

// ./components/Editor.js

const Editor = () => {
  // ..
  return (
    status === "authenticated" && (
        <div className={"editor"}> {/* ... */} </div>
    )
  );

};

В нашем файле ./components/Editor.js, используя API fetch, мы отправим запрос POST на нашу конечную точку создания /api/note и сохраним данные в нашем состоянии контекста Notes. Давайте отредактируем нашу функцию saveNote.

// ./components/Editor.js

// ...
  const saveNote = async () => {
    if (title && body) {
      // ...
      try {
        if (noteAction == "edit") {
          // ...
        } else {
          // send create request with note data
          let res = await fetch("/api/note", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify(note),
          });

          const newNote = await res.json();
          console.log("Create successful", { newNote });
          // add to notes list (global context state)
          setNotes({ note: newNote, type: "add" });
        }
        // ...
      }
    }
  }
// ...

Давайте посмотрим, что в действии:

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

Добавить функциональность заметки об обновлении

Точно так же мы создадим функцию обновления. В нашем файле ./prisma/note.js создайте новую функцию updateNote().

// ./prisma/Note.js
// ...
// UPDATE
export const updateNote = async (id, updatedData, session) => {
  let userId = session?.user.id;
  const updatedNote = await prisma.note.update({
    where: {
      id_userId: {
        id,
        userId,
      },
    },
    data: {
      ...updatedData,
    },
  });
  const note = await getNoteByID(updatedNote.id);
  return note;
};

Здесь мы получаем заметку id, updatedData и пользователя session. От пользователя session мы получаем userId.
В prisma.note.update мы фильтруем примечание для обновления по userId, а примечание id объединяем уникальные поля и соединяем их знаком подчеркивания.

where: {
    id_userId: {
      id,
      userId,
    },
  },
},

Затем передайте updatedData data. Далее мы создадим конечную точку API для вызова нашей функции обновления.

Добавить конечную точку API примечания к обновлению

Вернувшись в наш файл ./pages/api/note.js, для запросов PUT мы получим и передадим примечание id, title , body и session и передадим его в нашу функцию updateNote().

// ./pages/api/note.js
// ...
export default async function handle(req, res) {
  // Run if the request is a POST request
  // ...
  // Run if the request is a PUT request
  else if (req.method == "PUT") {
    const { id, title, body } = req.body;
    // const updatedData = {title, body}
    // Update current note
    // also pass the session which would be use to get the user information
    const note = await updateNote(id, { title, body }, session);
    // return updated note
    return res.json(note);
  }
}

Теперь вернемся к нашему компоненту ./components/Editor.js и добавим запрос на обновление заметки.

// ./components/Editor.js
// ...
  const saveNote = async () => {
    if (title && body) {
      // ...
      try {
        if (noteAction == "edit") {
          // add note id to note data
          note.id = noteID;
          // send request to edit note
          let res = await fetch("/api/note", {
            method: "PUT",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify(note),
          });
          // update note
          const updatedNote = await res.json();
          console.log("Update successful", { updatedNote });
          // edit in notes list
          setNotes({ note: updatedNote, type: "edit" });
        } else {
          // send create request with note data
          // ...
        }
        // ...
      }
    }
  }
// ...

Здесь мы получаем примечание id из переменной состояния noteID. В нашей функции fetch мы отправляем запрос PUT с данными note и сохраняем ответ в нашем состоянии контекста Notes. Давайте посмотрим на это в действии:

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

Обновление представления для отображения заметок из базы данных

Во-первых, мы создадим дополнительные вспомогательные функции для получения всех заметок от аутентифицированного пользователя.

// ./prisma/Note.js
  // ...

  // get notes by user
  export const getAllNotesByUserID = async (id) => {
    const notes = await prisma.note.findMany({
      where: {
        userId: id,
      },
      include: {
        user: true,
      },
    });
    return notes;

🚨 Здесь мы используем require для импорта нашей вспомогательной функции getAllNotesByUserID, чтобы Next.js не пытался запустить ее на стороне клиента. Затем в компоненте ./components/NoteList.js мы получаем retrieved_notes в качестве реквизита, а в хуке useEffect заменяем состояние примечаний приложения на извлеченные примечания:

// ./components/NoteList.js

import { useEffect } from "react";
import Image from "next/image";
// ...

const NotesList = ({ retrieved_notes, showEditor }) => {
  // ...
  useEffect(() => {
    // replace notes in notes context state
    setNotes({ note: retrieved_notes, type: "replace" });
  }, [retrieved_notes]);

  return (
    <div className="notes">
      {notes.length > 0 ? (
        <ul className="note-list">
          {notes.map((note) => (
            <li key={note.id} className="note-item">
                {/* ... */}
                <footer className="note-footer">
                  <ul className="options">
                    <li className="option">
                      {/* add user image to note footer */}
                      <Image src={note.user.image} alt={note.user.name} width={32} height={32} className="rounded-full" />
                    </li>
                    {/* ... */}
                  </ul>
                </footer>
              </article>
            </li>
          ))}
        </ul>
      ) : (
        {/* ... */}
      )}
    </div>
  );
};

Кроме того, в нашем файле .modules/AppContext.js мы добавляем поддержку типа действия replace в нашей функции редуктора.

const notesReducer = (state, action) => {
  // get the note object and the type of action by destructuring
  const { note, type } = action;

  // if "replace"
  // replace the entire array with new value
  if (type === "replace") return note;

  // ...
}

Смотрите в действии:

Отлично. Теперь мы можем получать наши заметки из базы данных и обновлять состояние приложения при загрузке страницы.

Добавить функцию удаления заметки

Чтобы удалить заметку, нам нужно создать вспомогательную функцию удаления. В наш файл ./prisma/Note.js добавьте эту функцию:

// ./prisma/Note.js
// ...

// DELETE
export const deleteNote = async (id, session) => {
  let userId = session?.user.id;
  const deletedNote = await prisma.note.delete({
    where: {
      id_userId: {
        id,
        userId,
      },
    },
  });
  return deletedNote;
};

Затем создайте обработчик удаления в нашем файле ./pages/api/note.js.

// pages/api/note.js

export default async function handle(req, res) {
  if (req.method == "POST") {
    // ... Create a new note
  }
  else if (req.method == "PUT") {
    // ... Update current note
  }
  // Run if the request is a DELETE request
  else if (req.method == "DELETE") {
    const { id } = req.body;
    const note = await deleteNote(id, session);
    // return deleted note
    return res.json(note);
  }
}

И, наконец, в нашем компоненте ./components/NoteList.js мы отправим запрос DELETE в нашей функции deleteNote.

// ./components/NoteList.js

const NotesList = ({ retrieved_notes, showEditor }) => {
  // ...
  // function to delete note by using the setNotes Dispatch notes function
  const deleteNote = async (note) => {
    let confirmDelete = confirm("Do you really want to delete this note?");
    try {
      let res = await fetch(`/api/note`, {
        method: "DELETE",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(note),
      });
      const deletedNote = await res.json();
      confirmDelete ? setNotes({ note: deletedNote, type: "remove" }) : null;
    } catch (error) {
      console.log(error);
    }
  };

// ...

}

Давайте посмотрим на это в действии:

Потрясающий! Наконец, мы можем создавать динамические маршруты для отображения отдельных заметок. Создать новую динамическую страницу ./pages/note/[id].js

// ./pages/note/[id].js

import Head from "next/head";
import Image from "next/image";
import { getSession } from "next-auth/react";
const getNoteByID = require("../../prisma/Note").getNoteByID;
import HomeStyles from "../../styles/Home.module.css";
export const getServerSideProps = async ({ req, res, params }) => {
  const session = await getSession({ req });
  console.log({ params });
  const { id } = params;
  if (!session) {
    res.statusCode = 403;
    return { props: { note: null } };
  }
  const note = await getNoteByID(id);

  return {
    props: { note },
  };
};
const Note = ({ note }) => {
  if (note == null) {
    return (
      <>
        <Head>
          <title>Login to view note</title>
          <meta name="description" content="Login to view this note" />
          <link rel="icon" href="/favicon.ico" />
        </Head>
        <div className={HomeStyles.container}>
          <main className={HomeStyles.main}>
            <header className="max-w-4xl mt-24 mx-auto">
              <h1 className="text-4xl">Oops... You have to login to view this note</h1>
            </header>
          </main>
        </div>
      </>
    );
  }
  return (
    <>
      <Head>
        <title>{note.title}</title>
        <meta name="description" content={`By ${note.user.name}`} />
        <link rel="icon" href="/favicon.ico" />
      </Head>
      <div className={HomeStyles.container}>
        <main className={HomeStyles.main}>
          <article className="note max-w-4xl m-auto mt-20">
            <header className="note-header">
              <h2 className="text-4xl">{note.title}</h2>
            </header>
            <main className=" px-4">
              <p className="text-xl">{note.body}</p>
            </main>
            <footer className="note-footer">
              <ul className="options px-4">
                <li className="option">
                  {/* add user image to note footer */}
                  <Image src={note.user.image} alt={note.user.name} width={48} height={48} className="rounded-full" />
                </li>
              </ul>
            </footer>
          </article>
        </main>
      </div>
    </>
  );
};
export default Note;

Далее, в компоненте ./components/NoteList.js, мы будем использовать компонент Next.js <Link>, чтобы обернуть нашу кнопку «открыть», чтобы маршрутизировать каждую страницу заметки по ее id.

// ./components/NoteList.js

//...
<li className="option">
  <Link href={`/note/${note.id}`} target={`_blank`} rel={`noopener`}>
    <button className="cta cta-w-icon">
      <ExternalLinkIcon className="icon" />
      <span className="">Open</span>
    </button>
  </Link>
</li>

another word
//...

Теперь, если мы нажмем кнопку «Открыть», мы попадем на страницу заметки.

Были сделаны! Потрясающий! Вы также можете увидеть результат, размещенный на Netlify и код в ветке basic-crud репозитория проекта на GitHub.

Заключение

Общеизвестно, что Next.js и Prisma отлично подходят для создания быстрых полнофункциональных приложений. В этом руководстве мы создали приложение для заметок, в котором пользователи могут входить в приложение благодаря NextAuth, создавать, просматривать, редактировать и удалять заметки. Вся пользовательская информация и заметки сохраняются в базе данных MongoDB. Для взаимодействия с базой данных из приложения Next.js мы использовали ORM Prisma, который помогает нам определять нашу схему, а также создавать базу данных и подключаться к ней, чтобы мы могли ею управлять.

Дополнительная литература и ресурсы

Вот несколько отличных ссылок и ресурсов, которые помогут вам изучить Next.js и Prisma.

Код и размещенные примеры для этого проекта

Документация и статьи

Первоначально опубликовано на blog.openreplay.com 22 июня 2022 г.