Pull to refresh
1989.5
Timeweb Cloud
То самое облако

Дело было вечером или Создаем веб-приложение за 5 часов

Level of difficultyMedium
Reading time10 min
Views11K



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


В этой небольшой заметке я хочу рассказать вам о том, как я разработал игру с вопросами по JavaScript за один вечер, потому что, во-первых, мне было скучно :D, во-вторых, мне стало интересно, как быстро я смогу "запилить" подобный MVP.


Вот что мы имеем на сегодняшний день.


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


Приложение представляет собой классическое SPA и состоит из двух страниц:


  1. Экран приветствия или список вопросов.
  2. Таблица с рекордами.

В приложении реализован механизм аутентификации/авторизации по email или аккаунтам Google/GitHub. Авторизованный пользователь может записать свой результат в базу данных, когда его результат лучше худшего рекорда.


Есть БД PostgreSQL для хранения рекордов (лучших результатов) в количестве 100 штук.


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


❯ Создание и настройка проекта


Создаем шаблон React + TypeScript приложения с помощью Vite:


npm create vite@latest javascript-questions -- --template react-ts

Устанавливаем дополнительные зависимости:


npm i @mui/material @mui/icons-material @mui/x-date-pickers @emotion/react @emotion/styled @fontsource/roboto material-react-table react-router-dom react-syntax-highlighter react-toastify react-use
npm i -D @types/react-syntax-highlighter

  • @mui..., @emotion... и @fontsource/roboto нужны для MUI — библиотеки компонентов UI
  • material-react-table — библиотека для работы с таблицами TanStack Table на основе компонентов MUI
  • react-router-dom — библиотека клиентской маршрутизации
  • react-syntax-highlighter — компонент для подсветки синтаксиса
  • react-toastify — компонент для уведомлений
  • react-use — кастомные хуки

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


Идем на платформу управления пользователями Clerk и создаем там проект. Находим Publishable key в разделе API Keys и создаем в корне проекта файл .env следующего содержания:


VITE_CLERK_PUBLISHABLE_KEY=pk_test_...

Устанавливаем два пакета:


npm i @clerk/clerk-react @clerk/localizations

Оборачиваем корневой компонент приложения в провайдер:


import { ClerkProvider } from '@clerk/clerk-react'
// Локализация неполная, к сожалению
import { ruRU } from '@clerk/localizations'

const PUBLISHABLE_KEY = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY

if (!PUBLISHABLE_KEY) {
  throw new Error('Отсутствует ключ Clerk')
}

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <ClerkProvider publishableKey={PUBLISHABLE_KEY} localization={ruRU}>
      <App />
    </ClerkProvider>
  </React.StrictMode>,
)

И рендерим в шапке сайта соответствующие компоненты:


import {
  SignedIn,
  SignedOut,
  SignInButton,
  UserButton,
} from '@clerk/clerk-react'
import { Button } from '@mui/material'

export default function Nav() {
  return (
    <>
      <SignedOut>
        <SignInButton>
          <Button variant='contained' color='success'>
            Войти
          </Button>
        </SignInButton>
      </SignedOut>
      <SignedIn>
        <UserButton />
      </SignedIn>
    </>
  )
}

Верите или нет, но это все, что нужно для реализации полноценного механизма аутентификации/авторизации (magic! :D).


❯ База данных


Идем на платформу BaaS Supabase и создаем там проект. Идем в раздел Project Settings, затем в раздел API, находим там Project URL и anon public key в Project API keys и добавляем их в .env:


VITE_SUPABASE_URL=https://....supabase.co
VITE_SUPABASE_ANON_KEY=eyJ...

Идем в раздел Table Editor и создаем такую таблицу results:


create table
  public.results (
    id uuid not null default gen_random_uuid (),
    created_at timestamp with time zone not null default now(),
    user_id text not null,
    user_name text not null,
    question_count bigint not null,
    correct_answer_percent bigint not null,
    correct_answer_count bigint not null,
    constraint results_pkey primary key (id)
  ) tablespace pg_default;

Я создавал эту таблицу с помощью графического интерфейса.





Обратите внимание: для таблицы должна быть отключена безопасность на уровне строк (значок RLS disabled).


