Давайте создадим свой собственный раздел комментариев, потому что… почему бы и нет?
Привет, друзья! В этой серии мы будем использовать сексуальную комбинацию — Next.js + Supabase — для создания раздела комментариев для блогов.
Наша цель
Как и во всех старых добрых руководствах по Todo list, мы создадим простые CRUD (Create, Read, Update and Delete) функции для наших комментариев.
- Добавление комментариев
- Чтение комментариев
- Редактирование комментариев
- Удаление комментариев
- Ответ на комментарии
Код следования можно найти здесь.
Что нам нужно?
Очевидно, что мы будем использовать эти три компонента:
- Next.js: Возможно, лучший готовый к производству фронтенд-фреймворк на этой планете.
- Supabase: Модный продукт BaaS (Backend as a Service) для любителей PostgreSQL.
А также некоторые дополнения, которые сделают наш проект супер-простым:
- TailwindCSS: CSS-библиотека, которая сделает создание стиля очень простым.
- SWR: супер-простая библиотека для выборки/кэширования данных
Локальная установка: Next.js и TailwindCSS
Создание приложения Next
Сначала мы создадим базовое приложение Next.js на основе Typescript, используя npx create-next-app
.
$ npx create-next-app --typescript supabase-comments
Когда проект будет создан, перейдите в каталог files и вы увидите эти базовые файлы.
Все они нам не понадобятся, поэтому мы удалим некоторые из них.
...node_modules
├── package.json
├── pages
│ ├── _app.tsx
│ ├── api
│ │ └── hello.ts
│ └── index.tsx
├── public # <- Remove
│ ├── favicon.ico # <- Remove
│ └── vercel.svg # <- Remove
├── styles
│ ├── Home.module.css # <- Remove
│ └── globals.css
├── tsconfig.json
└── yarn.lock
Добавьте TailwindCSS для NextJS
Все вышеперечисленное сделано, тогда добавим TailwindCSS & другие зависимости для нашей стилизации.
$ yarn add -D tailwindcss postcss autoprefixer
$ yarn tailwindcss init -p
Последняя команда создаст файл tailwindcss.config.js
, который является конфигурационным javascript файлом для TailwindCSS.
Этот простой файл делает многое, но сейчас мы просто определим, за какими файлами должен следить TailwindCSS.
module.exports = {
content: [
"./pages/**/*.{js,ts,jsx,tsx}",
"./components/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
Затем замените содержимое файла styles/globals.css
на код, приведенный ниже.
@tailwind base;
@tailwind components;
@tailwind utilities;
Теперь мы можем правильно использовать наш TailwindCSS с NextJS!
Чтобы проверить, работает ли он, замените содержимое в pages/index.tsx
на приведенный ниже код.
import type { NextPage } from "next";
import Head from "next/head";
const Home: NextPage = () => {
return (
<div>
<Head>
<title>Comments Page</title>
</Head>
<div className="p-12">
<h1 className="text-2xl font-bold">Comments!</h1>
</div>
</div>
);
};
export default Home;
Выполните следующую команду, чтобы обслужить веб-страницу в среде разработки,
$ yarn dev
… и перейдите по адресу http://localhost:3000, чтобы увидеть жирный шрифт «Комментарии!» на пустой веб-странице.
Отлично! Мы закончили с локальными настройками (кроме SWR, но мы установим его позже), поэтому перейдем к удаленным настройкам.
Удаленная настройка: Supabase
Создайте организацию и проект
Перейдите на официальный сайт Supabase и войдите под своей учетной записью Github.
После этого вы попадете на панель приложений.
Нажмите New Project
, а затем нажмите + New Organization
, чтобы создать новую команду проекта.
(Если у вас уже есть организация, можете не создавать ее).
Затем он предложит вам создать новое имя для вашей организации. Просто введите любое крутое название и нажмите Create organization
.
Теперь для организации, которую мы только что создали, создадим новый проект, который будет содержать таблицы SQL.
- Имя: Я просто назову его «Master Supabase», но название не имеет значения.
- Пароль базы данных: Для пароля попробуйте использовать инструмент passgen для создания надежного пароля. Я обычно создаю его с помощью PassGen.co, длина которого больше 14.
- Регион: Выберите ближайший к месту вашего проживания (как для меня, Сеул).
- Ценовой план: Выберите «Бесплатный уровень», но вы можете повысить его позже, если захотите.
После заполнения формы нажмите Создать новый проект
.
И теперь у нас есть наш новый проект Supabase! Не стесняйтесь исследовать его, узнайте, что вы можете сделать.
Создание таблицы comments
Теперь мы создадим SQL-таблицу под названием comments
. Для этого щелкните по меню ‘Table editor’ в левой панели панели инструментов.
Если ваш проект новый, то в нем не будет никаких таблиц. Давайте нажмем Создать новую таблицу
.
После этого появится боковая панель для вставки формы для настроек таблицы.
- Имя:
comments
, но не стесняйтесь выбрать другое имя. - Описание: Это необязательно, и я собираюсь пропустить его в этот раз.
- Включить безопасность на уровне строк: Это модная функция в Supabase, но мы расскажем о ней в следующем посте. Сейчас мы ее просто пропустим.
- Колонки: Мы отредактируем & добавим несколько колонок, как показано на рисунке ниже.
Убедитесь, что вы изменили тип колонки ‘id’ на uuid!
Если вы закончили и подтвердили форму, нажмите кнопку Save
.
Теперь у нас есть наша таблица комментариев! Позвольте мне объяснить роль каждого столбца таблицы:
Создать комментарий
Добавьте форму ввода и клиентскую библиотеку Supabase
Наша индексная страница сейчас пуста, поэтому мы должны привести ее в порядок. Замените код в pages/index.tsx
на код ниже.
import type { NextPage } from "next";
import Head from "next/head";
const Home: NextPage = () => {
return (
<div>
<Head>
<title>Comments Page</title>
</Head>
<div className="pt-36 flex justify-center">
<div className="min-w-[600px]">
<h1 className="text-4xl font-bold ">Comments</h1>
<form onSubmit={onSubmit} className="mt-8 flex gap-8">
<input type="text" placeholder="Add a comment" className="p-2 border-b focus:border-b-gray-700 w-full outline-none" />
<button className="px-4 py-2 bg-green-500 rounded-lg text-white">Submit</button>
</form>
</div>
</div>
</div>
);
};
export default Home;
Мы добавили простую форму ввода для создания комментария. Добавлены некоторые коды стилизации,
но так как это не учебник по TailwindCSS, я оставлю объяснения для лучшего ресурса.
Наша форма выглядит хорошо, но на самом деле она ничего не делает. Чтобы создать комментарий, мы должны проделать следующий процесс:
- Пользователь вводит комментарий в форму, затем нажимает
Submit
. - Мы каким-то образом отправляем данные комментария в нашу таблицу Supabase
comments
. - Мы проверяем нашу таблицу, чтобы убедиться, что данные были добавлены.
Для выполнения второго шага нам нужна клиентская библиотека узла Supabase. Выполните приведенную ниже команду, чтобы добавить ее.
$ yarn add @supabase/supabase-js
Создайте наш мессенджер для Supabase
Теперь нам нужно создать объект клиента supabase, который представляет собой мессенджер, который поможет нам взаимодействовать с Supabase.
Добавьте эти 2 строки в pages/index.tsx
.
...
import { createClient } from "@supabase/supabase-js";
export const supabase = createClient(supabaseUrl, supabaseKey);
const Home: NextPage = () => {
return (
<div>
...
Чтобы создать объект клиента Supabase, нам нужны два данных: URL проекта Supabase и ключ.
Их можно найти в разделе Settings > Project settings > API
в нашей панели управления проектом Supabase.
Понимание переменных окружения
Эти ключи должны храниться в безопасном и отдельном месте.
Многие разработчики сохраняют защищенные данные в виде «переменных окружения», обычно сокращенно называемых «env vars».
Env vars также работают как «переменные», которые могут быть установлены по-разному в разных средах.
В нашем случае нам нужно определить env vars для среды разработки, и для этого в NextJS мы используем файл .env.local
.
Если вы хотите использовать те же переменные в производственной среде, вы можете использовать файл .env.production
и заменить значения.
Итак, давайте создадим файл .env.local
в корневом каталоге нашего приложения NextJS.
Затем скопируйте-вставьте первый ключ (ключ anon/public) из изображения выше и сохраните его в NEXT_PUBLIC_SUPABASE_KEY
.
Для второго ключа (ключ URL) сохраните его в NEXT_PUBLIC_SUPABASE_URL
.
Если все сделано правильно, все должно выглядеть следующим образом.
NEXT_PUBLIC_SUPABASE_KEY=[first-key]
NEXT_PUBLIC_SUPABASE_URL=[second-key]
Теперь, что это за префикс NEXT_PUBLIC_
? NextJS по-разному обрабатывает env-вары по их названиям:
- С префиксом
NEXT_PUBLIC_
: Они открыты в браузере, что означает, что их можно использовать в заданиях на стороне клиента. - Без префикса
NEXT_PUBLIC_
: Это задания на стороне сервера.
Это означает, что наша supabase в основном использует эти ключи на стороне браузера. Как только мы определим или отредактируем наш файл .env.local
, мы должны перезапустить сервер разработки.
сервер разработки, поэтому перейдите в терминал и убейте текущую сессию с помощью CTRL-C
, а затем перезапустите с помощью yarn dev
.
Поскольку теперь мы можем использовать наши env vars, добавьте и отредактируйте следующие строки в pages/index.tsx
.
...
import { createClient } from "@supabase/supabase-js";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL ?? "";
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_KEY ?? "";
export const supabase = createClient(supabaseUrl, supabaseKey);
const Home: NextPage = () => {
return (
<div>
...
Дополнительные ? ""
после каждой инициализации env var для того, чтобы они не были неопределенного
типа, который тогда createClient
не примет.
Отправка запроса insert
Прежде чем использовать наш мессенджер supabase
, мы сначала получим полезную нагрузку комментария пользователя из нашей формы ввода.
Для этого,
- Добавьте состояние реакции
comment
для заполнителя пользовательского комментария. - Добавьте функцию
onChange
для обновления полезной нагрузки комментария вcomment
при каждом изменении полезной нагрузки. - Добавьте функцию
onSubmit
для обработки поведения формы при отправке. В нашем случае мы не хотим перезагружать форму каждый раз при отправке, поэтому мы используемevent.preventDefault()
.
Изменения в коде будут выглядеть следующим образом.
...
const Home: NextPage = () => {
const [comment, setComment] = useState<string>("");
const onChange = (event: ChangeEvent<HTMLInputElement>) => {
const commentValue = event.target.value;
setComment(commentValue);
};
const onSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
console.log(comment);
};
...
<form onSubmit={onSubmit} className="mt-8 flex gap-8">
<input
onChange={onChange}
type="text"
placeholder="Add a comment"
className="p-2 border-b focus:border-b-gray-700 w-full outline-none"
/>
...
Чтобы проверить, работает ли это, откройте Devtools для вашего браузера и перейдите на вкладку Console
, введите что-нибудь в поле ввода и нажмите Submit
.
Если все получилось, появится изображение, как на рисунке ниже.
Теперь мы будем использовать наш клиент supabase для создания комментария. С точки зрения SQL-таблицы, это, по сути, добавит новую строку.
Замените функцию onSubmit
на приведенный ниже код. Не забудьте добавить ключевое слово async
, поскольку api клиента supabase возвращает Promise
.
...
const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const { data, error } = await supabase.from("comments").insert({
username: "hoonwee@email.com",
payload: comment,
});
if (!error && data) {
// If succeed
window.alert("Hooray!");
} else {
// If failed
window.alert(error?.message);
}
};
...
Позвольте мне быстро разобрать часть await supabase
:
-
.insert(table_row)
: Используяinsert
, мы создаем новую строку в нашей таблицеcomments
с данными table_row.- Как вы видите, мы поместили только
username
иpayload
в нашуtable_row
, потому что остальные данные будут иметь значение по умолчанию.
- Как вы видите, мы поместили только
-
const { data, error }
: Supabase выдаст нам ответ, содержащийdata
, который содержит информацию о нашем действии, иerror
, если произошла ошибка.
Теперь давайте снова напишем что-нибудь и нажмем Submit
. И если у вас все получилось, вы увидите окно оповещения, содержащее сообщение Hooray!
Это очень хорошо, но мы все еще не знаем, были ли наши данные отправлены или нет.
Перейдите к таблице comments
в приборной панели Supabase, и вы увидите, что вставлена новая строка данных.
Прочитать комментарии
Создание списка комментариев
Отлично, теперь мы можем создавать комментарии, но мы также хотим отображать их на нашей веб-странице.
Для этого мы выполним следующие действия:
- Получаем все комментарии из таблицы
comments
. - Отображаем их в виде списка.
- Мы отсортируем их по данным
created_at
, чтобы увидеть их в хронологическом порядке.
Сначала мы должны добавить некоторый пользовательский интерфейс для списка! Добавьте следующий код в pages/index.tsx
.
...
export const supabase = createClient(supabaseUrl, supabaseKey);
interface CommentParams {
id: string;
created_at: string;
updated_at: string;
username: string;
payload: string;
reply_of?: string;
}
const Home: NextPage = () => {
const [comment, setComment] = useState<string>("");
const [commentList, setCommentList] = useState<CommentParams[]>([]);
...
Submit
</button>
</form>
<div className="flex flex-col gap-4 pt-12">
{commentList.map((comment) => (
<div key={comment.id} className="border rounded-md p-4">
<p className="font-semibold mb-2">{comment.username}</p>
<p className="font-light">{comment.payload}</p>
</div>
))}
</div>
</div>
...
Давайте быстро разберем эту часть:
- Ниже нашего элемента
form
мы добавили секцию списка комментариев, итерируемую реактивным состояниемcommentList
, которое мы недавно создали. - Состояние
commentList
имеет массив типа интерфейсаCommentParams
, который содержит все имена столбцов для каждого ключа объекта. - Вопросительный знак
?
в полеreply_of
указывает на то, что это поле является необязательным.
Отправьте запрос select
.
Прежде чем перейти к следующему шагу, я хочу, чтобы вы добавили больше комментариев с помощью нашей формы
- потому что, когда нам удастся получить комментарии из supabase, это будет выглядеть потрясающе, когда у нас будет куча комментариев.
Как только вы добавили комментарии, теперь добавим новую функцию getCommentList
, которая будет использовать клиент supabase
, чтобы
получить все комментарии. Добавьте код ниже.
...
const [commentList, setCommentList] = useState<CommentParams[]>([]);
const getCommentList = async () => {
const { data, error } = await supabase.from("comments").select("*");
if (!error && data) {
setCommentList(data);
} else {
setCommentList([]);
}
};
useEffect(() => {
getCommentList();
}, []);
...
Сейчас функция getCommentList
будет вызвана только один раз, при первом рендеринге нашей страницы.
Для этого мы вызовем нашу getCommentList
в хуке useEffect
. Поскольку хук useEffect
не имеет внешних зависимостей,
это вызовет внутреннюю часть только один раз, когда компонент будет отображен.
Теперь проверьте нашу веб-страницу. Она будет выглядеть так же, как и другие разделы комментариев!
Сортировка комментариев по порядку создания
Теперь наш клиент Supabase получает список комментариев в порядке created
,
но вскоре, когда мы редактируем и отвечаем на некоторые комментарии, он выводит их в порядке updated
.
Поэтому мы должны немного подправить наш код, чтобы отсортировать их.
...
<div className="flex flex-col gap-4 pt-12">
{commentList
.sort((a, b) => {
const aDate = new Date(a.created_at);
const bDate = new Date(b.created_at);
return +aDate - +bDate;
})
.map((comment) => (
<div key={comment.id} className="border rounded-md p-4">
<p className="font-semibold mb-2">{comment.username}</p>
...
Разбираем это на части:
- Мы добавили
.sort
перед тем, как сделать.map
в части рендеринга списка комментариев. - Логика внутри
.sort
будет упорядочивать комментарии от самого старого к самому молодому. - Знак
+
в началеaDate
иbDate
служит для приведения типаDate
к типуnumber
, так как возвращаемое значениеsort()
в Typescript должно быть в типеnumber
.
Обновление и удаление комментариев
Планирование функции
Мы совершаем ошибки, особенно часто, когда пишем что-то в интернете.
Именно поэтому в постах и комментариях в Facebook, Medium, Twitter и т.д. всегда есть разделы Edit
и Delete
.
Правильно работающая функция редактирования должна обладать такими возможностями:
- Возможность редактировать полезную нагрузку (содержимое) прямо в самом элементе комментария.
- Отключать кнопку редактирования, если полезная нагрузка не изменилась.
- Указывать, был ли этот комментарий отредактирован.
Отличная функция удаления должна:
- Спросить пользователя, действительно ли он собирается удалить комментарий, чтобы предотвратить, если это была ошибка при нажатии.
- Затем удалить его.
Создайте форму ввода для редактирования
Для лучшего пользовательского опыта форма ввода для редактирования комментария должна находиться не там же, где вы добавляете комментарий, а в самом элементе списка комментариев.
Это означает, что мы должны обновить наш элемент списка комментариев, поэтому давайте добавим следующий код, чтобы сделать это!
...
const Home: NextPage = () => {
const [comment, setComment] = useState<string>("");
const [commentList, setCommentList] = useState<CommentParams[]>([]);
const [editComment, setEditComment] = useState<EditCommentParams>({
id: "",
payload: "",
});
const onChangeEditComment = (event: ChangeEvent<HTMLInputElement>) => {
const payload = event.target.value;
setEditComment({ ...editComment, payload });
};
const confirmEdit = () => {
window.alert("Confirm edit comment");
};
const getCommentList = async () => {
const { data, error } = await supabase.from("comments").select("*");
...
<div key={comment.id} className="border rounded-md p-4">
<p className="font-semibold mb-2">{comment.username}</p>
<div className="flex items-center gap-2 justify-between">
{comment.id === editComment.id ? (
<input
type="text"
value={editComment.payload}
onChange={onChangeEditComment}
className="pb-1 border-b w-full"
/>
) : (
<p className="font-light">{comment.payload}</p>
)}
{editComment.id === comment.id ? (
<div className="flex gap-2">
<button type="button" onClick={confirmEdit} className="text-green-500">
Confirm
</button>
<button
type="button"
onClick={() => setEditComment({ id: "", payload: "" })}
className="text-gray-500"
>
Cancel
</button>
</div>
) : (
<button
type="button"
onClick={() => setEditComment({ id: comment.id, payload: comment.payload })}
className="text-green-500"
>
Edit
</button>
)}
</div>
</div>
))}
</div>
...
Краткий анализ этого пункта:
- Мы добавили состояние
editComment
, которое будет устанавливать, какой комментарий редактировать и какой полезной нагрузкой он должен быть. - Мы добавили две функции:
- Мы обновили нашу секцию элементов комментария, чтобы переключаться между «режимом чтения» и «режимом редактирования» с помощью состояния
editComment
.
Конструирование запроса update
.
Теперь осталось только заменить функцию confirmEdit
для взаимодействия с Supabase.
Замените эту часть этим кодом. Я уверен, что теперь вы знакомы с supabase
api.
...
const confirmEdit = async () => {
const { data, error } = await supabase
.from("comments")
.update({
payload: editComment.payload,
})
.match({ id: editComment.id });
if (!error && data) {
window.alert("Edited Comment!");
} else {
window.alert(error?.message);
}
};
...
Итак, очевидно, что из этого кода,
- Мы использовали функцию
update
для обновления данных.- Нам нужно передать только измененную часть, а не все остальные части.
- Затем мы использовали функцию
match
, чтобы определить, какие комментарии должны быть обновлены.
Но подождите, разве мы не должны обновить updated_at
?
Правильно! Мы сделаем это в Supabase Dashboard, а не в нашем коде Next.js.
Для этого мы будем использовать реальный SQL-запрос, а для этого перейдем в SQL Editor через панель навигации.
Затем вы увидите поле ввода для записи SQL-запроса. Вставьте SQL-запрос ниже.
create extension if not exists moddatetime schema extensions;
create trigger handle_updated_at before update on comments
for each row execute procedure moddatetime (updated_at);
Этот запрос требует много объяснений, но в основном он будет устанавливать столбец updated_at
в текущую метку времени для каждого обновления.
Нажмите Run
, чтобы запустить запрос и адаптировать триггер.
Теперь наш запрос на редактирование будет работать как шарм. Попробуйте отредактировать любой комментарий, а затем обновите его. Если это удалось, то вы увидите, что ваш комментарий отредактирован.
Отключение кнопки Confirm
, когда комментарий не редактируется
В настоящее время мы просто позволяем пользователю нажимать кнопку Confirm
всякий раз, когда он нажимает кнопку Edit
, не проверяя, изменилась ли полезная нагрузка.
Это может привести к двум проблемам:
- Наша функция
confirmEdit
всегда изменяет данныеupdated_at
, поэтому даже если мы ошибочно подтвердили редактирование без изменений, комментарий всегда будет помечен какedited
, поскольку нет возврата назад во времени. - Сейчас это не столь критично, но если мы будем использовать это в более крупном проекте, то возникнут ненужные транзакции между браузером пользователя и Supabase.
Чтобы предотвратить это, нам нужно отключить кнопку Confirm
, когда пользователь не изменил свой комментарий. Давайте немного подправим код.
...
{editComment.id === comment.id ? (
<>
<button
type="button"
onClick={confirmEdit}
disabled={editComment.payload === comment.payload}
className={`${editComment.payload === comment.payload ? `text-gray-300` : `text-green-500`}`}
>
Confirm
</button>
<button
type="button"
onClick={() => setEditComment({ id: "", payload: "" })}
className="text-gray-500"
...
Теперь наша кнопка Confirm
будет отключена, если содержимое комментария не было изменено.
Укажите edited
.
Элемент комментария должен указывать на то, что он был отредактирован. Этого можно добиться довольно просто — сравнив created_at
и updated_at
.
...
.map((comment) => (
<div key={comment.id} className="border rounded-md p-4">
<p className="font-semibold mb-2">
{comment.username}
{comment.updated_at !== comment.created_at && (
<span className="ml-4 text-sm italic font-extralight">edited</span>
)}
</p>
<div className="flex items-center gap-2 justify-between">
{comment.id === editComment.id ? (
...
Теперь, если мы отредактируем любой комментарий, он будет отображаться как edited
сверхлегким & курсивным шрифтом.
Удаление комментария
Удаление комментария не сильно отличается от обновления комментария — здесь используется та же функция match
, чтобы определить, какой комментарий должен быть удален.
быть удален. Давайте сделаем это очень быстро.
...
const confirmDelete = async (id: string) => {
const ok = window.confirm("Delete comment?");
if (ok) {
const { data, error } = await supabase.from("comments").delete().match({ id });
if (!error && data) {
window.alert("Deleted Comment :)");
} else {
window.alert(error?.message);
}
}
};
...
onChange={onChangeEditComment}
className="pb-1 border-b w-full"
/>
) : (
<p className="font-light">{comment.payload}</p>
)}
<div className="flex gap-2">
{editComment.id === comment.id ? (
<>
<button type="button" onClick={confirmEdit} className="text-green-500">
Confirm
</button>
<button
type="button"
onClick={() => setEditComment({ id: "", payload: "" })}
className="text-gray-500"
>
Cancel
</button>
</>
) : (
<>
<button
type="button"
onClick={() => setEditComment({ id: comment.id, payload: comment.payload })}
className="text-green-500"
>
Edit
</button>
<button type="button" onClick={() => confirmDelete(comment.id)} className="text-gray-700">
Delete
</button>
</>
)}
</div>
</div>
</div>
))}
</div>
</div>
...
После добавления кода нажмите кнопку Delete
в элементе комментария, который вы хотите удалить, нажмите Ok
в окне подтверждения,
и обновите страницу — комментарий исчезнет!
Видите, сделать CRUD функцию с помощью Supabase — это супер-просто!
Ответ на комментарий
Как будет работать наш ответ
Теперь угадайте единственный столбец в нашей таблице, который мы еще не использовали — правильно, столбец reply_of
. Мы будем использовать его прямо сейчас, чтобы
добавить функцию ответа на комментарии.
Давайте подумаем, как лучше всего будет работать наша функция ответа:
- Пользователь нажимает кнопку
Reply
на элементе комментария. - Тогда наша форма ввода (для добавления комментариев) покажет, что этот комментарий является ответным комментарием на какой-то другой комментарий.
- После добавления и попадания в список комментариев он должен быть отличим от обычных комментариев.
Добавление Reply of: ....
.
Хорошо, как всегда, давайте сначала поработаем с частью пользовательского интерфейса.
...
const [editComment, setEditComment] = useState<EditCommentParams>({ id: "", payload: "" });
const [replyOf, setReplyOf] = useState<string | null>(null);
const onChangeEditComment = (event: ChangeEvent<HTMLInputElement>) => {
...
<h1 className="text-4xl font-bold ">Comments</h1>
<form onSubmit={onSubmit} className="mt-8 flex gap-8">
<div className="w-full">
{replyOf && (
<div className="flex gap-4 my-2 items-center justify-start">
<p className="text-xs font-extralight italic text-gray-600">
Reply of: {commentList.find((comment) => comment.id === replyOf)?.payload ?? ""}
</p>
<button onClick={() => setReplyOf(null)} className="text-xs font-light text-red-600">
Cancel
</button>
</div>
)}
...
.map((comment) => (
<div key={comment.id} className="border rounded-md p-4">
{comment.reply_of &&
<p className="font-extralight italic text-gray-600 text-xs">
Reply of: {commentList.find((c) => c.id === comment.reply_of)?.payload ?? ""}
</p>
}
<p className="font-semibold mb-2">
{comment.username}
...
Delete
</button>
<button type="button" onClick={() => setReplyOf(comment.id)} className="text-orange-500">
Reply
</button>
</>
)}
</div>
...
Здесь, в этом коде, мы видим следующее:
- Мы объявили новое состояние
replyOf
, чтобы сохранить id выбранного нами ответного комментария. - Мы добавили одну текстовую строку в (1) Форму ввода (2) Над именем пользователя в элементе комментария, показывающую, на какой комментарий мы отвечаем.
- В форме ввода мы также добавили кнопку
Cancel
, чтобы отменить ответ и вернуть нашу форму ввода в обычное состояние.
- В форме ввода мы также добавили кнопку
- Мы добавили кнопку
Reply
, которая будет использоватьsetReplyOf
для сохранения id выбранного нами комментария.
Ок, этого объяснения достаточно, но в основном это будет выглядеть вот так просто.
Затем все, что вам нужно добавить, это передать id отвечающего комментария в поле reply_of
в onSubmit
.
...
const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const { data, error } = await supabase.from("comments").insert({
username: "hoonwee@email.com",
payload: comment,
reply_of: replyOf,
});
if (!error && data) {
window.alert("Hooray!");
} else {
window.alert(error?.message);
}
};
...
Теперь попробуйте добавить комментарий с ответом, а затем обновите его. Если все сделано правильно, вы увидите ответный комментарий, как показано на рисунке ниже.
Рестайлинг с использованием иконок
Проблемы с пользовательским интерфейсом, использующим только текст
Итак, теперь наш раздел комментариев уже великолепен. Он может читать, создавать, обновлять, удалять и отвечать на комментарии.
Хотя он полностью функционален, мы должны признать, что он выглядит очень скучно и визуально не привлекательно — потому что мы использовали только текст для нашего пользовательского интерфейса.
Проблема с использованием только текста в пользовательском интерфейсе может вызвать неприятные впечатления у пользователей, например:
- Он может быть менее интуитивным, что будет сбивать пользователей с толку.
- Если текст слишком длинный, он может испортить общий визуальный дизайн.
Поэтому для решения этой проблемы нам нужен какой-то элемент дизайна, который может упаковать смысл пользовательского интерфейса в сильный и лаконичный визуальный формат.
Насколько я знаю, лучше всего для этого подходит иконка.
Текст → Иконки
В интернете есть масса пакетов иконок, и здесь мы будем использовать один из них под названием Hero icons.
Поскольку он был разработан людьми, стоящими за TailwindCSS, он лучше всего подходит для нашего проекта.
Установите Hero icons с помощью следующей команды.
$ yarn add @heroicons/react
Теперь давайте начнем заменять несколько текстов на иконки Hero!
...
import { ReplyIcon, PencilIcon, TrashIcon, CheckCircleIcon, XCircleIcon, XIcon } from "@heroicons/react/outline";
...
Cancel
</button>
</>
) : (
<>
<button
onClick={() => setEditComment({ id: comment.id, payload: comment.payload })}
title="Edit comment"
>
<PencilIcon className="w-6" />
</button>
<button onClick={() => confirmDelete(comment.id)} title="Delete comment">
<TrashIcon className="w-6" />
</button>
<button onClick={() => setReplyOf(comment.id)} title="Reply to comment">
<ReplyIcon className="w-6 rotate-180" />
</button>
</>
)}
</div>
...
Что было изменено?
- Мы заменили три текста в нашем элементе комментария —
Edit
,Delete
иReply
. - Мы убрали ненужную цветовую вариацию между кнопками, потому что наши иконки и так различимы.
- Мы добавили свойство
title
, чтобы показать, что означает эта иконка, когда пользователь наводит курсор мыши на кнопку.- Я настоятельно рекомендую сделать это, поскольку инфографика, которую мы знаем как здравый смысл, может быть не такой, как в других культурах.
- Мы повернули иконку
Reply
на 180 градусов. Я сделал это потому, что мне показалось правильным именно такой угол, но вы можете изменить его, если хотите.
Давайте продолжим добавлять новые иконки.
...
{replyOf && (
<div className="flex gap-4 my-2 items-center justify-start">
<div className="flex items-center justify-start gap-2">
<ReplyIcon className="w-4 text-gray-600 rotate-180" />
<p className="font-extralight italic text-gray-600 text-sm">
{commentList.find((comment) => comment.id === replyOf)?.payload ?? ""}
</p>
</div>
<button onClick={() => setReplyOf(null)} title="Cancel">
<XIcon className="w-4 text-gray-600" />
</button>
</div>
)}
...
{comment.reply_of && (
<div className="flex items-center justify-start gap-2">
<ReplyIcon className="w-3 text-gray-600 rotate-180" />
<p className="font-extralight italic text-gray-600 text-xs">
{commentList.find((c) => c.id === comment.reply_of)?.payload ?? ""}
</p>
</div>
)}
...
<div className="flex gap-2">
{editComment.id === comment.id ? (
<>
<button
type="button"
onClick={confirmEdit}
disabled={editComment.payload === comment.payload}
title="Confirm"
>
<CheckCircleIcon
className={`${
editComment.payload === comment.payload ? `text-gray-300` : `text-green-500`
} w-6`}
/>
</button>
<button type="button" onClick={() => setEditComment({ id: "", payload: "" })} title="Cancel">
<XCircleIcon className="w-6 text-gray-600" />
</button>
</>
) : (
<>
...
Мы изменили Reply of
на нашу иконку Reply
, а также изменили Confirm
и Cancel
.
Мы изменили не так много, но выглядит гораздо лучше!
Реализация SWR
Наконец, мы собираемся исправить часть получения данных, которую сейчас пользователю нужно перезагружать каждый раз, когда он изменяет (создает, редактирует, удаляет) комментарии.
Используя удивительную библиотеку под названием SWR
, мы устраним эту проблему и поднимем удобство работы с разделом комментариев на совершенно другой уровень.
Краткий обзор SWR
Существует множество библиотек для сбора данных для Next.js, но одной из самых популярных и простых в использовании является SWR.
Вот простой пример с их официальной страницы документа (немного измененный для лучшего понимания).
import useSWR from 'swr';
const fetcher = (url: string) => fetch(url).then((res) => res.body);
function Profile() {
const { data, error } = useSWR('/api/user', fetcher);
if (error) return <div>failed to load</div>;
if (!data) return <div>loading...</div>;
return <div>hello {data.name}!</div>;
}
Этот простой на вид код делает нечто прекрасное.
- Он использует хук под названием
useSWR
, который принимает 2 аргумента:url
для получения данных.- Функция
fetcher
, которая будет забирать данные из данного url.
- Затем вы можете просто использовать
data
, как вы используете состояние React.
Видите? Это так просто! Попрощайтесь с тяжелыми днями, когда вам приходилось использовать несколько useState
и useEffect
для манипулирования и обновления удаленной даты — что очень сложно.
удаленную дату — что усложняло задачу и приводило к ошибкам.
Как следует из названия, механизм этого происходит из стратегии аннулирования кэша HTTP под названием stale-while-revalidate
.
Подробное описание этой стратегии выходит за рамки нашей статьи, поэтому, если вам интересно, лучше посмотреть
эту ссылку, если вам интересно.
Установка SWR и рефакторинг API
Теперь давайте установим SWR с помощью следующей команды.
$ yarn add swr
И мы заменим наш старый метод получения данных на новый, используя useSWR
.
Но сначала я уверен, что наш код нуждается в рефакторинге, поскольку в нашем клиентском файле index.tsx
уже слишком много кода, связанного с API.
К счастью, Next.js предоставляет нам каталог api
внутри каталога pages
, в который можно поместить все виды API-кода.
Давайте создадим новый файл pages/api/comments.ts
с кодом, приведенным ниже.
import { createClient } from "@supabase/supabase-js";
import { NextApiRequest, NextApiResponse } from "next";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL + "";
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_KEY + "";
export const supabase = createClient(supabaseUrl, supabaseKey);
const CommentsApi = async (req: NextApiRequest, res: NextApiResponse) => {
switch (req.method) {
// Get all comments
case "GET":
const { data: getData, error: getError } = await supabase.from("comments").select("*");
if (getError) {
return res.status(500).json({ message: getError.message });
}
return res.status(200).json(getData);
// Add comment
case "POST":
const comment = req.body;
const { data: postData, error: postError } = await supabase.from("comments").insert(comment);
if (postError) {
return res.status(500).json({ message: postError.message });
}
return res.status(200).json(postData);
// Edit comment
case "PATCH":
const { commentId: editcommentId, payload } = req.body;
const { data: patchData, error: patchError } = await supabase
.from("comments")
.update({ payload })
.eq("id", editcommentId);
if (patchError) {
return res.status(500).json({ message: patchError.message });
}
return res.status(200).json(patchData);
// Delete comment
case "DELETE":
const { comment_id: deleteCommentId } = req.query;
if (typeof deleteCommentId === "string") {
const { data: deleteData, error: deleteError } = await supabase
.from("comments")
.delete()
.eq("id", deleteCommentId + "");
if (deleteError) {
return res.status(500).json({ message: deleteError.message });
}
return res.status(200).json(deleteData);
}
default:
return res.status(405).json({
message: "Method Not Allowed",
});
}
};
export default CommentsApi;
Вот это неожиданно много кода! Не волнуйтесь, я буду объяснять по порядку.
- Внутри функции мы встречаем фильтр условий
switch
с 5case
:
Таким образом, мы просто перенесли все связанные с API вещи в этот файл.
Каждая реализация внутри блока case
идентична той, что мы написали в index.tsx
.
Он использует await supabase.from("comments").something(...)
для каждого случая.
Теперь мы создали наш прилично выглядящий код API, как же нам получить к нему доступ? Это очень просто — просто возьмите «/api/comments».
Замена ‘get comments’
Теперь мы будем использовать наш хорошо организованный API comments.ts
с помощью хука useSWR
.
Сначала давайте заменим старую реализацию получения всех комментариев.
Отредактируйте и удалите коды в index.tsx
с помощью кода ниже.
...
const fetcher = (url: string) => fetch(url, { method: "GET" }).then((res) => res.json());
const Home: NextPage = () => {
const { data: commentList, error: commentListError } = useSWR<CommentParams[]>("/api/comments", fetcher);
/* Deleted
const [commentList, setCommentList] = useState<CommentParams[]>([]);
*/
const [comment, setComment] = useState<string>("");
...
/* Deleted
const getCommentList = async () => {
const { data, error } = await supabase.from("comments").select("*");
if (!error && data) {
setCommentList(data);
} else {
setCommentList([]);
}
};
useEffect(() => {
getCommentList();
}, []);
*/
...
<div className="flex items-center justify-start gap-2">
<ReplyIcon className="w-4 text-gray-600 rotate-180" />
<p className="font-extralight italic text-gray-600 text-sm">
{commentList?.find((comment) => comment.id === replyOf)?.payload ?? ""}
</p>
</div>
...
{(commentList ?? [])
.sort((a, b) => {
const aDate = new Date(a.created_at);
...
<div className="flex items-center justify-start gap-2">
<ReplyIcon className="w-3 text-gray-600 rotate-180" />
<p className="font-extralight italic text-gray-600 text-xs">
{commentList?.find((c) => c.id === comment.reply_of)?.payload ?? ""}
</p>
</div>
...
Вот что произошло:
- Удалены
commentList
React State, функцияgetCommentList
иuseEffect
, которая использовалась для обновления комментариев при обновлении данных. - Замените эту часть одной строкой кода (или, возможно, 2 или 3 строками кода в зависимости от вашего форматера), используя хук
useSWR
.- Как и пример выше, он содержит
url("/api/comments")
иfetcher
. - Поскольку мы используем метод
GET
сfetch
, выполняется наш кейсGET
вcomments.ts
, который извлекает полный список комментариев.
- Как и пример выше, он содержит
- Добавлены маленькие
?
и? []
вcommentList
, когда он используется длянахождения
илисортировки
чего-либо.- Причина этого в том, что наши данные, получаемые из
useSWR
, нестабильны, поэтому они имеют шанс статьundefined
при сбое выборки. - Поэтому мы должны сообщить функции
find
с типом?
, что она может содержать данныеundefined
. - Для функции
sort
, которая не терпитundefined
, мы должны передать как минимум пустой массив.
- Причина этого в том, что наши данные, получаемые из
Мы сильно изменили наш код, в хорошем смысле! Наш раздел комментариев должен работать точно так же.
Замена «добавить комментарий»
Далее мы заменим функцию «добавить комментарий». Для этого нам нужно добавить еще одну функцию выборки, которая будет посылать пост-запрос в наш comments.ts
.
Добавьте функцию addCommentRequest
сразу после fetcher
.
...
const fetcher = (url: string) => fetch(url, { method: "GET" }).then((res) => res.json());
const addCommentRequest = (url: string, data: any) =>
fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}).then((res) => res.json());
const Home: NextPage = () => {
...
Мы стробируем данные комментария и публикуем их. Ничего сложного объяснять не нужно.
Теперь мы воспользуемся интересной функцией SWR, которая называется mutate
.
Используя mutate
, мы можем изменить локальный кэш списка комментариев еще до того, как получим обновленный список с сервера Supabase.
Давайте выясним это поведение, просто реализовав его. Обновите функцию onSubmit
и отредактируйте нашу форму добавления комментария.
...
const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const newComment = {
username: "hoonwee@email.com",
payload: comment,
reply_of: replyOf,
};
if (typeof commentList !== "undefined") {
mutate("api/comments", [...commentList, newComment], false);
const response = await addCommentRequest("api/comments", newComment);
if (response[0].created_at) {
mutate("api/comments");
window.alert("Hooray!");
setComment("")
}
}
};
...
<input
onChange={onChange}
value={comment}
type="text"
placeholder="Add a comment"
className="p-2 border-b focus:border-b-gray-700 w-full outline-none"
/>
Мы удалили наш старый await supabase...
и заменили его чем-то другим:
- Мы добавили две функции
mutate
, которые будут повторно получать список комментариев, в который был добавлен новый комментарий. Но почему две?- Первая функция на самом деле не будет получать данные. Вместо этого она будет считать, что добавление комментария прошло успешно, и притворяться, что она получила его, изменив локальный кэш списка комментариев.
- Теперь второй будет фактически заново получать данные и сравнивать между измененными и полученными данными. Если они равны, он ничего не делает. Если есть разница, то будет выполнен рендеринг для правильного списка комментариев.
- Между двумя функциями
mutate
находится вызов функцииawait addCommentRequest
. Она отправит POST-запрос к APIcomments.ts
и вернет ответ на запрос.- После успешного добавления комментария она вернет массив с одним элементом комментария.
- Если ответ представляет собой массив, и первый элемент содержит поле
created_at
, запрос считается успешным, поэтому мы используем вторую функциюmutate
для сравнения с измененным кэшем, и инициализируем форму комментария с помощьюsetComment
, установив пустую строку.
Теперь с помощью нашего мощного кода, модифицирующего кэш, мы можем видеть обновленный список комментариев без перезагрузки страницы!
Замена «редактирования, удаления комментариев»
Давайте попрактикуемся в использовании mutate
еще раз, заменив старый код для редактирования комментария.
Добавьте & Replace код, как показано ниже.
...
const editCommentRequest = (url: string, data: any) =>
fetch(url, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}).then((res) => res.json());
...
const confirmEdit = async () => {
const editData = {
payload: editComment.payload,
commentId: editComment.id,
};
if (typeof commentList !== "undefined") {
mutate(
"api/comments",
commentList.map((comment) => {
if (comment.id === editData.commentId) {
return { ...comment, payload: editData.payload };
}
}),
false
);
const response = await editCommentRequest("api/comments", editData);
console.log(response);
if (response[0].created_at) {
mutate("api/comments");
window.alert("Hooray!");
setEditComment({ id: "", payload: "" });
}
}
};
...
Поток действий такой же, как мы делали для функции onSubmit
.
- Сначала мы добавили функцию
editCommentRequest
fetcher. - Мы добавили два
mutate
, притворный и настоящий вconfirmEdit
. - Перед выполнением второго
mutate
мы проверяем, успешно ли прошел наш запрос с помощьюresponse[0].created_at
. - Наконец, мы сбрасываем состояние
editComment
.
Проделаем ту же работу для удаления комментариев.
...
const deleteCommentRequest = (url: string, id: string) =>
fetch(`${url}?comment_id=${id}`, { method: "DELETE" }).then((res) => res.json());
...
const confirmDelete = async (id: string) => {
const ok = window.confirm("Delete comment?");
if (ok && typeof commentList !== "undefined") {
mutate(
"api/comments",
commentList.filter((comment) => comment.id !== id),
false
);
const response = await deleteCommentRequest("api/comments", id);
if (response[0].created_at) {
mutate("api/comments");
window.alert("Deleted Comment :)");
}
}
};
...
Объяснения не нужны! Это то же самое, что мы делали для редактирования комментария.
Попробуйте отредактировать & удалить комментарий и проверьте, правильно ли изменяется список комментариев без перезагрузки.
И мы закончили!
Поздравляем! Мы успешно создали раздел комментариев с функциями:
- CRUD (создание, чтение, обновление, удаление) комментариев, используя библиотеку узлов Supabase.
- Изменение пользовательского интерфейса без перезагрузки с помощью SWR
- Чистый и понятный дизайн с использованием TailwindCSS и Hero Icons.
Хотя наш раздел комментариев великолепен, есть некоторые улучшения, которые необходимо сделать (сделайте это сами!):
- Замените окно предупреждения/подтверждения браузера на тост UI. Это будет выглядеть лучше.
- Внедрите логин пользователя, чтобы сделать его пригодным для использования в сообществе. Вы можете сделать это с нуля, или…
- Использовать встроенную таблицу пользователей Supabase
- Использовать Magic.
- Использовать NextAuth.
- Преобразовать систему ответов в потоки.
И это все для этой серии! Большое спасибо, что дочитали до конца, и я надеюсь увидеть вас в следующей статье/серии!
До тех пор, счастливого кодинга!