Создание полностекового приложения с помощью create-t3-app

Привет! Сегодня мы будем создавать приложение с помощью стека T3. Мы собираемся создать гостевую книгу, вдохновленную гостевой книгой Ли Робинсона. Давайте приступим к работе!

Начало работы

Давайте создадим начальный проект с помощью команды create-t3-app!

npx create-t3-app@latest
Вход в полноэкранный режим Выход из полноэкранного режима

Мы будем использовать все части стека.

Давайте также настроим базу данных Postgres на Railway. Railway позволяет очень просто и быстро настроить базу данных.

Перейдите на Railway и войдите на GitHub, если вы еще этого не сделали. Теперь нажмите на New Project.

Теперь укажите Postgres.

Это очень просто. Скопируйте строку подключения из вкладки Connect.

Давайте начнем кодировать! Откройте проект в вашем любимом редакторе кода.

В нем много папок, но не перегружайтесь. Вот основной обзор.

Откройте файл .env и вставьте строку подключения в DATABASE_URL.

Вы заметите, что у нас настроен Discord OAuth с помощью next-auth, поэтому нам также нужны DISCORD_CLIENT_ID и DISCORD_CLIENT_SECRET. Давайте настроим это.

Настройка аутентификации

Перейдите на портал разработчиков Discord и создайте новое приложение.

Перейдите в раздел OAuth2/General и добавьте все URL обратного вызова в Redirects. Для localhost URL обратного вызова будет http://localhost:3000/api/auth/callback/discord. Я также добавил производственный URL заранее.

Скопируйте ID и секрет клиента и вставьте их в .env.

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

Давайте также изменим базу данных на postgresql и откомментируем аннотации @db.Text в модели Account в prisma/schema.prisma. Все модели, которые вы видите в схеме, необходимы для работы Next Auth.

Давайте протолкнем эту схему в нашу базу данных Railway Postgres. Эта команда перенесет нашу схему в Railway и сгенерирует определения типов для клиента Prisma.

npx prisma db push
Вход в полноэкранный режим Выход из полноэкранного режима

Теперь запустите сервер разработчиков.

npm run dev
Войдите в полноэкранный режим Выйдите из полноэкранного режима

Перейдите в src/pages/index.tsx и удалите весь код, давайте просто отрендерим заголовок.

// src/pages/index.tsx

const Home = () => {
  return (
    <main>
      <h1>Guestbook</h1>
    </main>
  );
};

export default Home;
Вход в полноэкранный режим Выйдите из полноэкранного режима

Я не могу смотреть на светлую тему, поэтому применим некоторые глобальные стили в src/styles/globals.css, чтобы сделать это приложение темной темой.

// src/styles/globals.css

@tailwind base;
@tailwind components;
@tailwind utilities;

body {
  @apply bg-neutral-900 text-neutral-100
}
Вход в полноэкранный режим Выход из полноэкранного режима

Намного лучше.

Если вы посмотрите на src/pages/api/auth/[...nextauth].ts, вы увидите, что у нас уже настроен Discord OAuth с помощью Next Auth. Здесь вы можете добавить другие OAuth-провайдеры, такие как Google, Twitter и т.д.

Теперь давайте создадим кнопку, которая позволит пользователям войти в систему с помощью Discord. Мы можем использовать функцию signIn() из Next Auth.

// src/pages/index.tsx

import { signIn } from "next-auth/react";

const Home = () => {
  return (
    <main>
      <h1>Guestbook</h1>

      <button onClick={() => signIn("discord")}>Login with Discord</button>
    </main>
  );
};

export default Home;
Вход в полноэкранный режим Выйти из полноэкранного режима

Мы можем использовать хук useSession(), чтобы получить сессию для пользователя. В процессе работы мы также можем использовать функцию signOut() для реализации функции выхода из системы.

// src/pages/index.tsx

import { signIn, signOut, useSession } from "next-auth/react";

