Руководство по Supabase. Часть 2

от автора

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

В этом цикле из 2 статей я хочу рассказать вам о Supabase — открытой (open source), т.е. бесплатной альтернативе Firebase. В первой статье мы рассмотрели теорию, в этой — разработаем полноценное social app с аутентификацией, базой данных, хранилищем файлов и обработкой изменения данных в режиме реального времени.

Репозиторий с исходным кодом проекта.

Если вам это интересно, прошу под кат.

Что такое Supabase?

Supabase, как и Firebase — это SaaS (software as a service — программное обеспечение как услуга) или BaaS (backend as a service — бэкенд как услуга). Что это означает? Это означает, что в случае с fullstack app мы разрабатываем только клиентскую часть, а все остальное предоставляется Supabase через пользовательские комплекты для разработки программного обеспечения (SDK) и интерфейсы прикладного программирования (API). Под «всем остальным» подразумевается сервис аутентификации (включая возможность использования сторонних провайдеров), база данных (PostgreSQL), файловое хранилище, realtime (обработка изменений данных в режиме реального времени) и сервер, который все это обслуживает.

Подготовка и настройка проекта

Обратите внимание: для разработки клиента я буду использовать React, но вы можете использовать свой любимый JS-фреймворк — функционал, связанный с Supabase, будет что называется framework agnostic. Также обратите внимание, что «полноценное social app» не означает, что разработанное нами приложение будет production ready, однако, по-возможности, я постараюсь акцентировать ваше внимание на необходимых доработках.

Вы готовы? Тогда вперед!

Создаем шаблон проекта с помощью Vite:

# supabase-social-app - название приложения # --template react - используемый шаблон yarn create vite supabase-social-app --template react

Регистрируемся или авторизуемся на supabase.com и создаем новый проект:

Копируем ключ и URL на главной странице панели управления проектом:

Записываем их в переменные среды окружения. Для этого создаем в корневой директории проекта (supabase-social-app) файл .env следующего содержания:

VITE_SUPABASE_URL=https://your-url.supabase.co VITE_SUPABASE_KEY=your_key

Обратите внимание: префикс VITE_ в данном случае является обязательным.

На странице Authentication панели управления в разделе Settings отключаем необходимость подтверждения адреса электронной почты новым пользователем (Enable email confirmation):

Обратите внимание: при разработке нашего приложения мы пропустим шаг подтверждения пользователями своего email после регистрации в целях экономии времени. В реальном приложении в объекте user будет содержаться поле isEmailConfirmed, например — индикатор того, подтвердил ли пользователь свой email. Значение данного поля будет определять логику работы приложения в части авторизации.

В базе данных нам потребуется 3 таблицы:

  • users — пользователи;
  • posts — посты пользователей;
  • comments — комментарии к постам.

Supabase предоставляет графический интерфейс для работы с таблицами на странице Table Editor:

Но мы воспользуемся редактором SQL на странице SQL Editor (потому что не ищем легких путей)). Создаем новый запрос (New query) и вставляем такой SQL:

CREATE TABLE users (   id text PRIMARY KEY NOT NULL,   email text NOT NULL,   user_name text NOT NULL,   first_name text,   last_name text,   age int,   avatar_url text,   created_at timestamp DEFAULT now() );  CREATE TABLE posts (   id serial PRIMARY KEY,   title text NOT NULL,   content text NOT NULL,   user_id text NOT NULL,   created_at timestamp DEFAULT now() );  CREATE TABLE comments (   id serial PRIMARY KEY,   content text NOT NULL,   user_id text NOT NULL,   post_id int NOT NULL,   created_at timestamp DEFAULT now() );

Обратите внимание: мы не будем использовать внешние ключи (FOREIGN KEY) в полях user_id и post_id, поскольку это усложнит работу с Supabase на клиенте и потребует реализации дополнительной логики, связанной с редактированием и удалением связанных таблиц (ON UPDATE и ON DELETE).

Нажимаем на кнопку Run:

Мы можем увидеть созданные нами таблицы на страницах Table Editor и Database панели управления:

Обратите внимание на предупреждение RLS not enabled на странице Table Editor. Для доступа к таблицам рекомендуется устанавливать политики безопасности на уровне строк/политики защиты строк (Row Level Security). Для таблиц мы этого делать не будем, но нам придется сделать это для хранилища, в котором будут находиться аватары пользователей.

Создаем новый «бакет» на странице Storage панели управления (Create new bucket):

Делаем его публичным (Make public):

В разделе Policies создаем новую политику (New policy). Выбираем шаблон Give users access to a folder only to authenticated users (предоставление доступа к директории только для аутентифицированных пользователей) — Use this template:

Выбираем SELECT, INSERT и UPDATE и немного редактируем определение политики:

Нажимаем Review и затем Create policy.

Последнее, что нам осталось сделать для настройки проекта — включить регистрацию изменений данных для всех таблиц.

По умолчанию Supabase создает публикацию (publication) supabase_realtime. Нам нужно только добавить в нее наши таблицы. Для этого переходим в редактор SQL и вставляем такую строку:

alter publication supabase_realtime add table users, posts, comments;

Нажимаем RUN.

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

