
Привет, друзья!
В этой небольшой заметке я хочу рассказать вам о том, как я разработал игру с вопросами по JavaScript за один вечер, потому что, во-первых, мне было скучно :D, во-вторых, мне стало интересно, как быстро я смогу "запилить" подобный MVP.
Вот что мы имеем на сегодняшний день.
Интересно? Тогда прошу под кат.
Приложение представляет собой классическое SPA и состоит из двух страниц:
- Экран приветствия или список вопросов.
- Таблица с рекордами.
В приложении реализован механизм аутентификации/авторизации по 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-канале ↩