const Home = () => {
  const { data: session, status } = useSession();

  if (status === "loading") {
    return <main>Loading...</main>;
  }

  return (
    <main>
      <h1>Guestbook</h1>
      {session ? (
        <div>
          <p>
            hi {session.user?.name}
          </p>

          <button onClick={() => signOut()}>Logout</button>
        </div>
      ) : (
        <div>
          <button onClick={() => signIn("discord")}>Login with Discord</button>
        </div>
      )}
    </main>
  );
};

export default Home;
Вход в полноэкранный режим Выход из полноэкранного режима

Отлично! Теперь у нас работает аутентификация. Next Auth действительно делает все до глупости просто.

Бэкенд

Давайте теперь поработаем над бэкендом. Мы будем использовать tRPC для нашего уровня API и Prisma для подключения и запросов к базе данных.

Нам придется изменить схему Prisma и добавить модель Guestbook. Каждое сообщение в гостевой книге будет иметь имя и сообщение. Вот как будет выглядеть модель.

// prisma/schema.prisma

model Guestbook {
    id        String   @id @default(cuid())
    createdAt DateTime @default(now())
    name      String
    message   String   @db.VarChar(100)
}
Вход в полноэкранный режим Выход из полноэкранного режима

Давайте перенесем эту модифицированную модель в нашу базу данных Railway Postgres.

npx prisma db push
Войти в полноэкранный режим Выход из полноэкранного режима

Теперь перейдем к самой интересной части — времени tRPC. Идем дальше и удаляем protected-example-router.ts и protected-router.ts и example.ts в src/server/router. Сначала мы определим мутацию для отправки сообщений в нашу базу данных.

// src/server/router/guestbook.ts

import { z } from "zod";
import { createRouter } from "./context";

export const guestbookRouter = createRouter().mutation("postMessage", {
  input: z.object({
    name: z.string(),
    message: z.string(),
  }),
  async resolve({ ctx, input }) {
    try {
      await ctx.prisma.guestbook.create({
        data: {
          name: input.name,
          message: input.message,
        },
      });
    } catch (error) {
      console.log(error);
    }
  },
});
Вход в полноэкранный режим Выход из полноэкранного режима

Здесь мы имеем мутацию tRPC, которая использует zod для проверки ввода и имеет функцию resolve, которая выполняет простой запрос prisma для создания новой строки в таблице Guestbook.

Работа с prisma — это совершенно замечательный пример. Автозаполнение и безопасность типов просто потрясающие.

Мы также хотим, чтобы эта мутация была защищена. Здесь мы можем использовать промежуточные модули tRPC. Если вы посмотрите на src/server/context.ts, мы используем [unstable_getServerSession] из Next Auth, который дает нам доступ к сессии на сервере. Мы передаем ее в наш контекст tRPC. Мы можем использовать эту сессию, чтобы сделать нашу мутацию защищенной.

// src/server/router/guestbook.ts