Устанавливаем пакет:


npm i @supabase/supabase-js

Инициализируем и экспортируем клиента:


import { createClient } from '@supabase/supabase-js'

const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL
const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY

if (!SUPABASE_URL || !SUPABASE_ANON_KEY) {
  throw new Error('Отсутствует URL или ключ Supabase')
}

export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY)

Верите или нет, но это все, что нужно для создания и настройки Postres (magic! :D).


Обратите внимание: Supabase предоставляет собственный механизм аутентификации/авторизации, но Clerk мне больше нравится.


В качестве альтернативы можно рассмотреть такие варианты БД:


  • Vercel Postgres (пробовал, понравилось, но без Prisma работать с базой не очень удобно, а для prisma нужен сервер)
  • Convex (не пробовал, но знаю, что в тренде, планирую потестить в ближайшее время)

❯ Вопросы


Честное слово, я не хотел прибегать к помощи ИИ, но пришлось :D У меня был файл с вопросами в количестве 231 штуки в формате Markdown следующего содержания:


## ❯ Вопрос № 1

\`\`\`javascript
function sayHi() {
  console.log(name)
  console.log(age)
  var name = "John"
  let age = 30
}

sayHi()
\`\`\`

- A: `John` и `undefined`
- B: `John` и `Error`
- C: `Error`
- D: `undefined` и `Error`

<details>
<summary>Ответ</summary>
<div>
<h4>Правильный ответ: D</h4>

В функции `sayHi` мы сначала определяем переменную `name` с помощью ключевого слова `var`. Это означает, что `name` поднимается в начало функции. `name` будет иметь значение `undefined` до тех пор, пока выполнение кода не дойдет до строки, где ей присваивается значение `John`. Мы еще не определили значение `name`, когда пытаемся вывести ее значение в консоль, поэтому получаем `undefined`. Переменные, объявленные с помощью ключевых слов `let` и `const`, также поднимаются в начало области видимости, но в отличие от переменных, объявленных с помощью `var`, не инициализируются, т.е. такие переменные поднимаются без значения. Доступ к ним до инициализации невозможен. Это называется `временной мертвой зоной`. Когда мы пытаемся обратиться к переменным до их определения, `JavaScript` выбрасывает исключение `ReferenceError`.

</div>
</details>
...

Кстати, все вопросы, а также много другого интересного и полезного контента можно найти на моем сайте.


Мне нужно было преобразовать этот текст в такой массив объектов:


export default [
  {
    question:
      'function sayHi() {\n  console.log(name)\n  console.log(age)\n  var name = "John"\n  let age = 30\n}\n\nsayHi()',
    answers: ['John и undefined', 'John и Error', 'Error', 'undefined и Error'],
    correctAnswerIndex: 3,
    explanation:
      'В функции `sayHi` мы сначала определяем переменную `name` с помощью ключевого слова `var`. Это означает, что `name` поднимается в начало функции. `name` будет иметь значение `undefined` до тех пор, пока выполнение кода не дойдет до строки, где ей присваивается значение `John`. Мы еще не определили значение `name`, когда пытаемся вывести ее значение в консоль, поэтому получаем `undefined`. Переменные, объявленные с помощью ключевых слов `let` и `const`, также поднимаются в начало области видимости, но в отличие от переменных, объявленных с помощью `var`, не инициализируются, т.е. такие переменные поднимаются без значения. Доступ к ним до инициализации невозможен. Это называется `временной мертвой зоной`. Когда мы пытаемся обратиться к переменным до их определения, `JavaScript` выбрасывает исключение `ReferenceError`.',
  },
  ...
]

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





Доступ к ChatGPT из России я получил так: купил сервер в Нидерландах и развернул там VPN по инструкции из этой замечательной статьи. Затем нашел эту замечательную статью, откуда перешел на этот замечательный сайт и купил там нидерландский номер телефона (рублей за 50, если мне память не изменяет), на который пришел код подтверждения от OpenAI (ваша локация должна совпадать с "родиной" номера телефона, если я правильно понял схему валидации OpenAI).

Итак, я скормил ChatGPT файл с вопросами и составил примерно такой запрос: "Многоуважаемый ИИ, не соблаговолите ли вы проанализировать этот документ и преобразовать вопросы в такие объекты:… Буду очень признателен, если результат вы оформите в виде файла JavaScript" :D


Подумав минуту, ChatGPT сгенерировал почти идеальный JS-файл, содержащий все вопросы в виде массива объектов (некоторые вопросы слиплись, на редактирование файла ушло около часа).


❯ Деплой


Для деплоя своих приложений я использую либо Netlify (для SPA), либо Vercel (для приложений, разработанных с помощью Next.js). Для деплоя на Netlify я использую Netlify CLI:


# Устанавливаем пакет глобально
npm i -g netlify-cli

# Авторизуемся (разумеется, у вас должен быть аккаунт)
netlify login

# Подключаем проект (репозиторий должен находится в GitHub)
netlify init

Верите или нет, но это все, что нужно для деплоя приложения и повторной сборки приложения при отправке изменений в репозиторий с помощью git push (continuos deployment во всей красе :D)


Пожалуй, это все, чем я хотел поделиться с вами в этой заметке.


Из ближайших планов:


  • расширить функционал (есть парочка идей)
  • сделать PWA (есть плагин, который пока не хочет работать)
  • сделать мобильное приложение (скорее всего, будет только Android) с помощью React Native и Expo
  • возможно, сделать десктопное приложение с помощью Electron или Tauri

Буду рад любым замечаниям и предложениям. Happy coding!


Дополнение от 22.09


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


Я попробовал решить эту проблему за счет интеграции Clerk с Supabase по этому руководству, но у меня ничего не вышло из-за постоянно возникавшей ошибки разбора токена доступа. К тому же, интерфейс работы с БД стал очень неудобным. Поэтому я решил переехать на Convex. Ниже я расскажу, как я это сделал.


Устанавливаем Convex:


npm i convex

Создаем в корне проекта файл convex.json с настройками Convex следующего содержания:


{
  "functions": "src/convex/"
}

Запускаем Convex в режиме разработки:


npx convex dev

Определяем схему БД в файле convex/schema.ts:


import { defineSchema, defineTable } from 'convex/server'
import { v } from 'convex/values'

export default defineSchema({
  results: defineTable({
    userId: v.string(),
    userName: v.string(),
    questionCount: v.number(),
    correctAnswerCount: v.number(),
    correctAnswerPercent: v.number(),
  }),
})

Определяем методы для работы с БД в файле convex/results.ts:


import { ConvexError, v } from 'convex/values'
import { mutation, query } from './_generated/server'

export const get = query({
  args: {},
  handler: async (ctx) => {
    const results = await ctx.db.query('results').collect()
    return results.sort((a, b) => b.correctAnswerCount - a.correctAnswerCount)
  },
})

export const create = mutation({
  args: {
    userName: v.string(),
    questionCount: v.number(),
    correctAnswerCount: v.number(),
    correctAnswerPercent: v.number(),
  },
  handler: async (ctx, args) => {
    const identity = await ctx.auth.getUserIdentity()
    if (identity === null) {
      throw new ConvexError('Unauthorized')
    }

    return await ctx.db.insert('results', {
      userId: identity.tokenIdentifier,
      userName: args.userName,
      questionCount: args.questionCount,
      correctAnswerCount: args.correctAnswerCount,
      correctAnswerPercent: args.correctAnswerPercent,
    })
  },
})

export const update = mutation({
  args: {
    id: v.id('results'),
    userName: v.string(),
    questionCount: v.number(),
    correctAnswerCount: v.number(),
    correctAnswerPercent: v.number(),
  },
  handler: async (ctx, args) => {
    const identity = await ctx.auth.getUserIdentity()
    if (identity === null) {
      throw new ConvexError('Unauthorized')
    }

    const result = await ctx.db.get(args.id)
    if (!result) {
      throw new ConvexError('Result not found')
    }

    await ctx.db.patch(args.id, {
      userId: identity.tokenIdentifier,
      userName: args.userName,
      questionCount: args.questionCount,
      correctAnswerCount: args.correctAnswerCount,
      correctAnswerPercent: args.correctAnswerPercent,
    })

    return args.id
  },
})

Хук для получения результатов:


// hooks/useGetResults.ts
import { useQuery } from 'convex/react'
import { api } from '../convex/_generated/api'

export const useGetResults = () => {
  const data = useQuery(api.results.get)
  const isLoading = data === undefined
  return { data: data || [], isLoading }
}

Хук для создания результата (сигнатура хука вдохновлена TTanStack Query):


// hooks/useCreateResult.ts
import { useMutation } from 'convex/react'
import { useCallback, useState } from 'react'
import { Id } from '../convex/_generated/dataModel'
import { api } from '../convex/_generated/api'

type RequestT = {
  userName: string
  questionCount: number
  correctAnswerCount: number
  correctAnswerPercent: number
}
type ResponseT = Id<'results'> | null

type Options = {
  onSuccess?: (data: ResponseT) => void
  onError?: (e: Error) => void
  onSettled?: () => void
  throwError?: boolean
}

export const useCreateResult = () => {
  const [data, setData] = useState<ResponseT>(null)
  const [error, setError] = useState<Error | null>(null)
  const [status, setStatus] = useState<
    'success' | 'error' | 'settled' | 'pending' | null
  >(null)

  const isSuccess = status === 'success'
  const isError = status === 'error'
  const isSettled = status === 'settled'
  const isPending = status === 'pending'

  const mutation = useMutation(api.results.create)

  const mutate = useCallback(
    async (values: RequestT, options?: Options) => {
      setData(null)
      setError(null)
      setStatus('pending')
      try {
        const response = await mutation(values)
        setData(response)
        setStatus('success')
        options?.onSuccess?.(response)
        return response
      } catch (e) {
        setError(e as Error)
        setStatus('error')
        options?.onError?.(e as Error)
        if (options?.throwError) {
          throw e
        }
      } finally {
        setStatus('settled')
        options?.onSettled?.()
      }
    },
    [mutation],
  )

  return { mutate, data, error, isPending, isSuccess, isError, isSettled }
}

Далее необходимо интегрировать Clerk с Convex.


Идем в панель управления Clerk. Переходим в раздел JWT Templates, нажимаем New template и выбираем Convex.





На странице созданного шаблона JWT копируем значение поля Issuer.





Идем в панель управления Convex. Переходим в раздел Settings -> Environment Variables и создаем там переменную среды CLERK_JWT_ISSUER_DOMAIN со значением скопированного Issuer.





Создаем файл convex/auth.config.ts следующего содержания:


export default {
  providers: [
    {
      domain: process.env.CLERK_JWT_ISSUER_DOMAIN,
      applicationID: 'convex',
    },
  ],
}

Создаем клиента и добавляем провайдера Convex в src/main.tsx:


import { ClerkProvider, useAuth } from '@clerk/clerk-react'
import { ruRU } from '@clerk/localizations'
import { ConvexReactClient } from 'convex/react'
import { ConvexProviderWithClerk } from 'convex/react-clerk'
// ...

const VITE_CONVEX_URL = import.meta.env.VITE_CONVEX_URL

if (!VITE_CONVEX_URL) {
  throw new Error('Отсутствует URL Convex')
}

const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL)

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <ClerkProvider publishableKey={PUBLISHABLE_KEY} localization={ruRU}>
      <ConvexProviderWithClerk client={convex} useAuth={useAuth}>
        <BrowserRouter>
          <App />
        </BrowserRouter>
      </ConvexProviderWithClerk>
    </ClerkProvider>
  </React.StrictMode>,
)

При деплое приложения на Netlify нужно еще сделать 2 вещи для Convex.


Перезаписываем команду для сборки приложения в файле netlify.toml или в панели управления Netlify:


npx convex deploy --cmd 'npm run build'

Идем в панель управления Convex. Переходим в раздел Settings -> URL & Deploy Key и создаем ключ деплоя в Deploy Keys.


Идем в панель управления Netlify и добавляем переменную среды CONVEX_DEPLOY_KEY:





Деплоим приложение и радуемся :D


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




Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале

Tags:
Hubs:
Total votes 19: ↑17 and ↓2+26
Comments6

Articles

Information

Website
timeweb.cloud
Registered
Founded
Employees
201–500 employees
Location
Россия
Representative
Timeweb Cloud