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

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



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


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


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


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


Что такое Supabase?


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


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


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


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


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


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

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








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





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


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

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


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





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


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


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

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





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


CREATE TABLE users (
  id text PRIMARY KEY NOT NULL,
  email text NOT NULL,
  user_name text NOT NULL,
  first_name text,
  last_name text,
  age int,
  avatar_url text,
  created_at timestamp DEFAULT now()
);

CREATE TABLE posts (
  id serial PRIMARY KEY,
  title text NOT NULL,
  content text NOT NULL,
  user_id text NOT NULL,
  created_at timestamp DEFAULT now()
);

CREATE TABLE comments (
  id serial PRIMARY KEY,
  content text NOT NULL,
  user_id text NOT NULL,
  post_id int NOT NULL,
  created_at timestamp DEFAULT now()
);

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


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





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








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


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





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





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





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





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


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


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


alter publication supabase_realtime
add table users, posts, comments;

Нажимаем RUN.


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


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

# зависимость для разработки
yarn add -D sass

  • @supabase/supabase-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>&copy; 2022. Not all rights reserved</p>
      </footer>
    </div>
  )
}

export default App

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


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


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


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


import { createClient } from '@supabase/supabase-js'
import serializeUser from '../utils/serializeUser.js'
import { config } from 'dotenv'

// получаем доступ к переменным среды окружения
config()

// создаем клиента `Supabase`
const supabase = createClient(
  process.env.VITE_SUPABASE_URL,
  process.env.VITE_SUPABASE_KEY
)

// создаем 2 пользователей, `Alice` и `Bob` с постами и комментариями
async function seedDb() {
  try {
    const { user: aliceAuth } = await supabase.auth.signUp(
      {
        email: 'alice@mail.com',
        password: 'password'
      },
      {
        data: {
          user_name: 'Alice'
        }
      }
    )
    const { user: bobAuth } = await supabase.auth.signUp(
      {
        email: 'bob@mail.com',
        password: 'password'
      },
      {
        data: {
          user_name: 'Bob'
        }
      }
    )
    const {
      data: [alice, bob]
    } = await supabase
      .from('users')
      .insert([serializeUser(aliceAuth), serializeUser(bobAuth)])

    const { data: alicePosts } = await supabase.from('posts').insert([
      {
        title: `Alice's first post`,
        content: `This is Alice's first post`,
        user_id: alice.id
      },
      {
        title: `Alice's second post`,
        content: `This is Alice's second post`,
        user_id: alice.id
      }
    ])
    const { data: bobPosts } = await supabase.from('posts').insert([
      {
        title: `Bob's's first post`,
        content: `This is Bob's first post`,
        user_id: bob.id
      },
      {
        title: `Bob's's second post`,
        content: `This is Bob's second post`,
        user_id: bob.id
      }
    ])
    for (const post of alicePosts) {
      await supabase.from('comments').insert([
        {
          user_id: alice.id,
          post_id: post.id,
          content: `This is Alice's comment on Alice's post "${post.title}"`
        },
        {
          user_id: bob.id,
          post_id: post.id,
          content: `This is Bob's comment on Alice's post "${post.title}"`
        }
      ])
    }
    for (const post of bobPosts) {
      await supabase.from('comments').insert([
        {
          user_id: alice.id,
          post_id: post.id,
          content: `This is Alice's comment on Bob's post "${post.title}"`
        },
        {
          user_id: bob.id,
          post_id: post.id,
          content: `This is Bob's comment on Bob's post "${post.title}"`
        }
      ])
    }
    console.log('Done')
  } catch (e) {
    console.error(e)
  }
}
seedDb()

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


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





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








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


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








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


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











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


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








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





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


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


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


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


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




Источник: https://habr.com/ru/company/timeweb/blog/660183/


Интересные статьи

Интересные статьи

Поздравляю, по крайней мере, всех живущих в Сибири с наступлением лета!)))Сегодня довольно непростая тема - навигация.Мы рассмотрим как устроена навигация в Flutter, что ...
В прошлый раз мы оставили сцену Xbox 360 на моменте, когда разработчикам стало очевидно, что защита DVD-ROM легко обходится, и с этим точно нужно что-то делать. Попытки исправить ситуацию обнов...
И вновь доброго времени суток! Данная статья является продолжением ранее опубликованной статьи о нашей «темной» Вселенной. В данной части мы продолжим рассмотрение различных интересных особенност...
Автор статьи, перевод которой мы сегодня публикуем, говорит, что сейчас можно наблюдать рост популярности таких сервисов аутентификации, как Google Firebase Authentication, AWS Cognito и Auth0. И...
Хабр, привет! Сегодня я покажу, как бесплатно проанализировать интересующий вас сайт через сервис Similarweb. Подробно расскажу обо всех нюансах и проведу по всем шагам от регистрации...