export const guestbookRouter = createRouter()
  .middleware(async ({ ctx, next }) => {
    // Any queries or mutations after this middleware will
    // raise an error unless there is a current session
    if (!ctx.session) {
      throw new TRPCError({ code: "UNAUTHORIZED" });
    }
    return next();
  })
  .mutation("postMessage", {
    // ...
Вход в полноэкранный режим Выход из полноэкранного режима

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

// src/server/router/guestbook.ts

export const guestbookRouter = createRouter()
  .query("getAll", {
    async resolve({ ctx }) {
      try {
        return await ctx.prisma.guestbook.findMany({
          select: {
            name: true,
            message: true,
          },
          orderBy: {
            createdAt: "desc",
          },
        });
      } catch (error) {
        console.log("error", error);
      }
    },
  })
  .middleware(async ({ ctx, next }) => {
    // ...
Вход в полноэкранный режим Выход из полноэкранного режима

Здесь мы получаем только имя и сообщение из всех строк модели Guestbook и сортируем их в порядке убывания по полю createdAt.

Теперь объедините этот маршрутизатор в основной appRouter.

// src/server/router/index.ts

import superjson from "superjson";
import { createRouter } from "./context";
import { guestbookRouter } from "./guestbook";

export const appRouter = createRouter()
  .transformer(superjson)
  .merge("guestbook.", guestbookRouter);

// export type definition of API
export type AppRouter = typeof appRouter;
Вход в полноэкранный режим Выход из полноэкранного режима

На этом мы практически закончили с бэкендом. Теперь давайте поработаем над пользовательским интерфейсом.

Фронтенд

Давайте сначала выровняем все по центру.

// src/pages/index.tsx

import { signIn, signOut, useSession } from "next-auth/react";

const Home = () => {
  const { data: session, status } = useSession();

  if (status === "loading") {
    return <main className="flex flex-col items-center pt-4">Loading...</main>;
  }

  return (
    <main className="flex flex-col items-center">
      <h1 className="text-3xl pt-4">Guestbook</h1>
      <p>
        Tutorial for <code>create-t3-app</code>
      </p>

      <div className="pt-10">
        {session ? (
          <div>
            <p>hi {session.user?.name}</p>

            <button onClick={() => signOut()}>Logout</button>
          </div>
        ) : (
          <div>
            <button onClick={() => signIn("discord")}>
              Login with Discord
            </button>
          </div>
        )}
      </div>
    </main>
  );
};

export default Home;
Вход в полноэкранный режим Выход из полноэкранного режима

Я также сделал заголовок больше и добавил немного прокладок между элементами.

Давайте используем наш tRPC-запрос, чтобы получить все сообщения для гостевой книги в базе данных. Но сейчас у нас нет никаких данных. Мы можем использовать Prisma Studio для получения некоторых данных вручную.

npx prisma studio
Вход в полноэкранный режим Выйти из полноэкранного режима

Он автоматически откроется на http://localhost:5555. Перейдите к таблице Guestbook и добавьте кучу записей, как здесь.

Теперь, когда у нас есть данные, мы можем использовать запрос и отобразить данные. Для этого мы можем использовать обертку tRPC react-query. Давайте создадим для этого компонент в src/pages/index.tsx.

// src/pages/index.tsx

import { trpc } from "../utils/trpc";

const Messages = () => {
  const { data: messages, isLoading } = trpc.useQuery(["guestbook.getAll"]);

  if (isLoading) return <div>Fetching messages...</div>;

  return (
    <div className="flex flex-col gap-4">
      {messages?.map((msg, index) => {
        return (
          <div key={index}>
            <p>{msg.message}</p>
            <span>- {msg.name}</span>
          </div>
        );
      })}
    </div>
  );
};
Вход в полноэкранный режим Выход из полноэкранного режима

Здесь мы используем useQuery() и отображаем массив, который он возвращает.

Конечно, здесь также есть замечательная защита типов и автозаполнение.

Теперь отобразите этот компонент в компоненте Home.

// src/pages/index.tsx

 <main className="flex flex-col items-center">
    <h1 className="text-3xl pt-4">Guestbook</h1>
    <p>
      Tutorial for <code>create-t3-app</code>
    </p>

    <div className="pt-10">
      {session ? (
        <div>
          <p>hi {session.user?.name}</p>

          <button onClick={() => signOut()}>Logout</button>

          <div className="pt-10">
            <Messages />
          </div>
        </div>
      ) : (
        <div>
          <button onClick={() => signIn("discord")}>
            Login with Discord
          </button>

          <div className="pt-10" />
          <Messages />
        </div>
      )}
    </div>
  </main>
Вход в полноэкранный режим Выход из полноэкранного режима

Теперь давайте создадим форму и используем в ней нашу мутацию tRPC.

// src/pages/index.tsx

const Home = () => {
  const { data: session, status } = useSession();
  const [message, setMessage] = useState("");
  const postMessage = trpc.useMutation("guestbook.postMessage");

  if (status === "loading") {
    return <main className="flex flex-col items-center pt-4">Loading...</main>;
  }

  return (
    <main className="flex flex-col items-center">
      <h1 className="text-3xl pt-4">Guestbook</h1>
      <p>
        Tutorial for <code>create-t3-app</code>
      </p>

      <div className="pt-10">
        {session ? (
          <div>
            <p>hi {session.user?.name}</p>

            <button onClick={() => signOut()}>Logout</button>

            <div className="pt-6">
              <form
                className="flex gap-2"
                onSubmit={(event) => {
                  event.preventDefault();

                  postMessage.mutate({
                    name: session.user?.name as string,
                    message,
                  });

                  setMessage("");
                }}
              >
                <input
                  type="text"
                  value={message}
                  placeholder="Your message..."
                  maxLength={100}
                  onChange={(event) => setMessage(event.target.value)}
                  className="px-4 py-2 rounded-md border-2 border-zinc-800 bg-neutral-900 focus:outline-none"
                />
                <button
                  type="submit"
                  className="p-2 rounded-md border-2 border-zinc-800 focus:outline-none"
                >
                  Submit
                </button>
              </form>
            </div>

            <div className="pt-10">
              <Messages />
            </div>
          </div>
        ) : (
          <div>
            <button onClick={() => signIn("discord")}>
              Login with Discord
            </button>

            <div className="pt-10" />
            <Messages />
          </div>
        )}
      </div>
    </main>
  );
};
Вход в полноэкранный режим Выход из полноэкранного режима

Здесь у нас есть форма, и мы используем useMutation() для отправки данных в базу данных. Но вы заметите одну проблему. Когда мы нажимаем на кнопку submit, она действительно отправляет сообщение в базу данных, но пользователь не получает никакого немедленного отклика. Только при обновлении страницы пользователь может увидеть новое сообщение.

Для этого мы можем использовать оптимистичное обновление пользовательского интерфейса! react-query позволяет сделать это очень просто. Нам просто нужно добавить кое-что в наш хук useMutation().

// src/pages/index.tsx

const ctx = trpc.useContext();
const postMessage = trpc.useMutation("guestbook.postMessage", {
  onMutate: () => {
    ctx.cancelQuery(["guestbook.getAll"]);

    let optimisticUpdate = ctx.getQueryData(["guestbook.getAll"]);
    if (optimisticUpdate) {
      ctx.setQueryData(["guestbook.getAll"], optimisticUpdate);
    }
  },
  onSettled: () => {
    ctx.invalidateQueries(["guestbook.getAll"]);
  },
});
Вход в полноэкранный режим Выход из полноэкранного режима

Код практически не требует пояснений. Вы можете прочитать больше об оптимистичных обновлениях с помощью react-query здесь.

Мы практически закончили с частью кодирования! Это было довольно просто, не так ли? Стек T3 позволяет очень легко и быстро создавать веб-приложения с полным стеком. Теперь давайте развернем нашу гостевую книгу.

Развертывание

Для развертывания мы будем использовать Vercel. Vercel позволяет очень легко развертывать приложения NextJS, именно они создали NextJS.

Сначала разместите свой код в репозитории GitHub. Перейдите в Vercel и зарегистрируйтесь на GitHub, если вы еще этого не сделали.

Затем нажмите на New Project и импортируйте только что созданный репозиторий.

Теперь нам нужно добавить переменные окружения, поэтому скопируйте и вставьте все переменные окружения в Vercel. После того как вы это сделаете, нажмите Deploy.

Добавьте пользовательский домен, если он у вас есть, и все готово! Поздравляем!

Весь код можно найти здесь. Вы можете посетить сайт по адресу guestbook.nxl.sh.

Кредиты

  • Ли Робинсону за идею гостевой книги.
  • JAR и Krish за вычитку.

Оцените статью
devanswers.ru
Добавить комментарий