sidebar_position | title | description | keywords | tags | ||||||||||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
15 |
Руководство по Supabase |
Руководство по Supabase |
|
|
Supabase
, как иFirebase
- этоSaaS
илиBaaS
. Что это означает? Это означает, что в случае с фулстек-приложением мы разрабатываем только клиентскую часть, а все остальное предоставляетсяSupabase
через пользовательские комплекты для разработки программного обеспечения (SDK
) и программные интерфейсы приложения (API
). Под "всем остальным" подразумевается сервис аутентификации (включая возможность использования сторонних провайдеров), база данных (PostgreSQL
), файловое хранилище, обработку модификации данных в режиме реального времени и сервер, который все это обслуживает.
Пример использования Supabase
для разработки фулстек-приложения для публикации постов.
Установка
yarn add @supabase/supabase-js
Импорт
import supabase from '@supabase/supabase-js'
Инициализация
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(url, key, options)
url
- путь, предоставляемый после создания проекта, доступный в настройках панели управления проектом;key
- ключ, предоставляемый после создания проекта, доступный в настройках панели управления проектом;options
- дополнительные настройки.
const options = {
// дефолтная схема
schema: 'public',
headers: { 'x-my-custom-header': 'my-app-name' },
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: true
}
const supabase = createClient('https://my-app.supabase.co', 'public-anon-key', options)
По умолчанию для выполнения HTTP-запросов supabase-js
использует библиотеку cross-fetch
. Это можно изменить следующим образом:
const supabase = createClient('https://my-app.supabase.co', 'public-anon-key', {
fetch: fetch.bind(globalThis)
})
Регистрация
Для регистрации нового пользователя используется метод signUp
:
async function registerUser({ email, password, first_name, last_name, age }) {
try {
const { user, session, error } = await supabase.auth.signUp({
// обязательно
email,
password
}, {
// опционально
data: {
// такой синтаксис является валидным в JS
// и более подходящим для Postgres
first_name,
last_name,
age
},
// дефолтная настройка
redirectTo: window.location.origin
})
if (error) throw error
return { user, session }
} catch (e) {
throw e
}
}
Такая сигнатура позволяет вызывать данный метод следующим образом (на примере React-приложения
):
// предположим, что `registerUser` возвращает только пользователя
const onSubmit = (e) => {
e.preventDefault()
if (submitDisabled) return
setLoading(true)
userApi
.registerUser(formData)
.then(setUser)
.catch(setError)
.finally(() => {
setLoading(false)
})
}
В TypeScript
можно использовать такую сигнатуру:
async function registerUser({ first_name, last_name, age, email, password }: UserData): ResponseData {
let data = { user: null, error: null }
try {
const { user, error } = await supabase.auth.signUp({
email,
password
}, {
data: {
first_name,
last_name,
age
}
})
if (error) {
data.error = error
} else {
data.user = user
}
} catch (e) {
data.error = e
} finally {
return data
}
}
Обратите внимание: по умолчанию после регистрации пользователь должен подтвердить свой email. Это можно отключить в разделе Authentication -> Settings
.
Авторизация
Для авторизации, в том числе с помощью сторонних провайдеров, используется метод signIn
:
async function loginUser({ email, password }) {
try {
const { user, session, error } = await supabase.auth.singIn({
// обязательно
email,
password
}, {
// опционально
returnTo: window.location.pathname
})
if (error) throw error
return { user, session }
} catch (e) {
throw e
}
}
Если авторизация выполняется только через email, пользователю отправляется так называемая магическая ссылка/magic link
.
Пример авторизации с помощью аккаунта GitHub
:
async function loginWithGitHub() {
try {
const { user, session, error } = await supabase.auth.signIn({
// провайдером может быть `google`, `github`, `gitlab` или `bitbucket`
provider: 'github'
}, {
scopes: 'repo gist'
})
if (error) throw error
// для доступа к `API` провайдера используется `session.provider_token`
return { user, session }
} catch (e) {
throw e
}
}
Выход из системы
Для выхода из системы используется метод signOut
:
async function logoutUser() {
try {
const { error } = await supabase.auth.signOut()
if (error) throw error
} catch (e) {
throw e
}
}
Для того, чтобы сообщить серверу о необходимости завершения сессии используется метод auth.api.signOut(jwt)
.
Сессия
Метод session
используется для получения данных активной сессии:
const session = supabase.auth.session()
Пользователь
Метод user
возвращает данные авторизованного пользователя:
const user = supabase.auth.user()
В данном случае возвращается объект из локального хранилища.
Для получения данных пользователя из БД используется метод auth.api.getUser(jwt)
.
На стороне сервера для этого используется метод auth.api.getUserByCookie
.
Обновление данных пользователя
Для обновления данных пользователя используется метод update
:
async function updateUser({ age }) {
try {
const { user, error } = await supabase.auth.update({
data: {
age
}
})
if (error) throw error
return user
} catch (e) {
throw e
}
}
Обратите внимание: можно обновлять либо основные данные (email
, password
), либо дополнительные (data
).
Регистрация изменения состояния авторизации
supabase.auth.onAuthStateChange((event, session) => {
console.log(event, session)
})
Сброс пароля
const { data, error } = supabase.auth.api.resetPasswordForEmail(email, { redirectTo: window.location.origin })
После вызова этого метода на email пользователя отправляется ссылка для сброса пароля. Когда пользователь кликает по ссылке, он переходит по адресу: SITE_URL/#access_token=X&refresh_token=Y&expires_in=Z&token_type=bearer&type=recovery
. Мы регистрируем type=recovery
и отображаем форму для сброса пароля. Затем используем access_token
из URL
и новый пароль для обновления пользовательских данных:
async function resetPassword(access_token, new_password) {
try {
const { data, error } = await supabase.auth.api.updateUser(access_token, { password: new_password })
if (error) throw error
return data
} catch (e) {
throw e
}
}
Извлечение (выборка) данных
Сигнатура:
const { data, error } = await supabase
.from(table_name)
.select(column_names, options)
table_name
- название таблицы (обязательно);column_names
- список разделенных через запятую названий колонок (столбцов) или'*'
для выборки всех полей;options
- дополнительные настройки:head
- если имеет значениеtrue
, данные аннулируются (см. пример ниже);count
- алгоритм для подсчета количества строк в таблице:null | exact | planned | estimated
.
Пример:
async function getUsers() {
try {
const { data, error } = await supabase
.from('users')
.select('id, first_name, last_name, age, email')
if (error) throw error
return data
} catch (e) {
throw(e)
}
}
По умолчанию максимальное количество возвращаемых строк равняется 1000
. Это можно изменить в настройках.
select()
может использоваться совместно с модификаторами и фильтрами (будут рассмотрены позже).
Получение данных из связанных таблиц
async function getUsersAndPosts() {
try {
const { data, error } = await supabase
.from('users')
.select(`
id,
first_name,
last_name,
age,
email,
posts (
id,
title,
content
)
`)
if (error) throw error
return data
} catch (e) {
throw e
}
}
Фильтрация данных с помощью внутреннего соединения (inner join)
Предположим, что мы хотим извлечь сообщения (messages), принадлежащие пользователю с определенным именем (username):
async function getMessagesByUsername(username) {
try {
const { data, error } = await supabase
.from('messages')
// для такой фильтрации используется функция `!inner()`
.select('*, users!inner(*)')
.eq('users.username', username)
if (error) throw error
return data
} catch (e) {
throw e
}
}
Получение количества строк
async function getUsersAndUserCount() {
try {
const { data, error, count } = await supabase
.from('users')
// для получения только количества строк используется `{ count: 'exact', head: true }`
.select('id, first_name, last_name, email', { count: 'exact' })
if (error) throw error
return { data, count }
} catch(e) {
throw e
}
}
Получение данных из JSONB-столбцов
async function getUsersWithTheirCitiesByCountry(country) {
try {
const { data, error } = await supabase
.from('users')
.select(`
id, first_name, last_name, email,
address->city
`)
.eq('address->country', country)
if (error) throw error
return data
} catch(e) {
throw e
}
}
Получение данных в формате CSV
async function getUsersInCSV() {
try {
const { data, error } = await supabase
.from('users')
.select()
.csv()
if (error) throw error
return data
} catch(e) {
throw e
}
}
Запись (добавление, вставка) данных
Сигнатура:
const { data, error } = await supabase
.from(table_name)
.insert(data_array, options)
table_name
- название таблицы;data_array
- массив записываемых данных;options
- дополнительные настройки, например:returning: 'minimal'
- не возвращать данные после записи;upsert: true
- обновить существующую строку вместо создания новой.
Пример:
async function createPost({ title, body, author_id }) {
try {
const { data, error } = await supabase
.from('posts')
.insert([{
title, body, author_id
}])
if (error) throw error
return data
} catch (e) {
throw e
}
}
Разумеется, можно создавать несколько записей одновременно:
// предположим, что `messages` - массив объектов `{ title, body, user_id, room_id }`
async function saveMessagesForRoom(messages) {
try {
const { error } = await supabase
.from('messages')
.insert(messages, { returning: 'minimal' })
if (error) throw error
return data
} catch (e) {
throw e
}
}
Обратите внимание: для обновления строк с помощью upsert: true
данные должны содержать первичные или основные ключи/primary keys
соответствующей таблицы.
Модификация данных
Сигнатура:
const { data, error } = await supabase
.from(table_name)
.update({ column_name: 'column_value' }, options)
.match(condition)
table_name
- название таблицы;column_name: 'column_value'
- название обновляемой колонки и ее новое значение;options
- дополнительные настройки:returning
- определяет, возвращаются ли данные после обновления:minimal | presentation
;count
- алгоритм для подсчета количества обновленных строк;condition
- условие для поиска обновляемой колонки.
Пример:
async function updateUser({ changes, user_id }) {
try {
const { data, error } = await supabase
.from('users')
.update({ ...changes })
.match({ id: user_id })
if (error) throw error
return data
} catch (e) {
throw e
}
}
Обратите внимание: метод update
должен всегда использоваться совместно с фильтрами.
Пример обновления JSON-колонки
async function updateUsersCity({ address, user_id }) {
try {
const { error } = await supabase
.from('users')
.update(`address: ${address}`, { returning: 'minimal' })
.match({ id: user_id })
if (error) throw error
} catch (e) {
throw e
}
}
В настоящее время поддерживается обновление только объекта целиком.
Модификация или запись данных
Сигнатура:
const { data, error } = await supabase
.from(table_name)
.upsert({ primary_key_name: 'primary_key_value', column_name: 'column_value' }, options)
options
- дополнительные настройки:returning
;count
;onConflict: string
- позволяет работать с колонками, имеющими ограничениеUNIQUE
;ignoreDuplicates: boolean
- определяет, должны ли игнорироваться дубликаты.
Пример:
async function updateOrCreatePost({ id, title, body, author_id }) {
try {
const { data, error } = await supabase
.from('posts')
.upsert({ id, title, body, author_id })
if (error) throw error
return data
} catch (e) {
throw e
}
}
Разумеется, можно обновлять или создавать несколько записей за раз.
Обратите внимание на 2 вещи:
- данные должны содержать первичные ключи;
- первичные ключи могут быть только натуральными (не суррогатными).
Удаление данных
Сигнатура:
const { data, error } = await supabase
.from(table_name)
.delete(options)
.match(condition)
options
:returning
;count
.
Пример:
async function removeUser(user_id) {
try {
const { error } = await supabase
.from('users')
.delete({ returning: 'minimal' })
.match({ id: user_id })
if (error) throw error
} catch (e) {
throw e
}
}
Вызов функций Postgres
Сигнатура:
const { data, error } = await supabase
.rpc(fn, params, options)
fn
- название функции;params
- параметры, передаваемые функции;options
:head
;count
.
Предположим, что мы определили такую функцию:
CREATE FUNCTION check_password(username_or_email TEXT, password_hash TEXT)
RETURNS BOOLEAN AS
$$
DECLARE is_password_correct BOOLEAN;
BEGIN
SELECT (hashed_password = $2) INTO is_password_correct
FROM users
WHERE username = $1 OR email = $1;
RETURN is_password_correct;
END;
$$
LANGUAGE plpgsql
Данную функцию можно вызвать следующим образом:
async function checkPassword(username_or_email, password) {
let password_hash
try {
password_hash = await bcrypt.hash(password, 10)
} catch (e) {
throw e
}
try {
const { data, error } = await supabase
.rpc('check_password', { username_or_email, password_hash })
if (error) throw error
return data
} catch(e) {
throw e
}
}
Метод rpc
может использоваться совместно с модификаторами и фильтрами, например:
const { data, error } = await supabase
.rpc('get_all_cities')
.select('city_name', 'population')
.eq('city_name', 'The Shire')
Модификаторы используются в запросах на выборку данных (select
). Они также могут использоваться совместно с методом rpc
, когда он возвращает таблицу.
limit()
Модификатор limit
ограничивает количество возвращаемых строк.
Сигнатура:
const { data, error } = await supabase
.from(table_name)
.select(column_names)
.limit(count, options)
count
: количество возвращаемых строк;options
:foreignTable
: внешняя таблица (для колонок с внешним ключом).
Пример:
async function getTop10Users() {
try {
const { data, error } = await supabase
.from('users')
.select('id, user_name, email')
.limit(10)
if (error) throw error
return data
} catch(e) {
throw e
}
}
Пример с внешней таблицей:
async function getTop10CitiesByCountry(country_name) {
try {
const { data, error } = await supabase
.from('countries')
.select('country_name, cities(city_name)')
.eq('country_name', 'Rus')
.limit(10, { foreignTable: 'cities' })
if (error) throw error
return data
} catch(e) {
throw e
}
}
order()
Модификатор order
сортирует строки в указанном порядке.
Сигнатура:
const { data, error } = await supabase
.from(table_name)
.select(column_names)
.order(column_name, options)
column_name
: название колонки, по которой выполняется сортировка;options
:ascending
: если имеет значениеtrue
, сортировка выполняется по возрастанию;nullsFirst
: еслиtrue
, колонки со значениемnull
идут впереди;foreignTable
: название внешней таблицы.
Пример:
async function getMostLikedPosts(limit) {
try {
const { data, error } = await supabase
.from('posts')
.select()
.order('like_count', { ascending: true })
.limit(limit)
if (error) throw error
return data
} catch (e) {
throw e
}
}
range()
Модификатор range
возвращает диапазон строк (включительно).
Сигнатура:
const { data, error } = await supabase
.from(table_name)
.select(column_names)
.range(start, end, options)
start
: начало диапазона;end
: конец диапазона;options
:foreignTable
.
Пример:
async function getPostSlice(from, to) {
try {
const { data, error } = await supabase
.from('posts')
.select()
.range(from, to)
if (error) throw error
return data
} catch(e) {
throw e
}
}
single()
Модификатор single
возвращает одну строку.
Пример:
async function getUserById(user_id) {
try {
// в данном случае мы получим объект вместо массива
const { data, error } = await supabase
.from('users')
.select('id, user_name, email')
.eq('id', user_id)
.single()
if (error) throw error
return data
} catch(e) {
throw e
}
}
Существует также модификатор maybeSingle
, возвращающий хотя бы одну строку.
Фильтры могут использоваться в запросах select
, update
и delete
, а также в методе rpc
, когда функция возвращает таблицу.
Фильтры должны применяться в конце запроса. Их можно объединять в цепочки и применять условно.
Примеры:
const { data, error } = await supabase
.from('cities')
.select()
.eq('id', '1342')
.single()
const { data, error } = await supabase
.from('cities')
.select()
.gte('population', 1000)
.lt('population', 10000)
const filterByName = null
const filterPopLow = 1000
const filterPopHigh = 10000
let query = supabase
.from('cities')
.select()
if (filterByName) { query = query.eq('name', filterByName) }
if (filterPopLow) { query = query.gte('population', filterPopLow) }
if (filterPopHigh) { query = query.lt('population', filterPopHigh) }
const { data, error } = await query
Виды фильтров:
or('filter1, filter2, ...filterN', { foreignTable })
- значение колонки должно удовлетворять хотя бы одному условию, например:or('population.gt.1000, population.lte.100000')
; может использоваться в сочетании с фильтромand
;not(column_name, operator, value)
- значение колонки не должно удовлетворять условию, например:not('population', 'gte', 100000)
;match(query)
- строка должна точно соответствовать объекту, например:match({ user_name: 'Harry', life_style: 'webdev' })
;eq(column_name, column_value)
- строка должна соответствовать условию, например:eq('user_name', 'Harry')
neq(column_name, column_value)
- противоположностьeq
;gt(column_name, value)
- строка должна содержать колонку, которая имеет значение, большее чем указанное (greater than), например:gt('age', 17)
;gte(column_name, value)
,lt(column_name, value)
,lte(column_name, value)
- больше или равно, меньше и меньше или равно, соответственно;like(column_name, pattern)
иilike(column_name, pattern)
- возвращает все строки, значения указанной колонки которых совпадает с паттерном (шаблоном), например,like('user_name', '%oh%')
;is(column_name, value)
- значение колонки должно точно совпадать с указанным значением, например,is('married', false)
;in(column_name, column_value[])
- значение колонки должно совпадать хотя бы с одним из указанных в виде массива значений, например,in('city_name', ['Paris', 'Tokyo'])
;contains(column_name, [column_value])
-contains('main_lang', ['javascript'])
;overlaps(column_name, column_value[])
-overlaps('hobby', ['code', 'guitar'])
;textSearch(column_name, query, options)
- возвращает все строки, значения указанной колонки которой соответствуют запросуto_tsquery
, например:
const { data, error } = await supabase
.from('quotes')
.select('catchphrase')
.textSearch('catchphrase', `'fat' & 'cat'`, {
config: 'english'
})
Настройки также принимают второй параметр type
, возможными значениями которого являются plain
и phrase
, определяющие базовую и полную нормализацию, соответственно.
filter(column_name, operator, value)
- значение колонки должно удовлетворять фильтру.filter
должен использоваться в последнюю очередь, когда других фильтров оказалось недостаточно:
const { data, error } = await supabase
.from('cities')
.select('city_name, countries(city_name)')
.filter('countries.city_name', 'in', '("France", "Japan")')
Также имеется несколько других (более специфичных) фильтров.
Для создания файлового хранилища используется метод createBucket
:
async function createAvatarBucket() {
try {
const { data, error } = await supabase
.storage
// createBucket(id, options)
.createBucket('avatars', { public: false })
if (error) throw error
return data
} catch (e) {
throw e
}
}
Для создания хранилища требуются следующие разрешения (permissions policy):
buckets
:insert
;objects
:none
.
В Supabase
безопасность работы с данными и файлами обеспечивается установкой и настройкой row level security (политик защиты строк). Мы рассмотрим их в следующей статье.
Для получения данных о хранилище используется метод getBucket
:
const { data, error } = await supabase
.storage
// getBucket(id)
.getBucket('avatars')
Требуются следующие разрешения:
buckets
:select
;objects
:none
.
Для получения списка хранилищ используется метод listBuckets
:
const { data, error } = await supabase
.storage
.listBuckets()
Требуются аналогичные разрешения.
Для обновления хранилища используется метод updateBucket(id, options)
(разрешение buckets
: update
), а для удаления - метод deleteBucket(id)
(buckets
: select
и delete
).
Обратите внимание: непустое хранилище не может быть удалено без его предварительной очистки с помощью метода emptyBucket(id)
(buckets
: select
, objects
: select
и delete
).
Загрузка файлов
Для загрузки файлов в хранилище используется метод upload
.
Сигнатура
await supabase
.storage
.from(bucket_name)
.upload(path, file, options)
bucket_name
- названия хранилища;path
- относительный путь к файлу. Должен иметь форматfolder/subfolder/fileName.fileExtension
. Разумеется, записывать файлы можно только в существующие хранилища;file
- загружаемый в хранилище файл: ArrayBuffer | ArrayBufferView | Blob | Buffer | File | FormData | ReadableStream | URLSearchParams | string (т.е. практически что угодно);options
- дополнительные настройки:cacheControl
: HTTP-заголовокCache-Control
;contentType
: тип содержимого, по умолчанию имеет значениеtext/plain;charset=utf-8
;upsert
: логический индикатор вставки (upsert) файла.
Разрешения:
buckets
:none
;objects
:insert
.
Пример
async function uploadAvatar({ userId, file }) {
// получаем расширение файла
const fileExt = file.name.split('.')[1]
try {
const { data, error } = await supabase
.storage
.from('users')
.upload(`avatars/${userId}.${fileExt}`, file, {
cacheControl: '3600',
upsert: false
})
if (error) throw error
return data
} catch(e) {
throw e
}
}
Скачивание файлов
Для скачивания файлов используется метод download
:
async function downloadFile({ bucketName, filePath }) {
try {
const { data, error } = await supabase
.storage
.from(bucketName)
.download(filePath)
if (error) throw error
return data
} catch(e) {
throw e
}
}
Разрешения:
buckets
:none
;objects
:select
.
Получение списка файлов
Для получения списка файлов используется метод list
.
Сигнатура
list(dir_name, options, parameters)
dir_name
: название директории;options
:limit
: количество возвращаемых файлов;offset
: количество пропускаемых файлов;sortBy
:column_name
: название колонки для сортировки;order
: порядок сортировки.
parameters
:signal
:AbortController.signal
.
Пример
async function getAvatars({ limit = 100, offset = 0, sortBy = { column: 'name', order: 'asc' } }) {
try {
const { data, error } = await supabase
.storage
.from('users')
.list('avatars', {
limit,
offset,
sortBy
})
if (error) throw error
return data
} catch(e) {
throw e
}
}
Разрешения:
buckets
:none
;objects
:select
.
Обновление файлов
Для обновления файлов используется метод update
.
Сигнатура
update(path, file, options)
options
: настройки, аналогичные передаваемымupload
.
Пример
const defaultUpdateAvatarOptions = { cacheControl: '3600', upsert: false }
async function updateAvatar({ filePath, file, options = defaultUpdateAvatarOptions }) {
try {
const { data, error } = await supabase
.storage
.from('users')
.update(`avatars/${filePath}`, file, options)
if (error) throw error
return data
} catch(e) {
throw e
}
}
Разрешения:
buckets
:none
;objects
:select
иupdate
.
Для перемещения файла с его опциональным переименованием используется метод move
:
const { data, error } = await supabase
.storage
.from('avatars')
.move('public/avatar1.png', 'private/moved_avatar.png')
Разрешения:
buckets
:none
;objects
:select
иupdate
.
Удаление файлов
Для удаления файлов используется метод remove
:
// список удаляемых файлов - массив `filePathArr`
async function removeFile({ bucketName, filePathArr }) {
try {
const { data, error } = await supabase
.storage
.from(bucketName)
.remove(filePathArr)
if (error) throw error
return data
} catch(e) {
throw e
}
}
Разрешения:
buckets
:none
;objects
:select
иdelete
.
Формирование пути к файлу
Для формирования пути к файлу без разрешения используется метод createSignedUrl
:
createSignedUrl(path, expiresIn)
expiresIn
- количество секунд, в течение которых ссылка считается валидной.
async function getSignedUrl({ bucketName, filePath, expiresIn = 60 }) {
try {
const { signedUrl, error } = await supabase
.storage
.from(bucketName)
.createSignedUrl(filePath, expiresIn)
if (error) throw error
return signedUrl
} catch(e) {
throw e
}
}
Разрешения:
buckets
:none
;objects
:select
.
Для формирования пути к файлу, находящемуся в публичной директории, используется метод getPublicUrl
:
async function getFileUrl({ bucketName, filePath }) {
try {
const { publicUrl, error } = supabase
.storage
.from(bucketName)
.getPublicUrl(filePath)
if (error) throw error
return publicUrl
} catch(e) {
throw e
}
}
Разрешения не требуются.
Подписка на изменения
Для подписки на изменения, происходящие в БД, используется метод subscribe
в сочетании с методом on
.
Сигнатура
const subscription = supabase
.from(table_name)
.on(event, callback)
event
- регистрируемое событие. Для регистрации всех событий используется'*'
;callback
- функция обработки полезной нагрузки.
Обратите внимание:
realtime
отключена по умолчанию для новых проектов по причинам производительности и безопасности;- для того, чтобы получать "предыдущие" данные для обновлений и удалений, необходимо установить
REPLICA IDENTITY
в значениеFULL
, например:ALTER TABLE table_name REPLICA IDENTITY FULL;
.
Примеры
Регистрация всех изменений всех таблиц:
const subscription = supabase
.from('*')
.on('*', payload => {
console.log('***', payload)
})
.subscribe()
Регистрация изменений определенной таблицы:
const subscription = supabase
.from('posts')
.on('*', payload => {
console.log('***', payload)
})
.subscribe()
Регистрация записи данных в определенную таблицу:
const subscription = supabase
.from('posts')
// INSERT | UPDATE | DELETE
.on('INSERT', payload => {
console.log('***', payload)
})
.subscribe()
Обработчики можно объединять в цепочки:
const subscription = supabase
.from('posts')
.on('INSERT', insertHandler)
.on('DELETE', deleteHandler)
.subscribe()
Имеется возможность регистрации изменений определенных строк. Синтаксис:
table_name:column_name=eq.value
table_name
: название таблицы;column_name
: название колонки;value
: значение, которое должна содержать колонка.
const subscription = supabase
.from('countries.id=eq.123')
.on('UPDATE', onUpdate)
.subscribe()
Отписка от изменений
Метод removeSubscription
удаляет активную подписку и возвращает количество открытых соединений.
supabase.removeSubscription(subscription_name)
subscription_name
- название подписки.
Для удаления всех подписок и закрытия WebSocket-соединения
используется метод removeAllSubscriptions
.
Получение списка подписок
Для получения списка подписок используется метод getSubscriptions
.
const allSubscriptions = supabase.getSubscriptions()
Обратите внимание: для разработки клиента я буду использовать React, но вы можете использовать свой любимый JS-фреймворк
- функционал, связанный с Supabase
, будет что называется framework agnostic
. Также обратите внимание, что разработанное нами приложение не будет 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
нужен клиент для взаимодействия с 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: '[email protected]',
password: 'password'
},
{
data: {
user_name: 'Alice'
}
}
)
const { user: bobAuth } = await supabase.auth.signUp(
{
email: '[email protected]',
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
предоставляет разработчикам интересные возможности по созданию фулстек-приложений, позволяя практически полностью сосредоточиться на клиентской части. So give it a chance!