# производственные зависимости yarn add @supabase/supabase-js dotenv react-icons react-router-dom zustand  # зависимость для разработки yarn add -D sass

  • @supabase/supabase-jsSDK для взаимодействия с Supabase;
  • dotenv — утилита для доступа к переменным среды окружения;
  • react-icons — большая коллекция иконок в виде React-компонентов;
  • react-router-dom — библиотека для маршрутизации в React-приложениях;
  • zustand — инструмент для управления состоянием React-приложений;
  • sassCSS-препроцессор.

На этом подготовка и настройка проекта завершены. Переходим к разработке клиента.

Клиент

Структура директории src будет следующей:

- api - `API` для взаимодействия с `Supabase`   - comment.js   - db.js   - post.js   - user.js - components - компоненты   - AvatarUploader.jsx - для загрузки аватара пользователя   - CommentList.jsx - список комментариев   - Error.jsx - ошибка (не надо так делать в продакшне))   - Field.jsx - поле формы   - Form.jsx - форма   - Layout.jsx - макет страницы   - Loader.jsx - индикатор загрузки   - Nav.jsx - панель навигации   - PostList.jsx - список постов   - PostTabs.jsx - вкладки постов   - Protected.jsx - защищенная страница   - UserUpdater.jsx - для обновления данных пользователя - hooks - хуки   - useForm.js - для формы   - useStore.js - для управления состоянием приложения - pages - страницы   - About.jsx   - Blog.jsx - для всех постов   - Home.jsx   - Login.jsx - для авторизации   - Post.jsx - для одного поста   - Profile.jsx - для профиля пользователя   - Register.jsx - для регистрации - styles - стили - supabase   - index.js - создание и экспорт клиента `Supabase` - utils - утилиты   - serializeUser.js - App.jsx - основной компонент приложения - main.jsx - основной файл клиента

С вашего позволения, я не буду останавливаться на стилях: там нет ничего особенного, просто скопируйте их из репозитория.

Настроим алиасы (alias — синоним) для облегчения импорта компонентов в vite.config.js:

import react from '@vitejs/plugin-react' import { dirname, resolve } from 'path' import { fileURLToPath } from 'url' import { defineConfig } from 'vite'  // абсолютный путь к текущей директории const _dirname = dirname(fileURLToPath(import.meta.url))  export default defineConfig({   plugins: [react()],   resolve: {     alias: {       '@': resolve(_dirname, './src'),       a: resolve(_dirname, './src/api'),       c: resolve(_dirname, './src/components'),       h: resolve(_dirname, './src/hooks'),       p: resolve(_dirname, './src/pages'),       s: resolve(_dirname, './src/supabase'),       u: resolve(_dirname, './src/utils')     }   } })

Начнем создание нашего клиента с разработки API.

API

Для работы API нужен клиент для взаимодействия с Supabase.

Создаем и экспортируем его в supabase/index.js:

