Привет, друзья!
В этом цикле из 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!
makar_crypt
я правильно понимаю что это всё для NODEJS (серверной части)? Просто много где слово "клентская", но имеется в другом смысле т.к:
Например вижу запись в Storage , соответственно на клиентской части ты же не будешь передавать secret key , auth и т.д.