Привет, друзья!
В этом цикле из 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-js —
SDKдля взаимодействия сSupabase; - dotenv — утилита для доступа к переменным среды окружения;
- react-icons — большая коллекция иконок в виде
React-компонентов; - react-router-dom — библиотека для маршрутизации в
React-приложениях; - zustand — инструмент для управления состоянием
React-приложений; - sass —
CSS-препроцессор.
На этом подготовка и настройка проекта завершены. Переходим к разработке клиента.
Клиент
Структура директории 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>© 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/

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