import { createClient } from '@supabase/supabase-js'  const supabase = createClient(   // такой способ доступа к переменным среды окружения является уникальным для `vite`   import.meta.env.VITE_SUPABASE_URL,   import.meta.env.VITE_SUPABASE_KEY )  export default supabase

API для работы со всеми таблицами базы данных (api/db.js):

import supabase from 's'  // метод для получения данных из всех таблиц async function fetchAllData() {   try {     // пользователи     const { data: users } = await supabase       .from('users')       .select('id, email, user_name')     // посты     const { data: posts } = await supabase       .from('posts')       .select('id, title, content, user_id, created_at')     // комментарии     const { data: comments } = await supabase       .from('comments')       .select('id, content, user_id, post_id, created_at')     return { users, posts, comments }   } catch (e) {     console.error(e)   } }  const dbApi = { fetchAllData }  export default dbApi

Утилита для сериализации объекта пользователя (utils/serializeUser.js):

const serializeUser = (user) =>   user     ? {         id: user.id,         email: user.email,         ...user.user_metadata       }     : null  export default serializeUser

Все данные пользователей, указываемые при регистрации, кроме email и password, записываются в поле user_metadata, что не очень удобно.

API для работы с таблицей users — пользователи (api/user.js):

import supabase from 's' import serializeUser from 'u/serializeUser'  // метод для получения данных пользователя из базы при наличии аутентифицированного пользователя // объект, возвращаемый методом `auth.user`, извлекается из локального хранилища const get = async () => {   const user = supabase.auth.user()   if (user) {     try {       const { data, error } = await supabase         .from('users')         .select()         .match({ id: user.id })         .single()       if (error) throw error       console.log(data)       return data     } catch (e) {       throw e     }   }   return null }  // метод для регистрации пользователя const register = async (data) => {   const { email, password, user_name } = data   try {     // регистрируем пользователя     const { user, error } = await supabase.auth.signUp(       // основные/обязательные данные       {         email,         password       },       // дополнительные/опциональные данные       {         data: {           user_name         }       }     )     if (error) throw error     // записываем пользователя в базу     const { data: _user, error: _error } = await supabase       .from('users')       // сериализуем объект пользователя       .insert([serializeUser(user)])       .single()     if (_error) throw _error     return _user   } catch (e) {     throw e   } }  // метод для авторизации пользователя const login = async (data) => {   try {     // авторизуем пользователя     const { user, error } = await supabase.auth.signIn(data)     if (error) throw error     // получаем данные пользователя из базы     const { data: _user, error: _error } = await supabase       .from('users')       .select()       .match({ id: user.id })       .single()     if (_error) throw _error     return _user   } catch (e) {     throw e   } }  // метод для выхода из системы const logout = async () => {   try {     const { error } = await supabase.auth.signOut()     if (error) throw error     return null   } catch (e) {     throw e   } }  // метод для обновления данных пользователя const update = async (data) => {   // получаем объект с данными пользователя   const user = supabase.auth.user()   if (!user) return   try {     const { data: _user, error } = await supabase       .from('users')       .update(data)       .match({ id: user.id })       .single()     if (error) throw error     return _user   } catch (e) {     throw e   } }  // метод для сохранения аватара пользователя // см. ниже  const userApi = { get, register, login, logout, update, uploadAvatar }  export default userApi

Метод для сохранения аватара пользователя:

// адрес хранилища const STORAGE_URL =   `${import.meta.env.VITE_SUPABASE_URL}/storage/v1/object/public/`  // метод принимает файл - аватар пользователя const uploadAvatar = async (file) => {   const user = supabase.auth.user()   if (!user) return   const { id } = user   // извлекаем расширение из названия файла   // метод `at` появился в `ECMAScript` в этом году   // он позволяет простым способом извлекать элементы массива с конца   const ext = file.name.split('.').at(-1)   // формируем название аватара   const name = id + '.' + ext   try {     // загружаем файл в хранилище     const {       // возвращаемый объект имеет довольно странную форму       data: { Key },       error     } = await supabase.storage.from('avatars').upload(name, file, {       // не кешировать файл - это важно!       cacheControl: 'no-cache',       // перезаписывать аватар при наличии       upsert: true     })     if (error) throw error     // формируем путь к файлу     const avatar_url = STORAGE_URL + Key     // обновляем данные пользователя -     // записываем путь к аватару     const { data: _user, error: _error } = await supabase       .from('users')       .update({ avatar_url })       .match({ id })       .single()     if (_error) throw _error     // возвращаем обновленного пользователя     return _user   } catch (e) {     throw e   } }

API для работы с таблицей posts — посты (api/post.js):

import supabase from 's'  // метод для создания поста const create = async (postData) => {   const user = supabase.auth.user()   if (!user) return   try {     const { data, error } = await supabase       .from('posts')       .insert([postData])       .single()     if (error) throw error     return data   } catch (e) {     throw e   } }  // для обновления поста const update = async (data) => {   const user = supabase.auth.user()   if (!user) return   try {     const { data: _data, error } = await supabase       .from('posts')       .update({ ...postData })       .match({ id: data.id, user_id: user.id })     if (error) throw error     return _data   } catch (e) {     throw e   } }  // для удаления поста const remove = async (id) => {   const user = supabase.auth.user()   if (!user) return   try {     // удаляем пост     const { error } = await supabase       .from('posts')       .delete()       .match({ id, user_id: user.id })     if (error) throw error     // удаляем комментарии к этому посту     const { error: _error } = await supabase       .from('comments')       .delete()       .match({ post_id: id })     if (_error) throw _error   } catch (e) {     throw e   } }  const postApi = { create, update, remove }  export default postApi

API для работы с таблицей comments — комментарии (api/comment.js):

import supabase from 's'  // метод для создания комментария const create = async (commentData) => {   const user = supabase.auth.user()   if (!user) return   try {     const { data, error } = await supabase       .from('comments')       .insert([{ ...commentData, user_id: user.id }])       .single()     if (error) throw error     return data   } catch (e) {     throw e   } }  // для обновления комментария const update = async (commentData) => {   const user = supabase.auth.user()   if (!user) return   try {     const { data, error } = await supabase       .from('comments')       .update({ ...commentData })       .match({ id: commentData.id, user_id: user.id })     if (error) throw error     return data   } catch (e) {     throw e   } }  // для удаления комментария const remove = async (id) => {   const user = supabase.auth.user()   if (!user) return   try {     const { error } = await supabase       .from('comments')       .delete()       .match({ id, user_id: user.id })     if (error) throw error   } catch (e) {     throw e   } }  const commentApi = { create, update, remove }  export default commentApi

Хуки

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

Создаем хранилище в файле hooks/useStore.js:

import create from 'zustand' import dbApi from 'a/db' import postApi from 'a/post'  const useStore = create((set, get) => ({   // состояние загрузки   loading: true,   // функция для его обновления   setLoading: (loading) => set({ loading }),   // состояние ошибки   error: null,   // функция для его обновления   setError: (error) => set({ loading: false, error }),   // состояние пользователя   user: null,   // функция для его обновления   setUser: (user) => set({ user }),    // пользователи   users: [],   // посты   posts: [],   // комментарии   comments: [],    // мы можем "тасовать" наши данные как угодно,   // например, так:   // объект постов с доступом по `id` поста   postsById: {},   // объект постов с доступом по `id` пользователя   postsByUser: {},   // карта "имя пользователя - `id` поста"   userByPost: {},   // объект комментариев с доступом по `id` поста   commentsByPost: {},   // массив всех постов с авторами и количеством комментариев   allPostsWithCommentCount: [],   // далее важно определить правильный порядок формирования данных    // формируем объект комментариев с доступом по `id` поста   getCommentsByPost() {     const { users, posts, comments } = get()     const commentsByPost = posts.reduce((obj, post) => {       obj[post.id] = comments         .filter((comment) => comment.post_id === post.id)         .map((comment) => ({           ...comment,           // добавляем в объект автора           author: users.find((user) => user.id === comment.user_id).user_name         }))       return obj     }, {})     set({ commentsByPost })   },   // формируем карту "имя пользователя - `id` поста"   getUserByPost() {     const { users, posts } = get()     const userByPost = posts.reduce((obj, post) => {       obj[post.id] = users.find((user) => user.id === post.user_id).user_name       return obj     }, {})     set({ userByPost })   },   // формируем объект постов с доступом по `id` пользователя   getPostsByUser() {     // здесь мы используем ранее сформированный объект `commentsByPost`     const { users, posts, commentsByPost } = get()     const postsByUser = users.reduce((obj, user) => {       obj[user.id] = posts         .filter((post) => post.user_id === user.id)         .map((post) => ({           ...post,           // пользователь может редактировать и удалять свои посты           editable: true,           // добавляем в объект количество комментариев           commentCount: commentsByPost[post.id].length         }))       return obj     }, {})     set({ postsByUser })   },   // формируем объект постов с доступом по `id` поста   getPostsById() {     // здесь мы используем ранее сформированные объекты `userByPost` и `commentsByPost`     const { posts, user, userByPost, commentsByPost } = get()     const postsById = posts.reduce((obj, post) => {       obj[post.id] = {         ...post,         // добавляем в объект комментарии         comments: commentsByPost[post.id],         // и их количество         commentCount: commentsByPost[post.id].length       }       // обратите внимание на оператор опциональной последовательности (`?.`)       // пользователь может отсутствовать (`null`)        // если пользователь является автором поста       if (post.user_id === user?.id) {         // значит, он может его редактировать и удалять         obj[post.id].editable = true       // иначе       } else {         // добавляем в объект имя автора поста         obj[post.id].author = userByPost[post.id]       }       return obj     }, {})     set({ postsById })   },   // формируем массив всех постов с авторами и комментариями   getAllPostsWithCommentCount() {     // здесь мы используем ранее сформированные объекты `userByPost` и `commentsByPost`     const { posts, user, userByPost, commentsByPost } = get()     const allPostsWithCommentCount = posts.map((post) => ({       ...post,       // является ли пост редактируемым       editable: user?.id === post.user_id,       // добавляем в объект автора       author: userByPost[post.id],       // и количество комментариев       commentCount: commentsByPost[post.id].length     }))     set({ allPostsWithCommentCount })   },    // метод для получения всех данных и формирования вспомогательных объектов и массива   async fetchAllData() {     set({ loading: true })      const {       getCommentsByPost,       getUserByPost,       getPostsByUser,       getPostsById,       getAllPostsWithCommentCount     } = get()      const { users, posts, comments } = await dbApi.fetchAllData()      set({ users, posts, comments })      getCommentsByPost()     getPostsByUser()     getUserByPost()     getPostsById()     getAllPostsWithCommentCount()      set({ loading: false })   },    // метод для удаления поста   // данный метод является глобальным, поскольку вызывается на разных уровнях приложения   removePost(id) {     set({ loading: true })     postApi.remove(id).catch((error) => set({ error }))   } }))  export default useStore

Хук для работы с формами (hooks/useForm.js):

import { useState, useEffect } from 'react'  // хук принимает начальное состояние формы // чтобы немного облегчить себе жизнь, // мы будем исходить из предположения, // что все поля формы являются обязательными export default function useForm(initialData) {   const [data, setData] = useState(initialData)   const [disabled, setDisabled] = useState(true)    useEffect(() => {     // если какое-либо из полей является пустым     setDisabled(!Object.values(data).every(Boolean))   }, [data])    // метод для изменения полей формы   const change = ({ target: { name, value } }) => {     setData({ ...data, [name]: value })   }    return { data, change, disabled } }

Компонент поля формы выглядит следующим образом (components/Field.jsx):

export const Field = ({ label, value, change, id, type, ...rest }) => (   <div className='field'>     <label htmlFor={id}>{label}</label>     <input       type={type}       id={id}       name={id}       required       value={value}       onChange={change}       {...rest}     />   </div> )

А компонент формы так (components/Form.jsx):

import useForm from 'h/useForm' import { Field } from './Field'  // функция принимает массив полей формы, функцию для отправки формы и подпись к кнопке для отправки формы export const Form = ({ fields, submit, button }) => {   // некоторые поля могут иметь начальные значения,   // например, при обновлении данных пользователя   const initialData = fields.reduce((o, f) => {     o[f.id] = f.value || ''     return o   }, {})   // используем хук   const { data, change, disabled } = useForm(initialData)    // функция для отправки формы   const onSubmit = (e) => {     if (disabled) return     e.preventDefault()     submit(data)   }    return (     <form onSubmit={onSubmit}>       {fields.map((f) => (         <Field key={f.id} {...f} value={data[f.id]} change={change} />       ))}       <button disabled={disabled} className='success'>         {button}       </button>     </form>   ) }

Рассмотрим пример использования хука для работы с хранилищем состояния и компонента формы на странице для регистрации пользователя (pages/Register.jsx):

import userApi from 'a/user' import { Form } from 'c' import useStore from 'h/useStore' import { useNavigate } from 'react-router-dom'  // начальное состояние формы const fields = [   {     id: 'user_name',     label: 'Username',     type: 'text'   },   {     id: 'email',     label: 'Email',     type: 'email'   },   {     id: 'password',     label: 'Password',     type: 'password'   },   {     id: 'confirm_password',     label: 'Confirm password',     type: 'password'   } ]  export const Register = () => {   // извлекаем из состояния методы для установки пользователя, загрузки и ошибки   const { setUser, setLoading, setError } = useStore(     ({ setUser, setLoading, setError }) => ({ setUser, setLoading, setError })   )   // метод для ручного перенаправления   const navigate = useNavigate()    // метод для регистрации   const register = async (data) => {     setLoading(true)     userApi       // данный метод возвращает объект пользователя       .register(data)       .then((user) => {         // устанавливаем пользователя         setUser(user)         // выполняем перенаправление на главную страницу         navigate('/')       })       // ошибка       .catch(setError)   }    return (     <div className='page register'>       <h1>Register</h1>       <Form fields={fields} submit={register} button='Register' />     </div>   ) }

Страница для авторизации пользователя выглядит похожим образом (pages/Login.jsx):

import userApi from 'a/user' import { Form } from 'c' import useStore from 'h/useStore' import { useNavigate } from 'react-router-dom'  const fields = [   {     id: 'email',     label: 'Email',     type: 'email'   },   {     id: 'password',     label: 'Password',     type: 'password'   } ]  export const Login = () => {   const { setUser, setLoading, setError } = useStore(     ({ setUser, setLoading, setError }) => ({ setUser, setLoading, setError })   )   const navigate = useNavigate()    const register = async (data) => {     setLoading(true)     userApi       .login(data)       .then((user) => {         setUser(user)         navigate('/')       })       .catch(setError)   }    return (     <div className='page login'>       <h1>Login</h1>       <Form fields={fields} submit={register} button='Login' />     </div>   ) }

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

Вы могли заметить, что на страницах для регистрации и авторизации пользователя мы обновляем состояние загрузки только один раз (setLoading(true)). Разве это не приведет к тому, что все время будет отображаться индикатор загрузки? Именно так. Давайте это исправим.

При регистрации/авторизации, а также при любых операциях чтения/записи в БД мы хотим вызывать метод fetchAllData из хранилища (в продакшне так делать не надо).

Для регистрации изменения состояния аутентификации клиент Supabase предоставляет метод auth.onAuthStateChanged, а для регистрации операций по работе с БД — метод from(tableNames).on(eventTypes, callback).subscribe.

Обновляем файл supabase/index.js:

import { createClient } from '@supabase/supabase-js' import useStore from 'h/useStore'  const supabase = createClient(   import.meta.env.VITE_SUPABASE_URL,   import.meta.env.VITE_SUPABASE_KEY )  // регистрация обновления состояния аутентификации supabase.auth.onAuthStateChange((event, session) => {   console.log(event, session)   // одной из прелестей `zustand` является то,   // что методы из хранилища могут вызываться где угодно   useStore.getState().fetchAllData() })  // регистрация обновления данных в базе supabase   // нас интересуют все таблицы   .from('*')   // и все операции   .on('*', (payload) => {     console.log(payload)      useStore.getState().fetchAllData()   })   .subscribe()  export default supabase

Как мы помним, на последней строке кода метода fetchAllData вызывается set({ loading: false }). Таким образом, индикатор загрузки будет отображаться до тех пор, пока приложение не получит все необходимые данные и не сформирует все вспомогательные объекты и массив. В свою очередь, пользователь всегда будет иметь дело с актуальными данными.

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

Страницы и компоненты

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

Начнем со страницы профиля пользователя (pages/Profile.jsx):

import { Protected, UserUpdater } from 'c' import useStore from 'h/useStore'  export const Profile = () => {   // извлекаем из хранилища объект пользователя   const user = useStore(({ user }) => user)   // копируем его   const userCopy = { ...user }   // и удаляем поле с адресом аватара -   // он слишком длинный и ломает разметку   delete userCopy.avatar_url    return (     // страница является защищенной     <Protected className='page profile'>       <h1>Profile</h1>       <div className='user-data'>         {/* отображаем данные пользователя */}         <pre>{JSON.stringify(userCopy, null, 2)}</pre>       </div>       {/* компонент для обновления данных пользователя */}       <UserUpdater />     </Protected>   ) }

Компонент Protected перенаправляет неавторизованного пользователя на главную страницу после завершения загрузки приложения (components/Protected.jsx):

import useStore from 'h/useStore' import { useEffect } from 'react' import { useNavigate } from 'react-router-dom'  export const Protected = ({ children, className }) => {   const { user, loading } = useStore(({ user, loading }) => ({ user, loading }))   const navigate = useNavigate()    useEffect(() => {     if (!loading && !user) {       navigate('/')     }   }, [user, loading])    // ничего не рендерим при отсутствии пользователя   if (!user) return null    return <div className={className ? className : ''}>{children}</div> }

Компонент для обновления данных пользователя (components/UserUpdater.jsx):

import { Form, AvatarUploader } from 'c' import useStore from 'h/useStore' import userApi from 'a/user'  export const UserUpdater = () => {   const { user, setUser, setLoading, setError } = useStore(     ({ user, setUser, setLoading, setError }) => ({       user,       setUser,       setLoading,       setError     })   )    // метод для обновления данных пользователя   const updateUser = async (data) => {     setLoading(true)     userApi.update(data).then(setUser).catch(setError)   }    // начальное состояние   // с данными из объекта пользователя   const fields = [     {       id: 'first_name',       label: 'First Name',       type: 'text',       value: user.first_name     },     {       id: 'last_name',       label: 'Last Name',       type: 'text',       value: user.last_name     },     {       id: 'age',       label: 'Age',       type: 'number',       value: user.age     }   ]    return (     <div className='user-updater'>       <h2>Update User</h2>       {/* компонент для загрузки аватара */}       <AvatarUploader />       <h3>User Bio</h3>       <Form fields={fields} submit={updateUser} button='Update' />     </div>   ) }

Компонент для загрузки аватара (components/AvatarUploader.jsx):

import { useState, useEffect } from 'react' import userApi from 'a/user' import useStore from 'h/useStore'  export const AvatarUploader = () => {   const { user, setUser, setLoading, setError } = useStore(     ({ user, setUser, setLoading, setError }) => ({       user,       setUser,       setLoading,       setError     })   )   // состояние для файла   const [file, setFile] = useState('')   const [disabled, setDisabled] = useState(true)    useEffect(() => {     setDisabled(!file)   }, [file])    const upload = (e) => {     e.preventDefault()     if (disabled) return     setLoading(true)     userApi.uploadAvatar(file).then(setUser).catch(setError)   }    return (     <div className='avatar-uploader'>       <form className='avatar-uploader' onSubmit={upload}>         <label htmlFor='avatar'>Avatar:</label>         <input           type='file'           // инпут принимает только изображения           accept='image/*'           onChange={(e) => {             if (e.target.files) {               setFile(e.target.files[0])             }           }}         />         <button disabled={disabled}>Upload</button>       </form>     </div>   ) }

Рассмотрим страницу для постов (pages/Blog.jsx):

import postApi from 'a/post' import { Form, PostList, PostTabs, Protected } from 'c' import useStore from 'h/useStore' import { useEffect, useState } from 'react'  // начальное состояние нового поста const fields = [   {     id: 'title',     label: 'Title',     type: 'text'   },   {     id: 'content',     label: 'Content',     type: 'text'   } ]  export const Blog = () => {   const { user, allPostsWithCommentCount, postsByUser, setLoading, setError } =     useStore(       ({         user,         allPostsWithCommentCount,         postsByUser,         setLoading,         setError       }) => ({         user,         allPostsWithCommentCount,         postsByUser,         setLoading,         setError       })     )   // выбранная вкладка   const [tab, setTab] = useState('all')   // состояние для отфильтрованных на основании выбранной вкладки постов   const [_posts, setPosts] = useState([])    // метод для создания нового поста   const create = (data) => {     setLoading(true)     postApi       .create(data)       .then(() => {         // переключаем вкладку         setTab('my')       })       .catch(setError)   }    useEffect(() => {     if (tab === 'new') return     // фильтруем посты на основании выбранной вкладки     const _posts =       tab === 'my' ? postsByUser[user.id] : allPostsWithCommentCount     setPosts(_posts)   }, [tab, allPostsWithCommentCount])    // если значением выбранной вкладки является `new`,   // возвращаем форму для создания нового поста   // данная вкладка является защищенной   if (tab === 'new') {     return (       <Protected className='page new-post'>         <h1>Blog</h1>         <PostTabs tab={tab} setTab={setTab} />         <h2>New post</h2>         <Form fields={fields} submit={create} button='Create' />       </Protected>     )   }    return (     <div className='page blog'>       <h1>Blog</h1>       <PostTabs tab={tab} setTab={setTab} />       <h2>{tab === 'my' ? 'My' : 'All'} posts</h2>       <PostList posts={_posts} />     </div>   ) }

Вкладки постов (components/PostTabs.jsx):

import useStore from 'h/useStore'  // вкладки // свойство `protected` определяет, // какие вкладки доступны пользователю const tabs = [   {     name: 'All'   },   {     name: 'My',     protected: true   },   {     name: 'New',     protected: true   } ]  export const PostTabs = ({ tab, setTab }) => {   const user = useStore(({ user }) => user)    return (     <nav className='post-tabs'>       <ul>         {tabs.map((t) => {           const tabId = t.name.toLowerCase()           if (t.protected) {             return user ? (               <li key={tabId}>                 <button                   className={tab === tabId ? 'active' : ''}                   onClick={() => setTab(tabId)}                 >                   {t.name}                 </button>               </li>             ) : null           }           return (             <li key={tabId}>               <button                 className={tab === tabId ? 'active' : ''}                 onClick={() => setTab(tabId)}               >                 {t.name}               </button>             </li>           )         })}       </ul>     </nav>   ) }

Список постов (components/PostList.jsx):

import { Link, useNavigate } from 'react-router-dom' import useStore from 'h/useStore' import { VscComment, VscEdit, VscTrash } from 'react-icons/vsc'  // элемент поста const PostItem = ({ post }) => {   const removePost = useStore(({ removePost }) => removePost)   const navigate = useNavigate()    // каждый пост - это ссылка на его страницу   return (     <Link       to={`/blog/post/${post.id}`}       className='post-item'       onClick={(e) => {         // отключаем переход на страницу поста         // при клике по кнопке или иконке         if (e.target.localName === 'button' || e.target.localName === 'svg') {           e.preventDefault()         }       }}     >       <h3>{post.title}</h3>       {/* если пост является редактируемым - принадлежит текущему пользователю */}       {post.editable && (         <div>           <button             onClick={() => {               // строка запроса `edit=true` определяет,               // что пост находится в состоянии редактирования               navigate(`/blog/post/${post.id}?edit=true`)             }}             className='info'           >             <VscEdit />           </button>           <button             onClick={() => {               removePost(post.id)             }}             className='danger'           >             <VscTrash />           </button>         </div>       )}       <p>Author: {post.author}</p>       <p className='date'>{new Date(post.created_at).toLocaleString()}</p>       {/* количество комментариев к посту */}       {post.commentCount > 0 && (         <p>           <VscComment />           <span className='badge'>             <sup>{post.commentCount}</sup>           </span>         </p>       )}     </Link>   ) }  // список постов export const PostList = ({ posts }) => (   <div className='post-list'>     {posts.length > 0 ? (       posts.map((post) => <PostItem key={post.id} post={post} />)     ) : (       <h3>No posts</h3>     )}   </div> )

Последняя страница, которую мы рассмотрим — это страница поста (pages/Post.jsx):

import postApi from 'a/post' import commentApi from 'a/comment' import { Form, Protected, CommentList } from 'c' import useStore from 'h/useStore' import { useNavigate, useParams, useLocation } from 'react-router-dom' import { VscEdit, VscTrash } from 'react-icons/vsc'  // начальное состояние для нового комментария const createCommentFields = [   {     id: 'content',     label: 'Content',     type: 'text'   } ]  export const Post = () => {   const { user, setLoading, setError, postsById, removePost } = useStore(     ({ user, setLoading, setError, postsById, removePost }) => ({       user,       setLoading,       setError,       postsById,       removePost     })   )   // извлекаем `id` поста из параметров   const { id } = useParams()   const { search } = useLocation()   // извлекаем индикатор редактирования поста из строки запроса   const edit = new URLSearchParams(search).get('edit')   // извлекаем пост по его `id`   const post = postsById[id]   const navigate = useNavigate()    // метод для обновления поста   const updatePost = (data) => {     setLoading(true)     data.id = post.id     postApi       .update(data)       .then(() => {         // та же страница, но без строки запроса         navigate(`/blog/post/${post.id}`)       })       .catch(setError)   }    // метод для создания комментария   const createComment = (data) => {     setLoading(true)     data.post_id = post.id     commentApi.create(data).catch(setError)   }    // если пост находится в состоянии редактирования   if (edit) {     const editPostFields = [       {         id: 'title',         label: 'Title',         type: 'text',         value: post.title       },       {         id: 'content',         label: 'Content',         type: 'text',         value: post.content       }     ]      return (       <Protected>         <h2>Update post</h2>         <Form fields={editPostFields} submit={updatePost} button='Update' />       </Protected>     )   }    return (     <div className='page post'>       <h1>Post</h1>       {post && (         <div className='post-item' style={{ width: '512px' }}>           <h2>{post.title}</h2>           {post.editable ? (             <div>               <button                 onClick={() => {                   navigate(`/blog/post/${post.id}?edit=true`)                 }}                 className='info'               >                 <VscEdit />               </button>               <button                 onClick={() => {                   removePost(post.id)                   navigate('/blog')                 }}                 className='danger'               >                 <VscTrash />               </button>             </div>           ) : (             <p>Author: {post.author}</p>           )}           <p className='date'>{new Date(post.created_at).toLocaleString()}</p>           <p>{post.content}</p>           {user && (             <div className='new-comment'>               <h3>New comment</h3>               <Form                 fields={createCommentFields}                 submit={createComment}                 button='Create'               />             </div>           )}           {/* комментарии к посту */}           {post.comments.length > 0 && <CommentList comments={post.comments} />}         </div>       )}     </div>   ) }

Компонент для комментариев к посту (components/CommentList.jsx):

import { useState } from 'react' import useStore from 'h/useStore' import commentApi from 'a/comment' import { Form, Protected } from 'c' import { VscEdit, VscTrash } from 'react-icons/vsc'  export const CommentList = ({ comments }) => {   const { user, setLoading, setError } = useStore(     ({ user, setLoading, setError }) => ({ user, setLoading, setError })   )   // индикатор редактирования комментария   const [editComment, setEditComment] = useState(null)    // метод для удаления комментария   const remove = (id) => {     setLoading(true)     commentApi.remove(id).catch(setError)   }    // метод для обновления комментария   const update = (data) => {     setLoading(true)     data.id = editComment.id     commentApi.update(data).catch(setError)   }    // если комментарий находится в состоянии редактирования   if (editComment) {     const fields = [       {         id: 'content',         label: 'Content',         type: 'text',         value: editComment.content       }     ]      return (       <Protected>         <h3>Update comment</h3>         <Form fields={fields} submit={update} button='Update' />       </Protected>     )   }    return (     <div className='comment-list'>       <h3>Comments</h3>       {comments.map((comment) => (         <div className='comment-item' key={comment.id}>           <p>{comment.content}</p>           {/* является ли комментарий редактируемым - принадлежит ли текущему пользователю? */}           {comment.user_id === user?.id ? (             <div>               <button onClick={() => setEditComment(comment)} className='info'>                 <VscEdit />               </button>               <button onClick={() => remove(comment.id)} className='danger'>                 <VscTrash />               </button>             </div>           ) : (             <p className='author'>Author: {comment.author}</p>           )}           <p className='date'>             {new Date(comment.created_at).toLocaleString()}           </p>         </div>       ))}     </div>   ) }

И основной компонент приложения (App.jsx):

import './styles/app.scss' import { Routes, Route } from 'react-router-dom' import { Home, About, Register, Login, Profile, Blog, Post } from 'p' import { Nav, Layout } from 'c' import { useEffect } from 'react' import useStore from 'h/useStore' import userApi from 'a/user'  function App() {   const { user, setUser, setLoading, setError, fetchAllData } = useStore(     ({ user, setUser, setLoading, setError, fetchAllData }) => ({       user,       setUser,       setLoading,       setError,       fetchAllData     })   )    useEffect(() => {     // запрашиваем данные пользователя при их отсутствии     if (!user) {       setLoading(true)       userApi         .get()         .then((user) => {           // устанавливаем пользователя           // `user` может иметь значение `null`           setUser(user)           // получаем данные из всех таблиц           fetchAllData()         })         .catch(setError)     }   }, [])    return (     <div className='app'>       <header>         <Nav />       </header>       <main>         <Layout>           <Routes>             <Route path='/' element={<Home />} />             <Route path='/blog' element={<Blog />} />             <Route path='/blog/post/:id' element={<Post />} />             <Route path='/about' element={<About />} />             <Route path='/register' element={<Register />} />             <Route path='/login' element={<Login />} />             <Route path='/profile' element={<Profile />} />           </Routes>         </Layout>       </main>       <footer>         <p>&copy; 2022. Not all rights reserved</p>       </footer>     </div>   ) }  export default App

Таким образом, мы рассмотрели все основные страницы и компоненты приложения.

Проверка работоспособности

Давайте убедимся в том, что приложение работает, как ожидается.

Определим функцию для наполнения базы фиктивными данными. В директории src создаем файл seedDb.js следующего содержания:

import { createClient } from '@supabase/supabase-js' import serializeUser from '../utils/serializeUser.js' import { config } from 'dotenv'  // получаем доступ к переменным среды окружения config()  // создаем клиента `Supabase` const supabase = createClient(   process.env.VITE_SUPABASE_URL,   process.env.VITE_SUPABASE_KEY )  // создаем 2 пользователей, `Alice` и `Bob` с постами и комментариями async function seedDb() {   try {     const { user: aliceAuth } = await supabase.auth.signUp(       {         email: 'alice@mail.com',         password: 'password'       },       {         data: {           user_name: 'Alice'         }       }     )     const { user: bobAuth } = await supabase.auth.signUp(       {         email: 'bob@mail.com',         password: 'password'       },       {         data: {           user_name: 'Bob'         }       }     )     const {       data: [alice, bob]     } = await supabase       .from('users')       .insert([serializeUser(aliceAuth), serializeUser(bobAuth)])      const { data: alicePosts } = await supabase.from('posts').insert([       {         title: `Alice's first post`,         content: `This is Alice's first post`,         user_id: alice.id       },       {         title: `Alice's second post`,         content: `This is Alice's second post`,         user_id: alice.id       }     ])     const { data: bobPosts } = await supabase.from('posts').insert([       {         title: `Bob's's first post`,         content: `This is Bob's first post`,         user_id: bob.id       },       {         title: `Bob's's second post`,         content: `This is Bob's second post`,         user_id: bob.id       }     ])     for (const post of alicePosts) {       await supabase.from('comments').insert([         {           user_id: alice.id,           post_id: post.id,           content: `This is Alice's comment on Alice's post "${post.title}"`         },         {           user_id: bob.id,           post_id: post.id,           content: `This is Bob's comment on Alice's post "${post.title}"`         }       ])     }     for (const post of bobPosts) {       await supabase.from('comments').insert([         {           user_id: alice.id,           post_id: post.id,           content: `This is Alice's comment on Bob's post "${post.title}"`         },         {           user_id: bob.id,           post_id: post.id,           content: `This is Bob's comment on Bob's post "${post.title}"`         }       ])     }     console.log('Done')   } catch (e) {     console.error(e)   } } seedDb()

Выполняем этот код с помощью node src/seed_db.js. Получаем сообщение об успехе операции (Done). В БД появилось 2 пользователя, 4 поста и 8 комментариев.

Находясь в корневой директории проекта (supabase-social-app), выполняем команду yarn dev для запуска сервера для разработки.

Переходим на страницу регистрации (Register) и создаем нового пользователя. Обратите внимание: Supabase требует, чтобы пароль состоял как минимум из 6 символов.

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

Переходим на страницу профиля (Profile), загружаем аватар и обновляем данные.

Вместо ссылки на страницу профиля у нас теперь имеется аватар пользователя, а в объекте user — заполненные поля first_name, last_name и age.

Переходим на страницу блога (Blog), «проваливаемся» в какой-нибудь пост и добавляем к нему комментарий.

Добавленный комментарий можно редактировать и удалять.

Возвращаемся на страницу блога, переключаемся на вкладку для создания нового поста (New) и создаем его.

На вкладке My страницы блога можно увидеть все созданные нами посты.

Их также можно редактировать и удалять.

Круто! Все работает, как часы.

Таким образом, Supabase предоставляет разработчикам довольно интересные возможности по созданию fullstack app, позволяя практически полностью сосредоточиться на клиентской части приложения. So give it a chance!

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

Благодарю за внимание и happy coding!



ссылка на оригинал статьи https://habr.com/ru/articles/660183/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *