[React]Supabase를 사용한 Todo앱 빌드하기
정말 오랜 시간끝에 포스팅을 하게되었습니다. 사실 요즘들어서 크게 포스팅할 일이 없어서
글을 쓰지 않았지만 이번에는 Firebase를 대체할 수 있는 것이 있다기에 Supabase 포스팅을 하고자 합니다.
이번 시간에서는 Supabase를 사용해서 ReactTodo 앱을 구축해보겠습니다.
먼저 시작하기전에는 React와 Next.js에 대한 사전적인 지식이 필요합니다.
우리가 구축해볼 앱은 위와 같은 Todo 앱형식의 서비스를 구축해보려 합니다.
다음은 위 서비스에 필요한 필수 사항입니다.
- React.js
- Next.js
- Supabase
- Chakra UI
- Vercel
1. Supabase 테이블, 인증 및 스토리지 구성
위 서비스를 구성하기전 먼저 Supabase를 사용해 기능을 구현해보는 시간을 가져보겠습니다.
Supabase에는 3가지 필수 사항이 필요합니다.
- Supabase 프로젝트 생성
- 사용자 및 정책에 대한 인증 방식 설정
- 사용자 및 데이터베이스 및 정책 구성
Supabase 프로젝트를 생성하기 위해서는 Supabase에 로그인해 New Project를 클릭해줍니다.
기본적인 Supabase는 사용자 이름을 통해 계정을 만들어 줍니다.
프로젝트 생성하기를 클릭하면 다음과같은 화면이 나오고 Supabase에 대한 응용 프로그램을 설정해줍니다.
anon 부분은 공개 API Key로 클라이언트 측에서 사용할 수 있는 API 키 입니다.
service_role 부분은 개인 API 키로 서버 측에서만 사용할 수 있습니다
2. 데이터 베이스에서 테이블을 생성하는 방법
이제 데이터 베이스에서 테이블을 생성하는 스크립트를 생성해 보겠습니다.
왼쪽 메뉴에서 SQL 섹션으로 이동해 새 쿼리를 클릭해줍니다.
새 쿼리를 클릭한뒤 다음 코드를 복사 붙여넣기 해주세요
create table profiles (
id uuid references auth.users not null,
username text unique,
avatarUrl text,
website text,
bio text,
joinedAt timestamp with time zone default timezone('utc'::text, now()) not null,
primary key (id),
unique(username)
);
alter table profiles enable row level security;
create policy "Profiles are viewable by user only."
on profiles for select
using ( auth.uid() = id );
create policy "Users can insert their own profile."
on profiles for insert
with check ( auth.uid() = id );
create policy "Users can update own profile."
on profiles for update
using ( auth.uid() = id );
begin;
drop publication if exists supabase_realtime;
create publication supabase_realtime;
commit;
alter publication supabase_realtime add table profiles;
-- Set up Storage!
insert into storage.buckets (id, name)
values ('avatars', 'avatars');
create policy "Avatar images are publicly accessible."
on storage.objects for select
using ( bucket_id = 'avatars');
create policy "Anyone can upload an avatar."
on storage.objects for insert
with check ( bucket_id = 'avatars' );
create policy "Anyone can update an avatar."
on storage.objects for update
with check ( bucket_id = 'avatars' );
이 Profiles에 관한 스크립트를 해석해보면
먼저 TodoApp의 사용자와 관련된 테이블 프로필을 만들어주고, 테이블에서는 고유하게 설정하는 방법을 이해하기
위해서 사용자 이름을 고유 제약 조건으로 설정후 기본 키를 id로 설정해주었습니다.
그런 다음에 행 수준의 보안을 설정하고 각 개인이 자신의 데이터만을 엑세스할 수 있도록 정책을
할당해주었습니다.
그 다음에는 데이터베이스에 대해 실시간으로 활성화합니다. Realtime은 행에 변경 사항이 있을 때마다 이벤트를
제공하며 그에 따라 UI를 업데이트 해줍니다.
이제 하단에 있는 RUN 버튼을 클릭해주면 다음과 같은 메시지가 표시됩니다.
이제 todo 테이블을 만들어 봅시다. 테이블을 생성하기 위해 New Query 버튼을 클릭해주고 다음 스크립트를
복사 붙여넣기 해주세요
create table todos (
id bigint generated by default as identity primary key,
user_id uuid references auth.users not null,
title text,
description text,
"isComplete" boolean default false,
insertedAt timestamp with time zone default timezone('utc'::text, now()) not null
);
alter table todos enable row level security;
create policy "Individuals can create todos." on todos for
insert with check (auth.uid() = user_id);
create policy "Individuals can view their own todos. " on todos for
select using (auth.uid() = user_id);
create policy "Individuals can update their own todos." on todos for
update using (auth.uid() = user_id);
create policy "Individuals can delete their own todos." on todos for
delete using (auth.uid() = user_id);
이제 똑같이 오른쪽 모서리에 있는 RUN 버튼을 클릭해주면
테이블이 생성되어있는지를 확인하기 위해 사이드베에 테이블 편집기 섹션으로 이동해주세요
테이블 편집기 내에서 성공적으로 테이블이 생성된 것을 볼 수 있습니다!!!
위 Todos 스크립트에서 볼 수 있듯 실시간으로 활성화되지는 않았습니다. 실시간으로
서버를 활성화하기 위해 데이터베이스 -> 복제 섹션으로 이동해줍니다.
소스 아래에 있는 첫번째 테이블에 버튼을 클릭해 토글을 활성화 해줍니다.
그렇다면 실시간으로 서버가 활성화되는 모습을 볼 수 있습니다.
이제 TodoApp에 대한 보안을 비활성화하고 싶다고 가정하고(권장하지는 않지만 이 프로젝트에서는 비활성화)
할 것인데
인증 섹션으로 이동후 그 안에서 정책페이지로 이동해줍니다.
이제 두번째 Pollcies 탭으로 가서 RLS 비활성화 버튼을 클릭해 App에 대한 모든 보안을 비활성화 해줍니다.
Supabase를 사용해 로그인 구현
이제 직접 실습하면서 구성해보도록 하겠습니다.
실습하기 위해 프로젝트 하나를 생성해줍니다.
npx create-next-app todo_app
이제 라이브러리를 설치하고 기본적인 구성이 필요합니다.
yarn add @chakra-ui/react @emotion/react@^11 @emotion/styled@^11 framer-motion@^4
주의: zsh 사용시 @뒤에 이스케이프 문자(\)를 추가해줘야 합니다.
yarn add @chakra-ui/react @emotion/react@\^11 @emotion/styled@\^11 framer-motion@\^4
이제 ChakraUI를 애플리케이션에 구성하는 코드를 작성해보겠습니다.
Chakra 문서에 따라서 app.js 로 이동해 다음코드를 작성해 줍니다.
import { ChakraProvider, extendTheme } from '@chakra-ui/react';
import customTheme from '../lib/theme';
function MyApp({ Component, pageProps }) {
return (
<ChakraProvider theme={customTheme}>
<Component {...pageProps} />
</ChakraProvider>
);
}
export default MyApp;
루트 디렉토리 아래에 lib폴더를 생성하고 그안에 theme.js를 생성해줍니다.
이 파일안에 다음코드를 추가해줍니다.
import { extendTheme } from '@chakra-ui/react';
const config = {
initialColorMode: 'dark',
useSystemColorMode: false,
};
const theme = extendTheme({ config });
export default theme;
이제 pages 디렉토리 아래 _document.js 파일을 생성하고 다음 코드를 넣어줍니다.
import { ColorModeProvider } from '@chakra-ui/react';
import NextDocument, { Html, Head, Main, NextScript } from 'next/document';
import theme from '../lib/theme';
export default class Document extends NextDocument {
render() {
return (
<Html lang="en">
<Head />
<body>
{/* Heer's the script */}
<ColorModeProvider initialColorMode={theme.config.initialColorMode} />
<Main />
<NextScript />
</body>
</Html>
);
}
}
저는 _document.js에 테마를 다크모드로 설정했습니다.
이제 index.js 로 가서 다음 코드를 입력해줍니다.
import { Box } from '@chakra-ui/react';
import Head from 'next/head';
const Home = () => {
return (
<div>
<Head>
<title>Todo App</title>
<meta name="description" content="Awesome todoapp to store your awsome todos" />
<link rel="icon" href="/favicon.ico" />
</Head>
<main>
<Box>Hello World</Box>
</main>
</div>
);
};
export default Home;
Supabase 클라이언트 라이브러리 설치
yarn add @supabase/supabase-js
설치가 완료되면 lib폴더에 client.js 파일을 생성해주고 다음과 같은 코드를 작성해줍니다.
import { createClient } from '@supabase/supabase-js';
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL;
const SUPABASE_AMON_KEY = process.env.NEXT_PUBLIC_SUPABASE_AMON_KEY;
const client = createClient(SUPABASE_URL, SUPABASE_AMON_KEY);
export { client as supabaseClient };
이곳에서는 프로젝트 전체를 사용할 Supabase 클라이언트를 실행하는 코드입니다.
이제 루트 디렉토리 아래 .env.local 파일 생성후 Supabase_URL 키와 anon 키를 넣어줍니다.
NEXT_PUBLIC_SUPABASE_URL=생성한 Supabase 프로젝트 주소
NEXT_PUBLIC_SUPABASE_AMON_KEY=AMON키
Settings -> API 섹션에서 직접 SupabaseURL과 amon 키를 확인할 수 있습니다.
이제 애플리케이션을 실행해보겠습니다.
yarn dev && npm run dev
이제 Pages 디렉토리 아래 signin.js파일을 만들고 다음 코드를 넣어줍니다.
import { Alert, AlertIcon, Box, Button, chakra, FormControl, FormLabel, Heading, Input, Stack, Text } from '@chakra-ui/react';
import { useState } from 'react';
import { supabaseClient } from '../lib/client';
const Signin = () => {
const [email, setEmail] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isSumitted, setIsSumitted] = useState(false);
const [error, setError] = useState(null);
const submitHandler = async event => {
event.preventDefault();
setIsLoading(true);
setError(null);
try {
const { error } = await supabaseClient.auth.signIn({
email,
});
if (error) {
setError(error.message);
} else {
setIsSumitted(true);
}
} catch (error) {
setError(error.message);
} finally {
setIsLoading(false);
}
};
return (
<Box minH="100vh" py="12" px={{ base: '4', lg: '8' }} bg="gray.50">
<Box maxW="md" mx="auto">
<Heading textAlign="center" m="6">
Welcome to Todo App
</Heading>
{error && (
<Alert status="error" mb="6">
<AlertIcon />
<Text textAlign="center">{error}</Text>
</Alert>
)}
<Box py="8" px={{ base: '4', md: '10' }} shadow="base" rounded={{ sm: 'lg' }} bg="white">
{isSubmitted ? (
<Heading size="md" textAlign="center" color="gray.600">
Please check {email} for login link
</Heading>
) : (
<chakra.form onSubmit={submitHandler}>
<Stack spacing="6">
<FormControl id="email">
<FormLabel>Email address</FormLabel>
<Input name="email" type="email" autoComplete="email" required value={email} onChange={changeHandler} />
</FormControl>
<Button type="submit" colorScheme="blue" size="lg" fontSize="md" isLoading={isLoading}>
Sign in
</Button>
</Stack>
</chakra.form>
)}
</Box>
</Box>
</Box>
);
};
export default SignIn;
이곳에서 양식을 만들고 Supabase auth를 사용해 로그인을 구현했습니다.
이제 _app.js로 이동해 다음 코드를 작성해줍니다.
import { ChakraProvider, extendTheme } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { supabaseClient } from '../lib/client';
import customTheme from '../lib/theme';
function MyApp({ Component, pageProps }) {
const router = useRouter();
const user = supabaseClient.auth.user();
useEffect(() => {
const { data: authListener } = supabaseClient.auth.onAuthStateChange((event, session) => {
handleAuthSession(event, session);
if (event === 'SIGNED_IN') {
const signedInUser = supabaseClient.auth.user();
const userId = signedInUser.id;
supabaseClient
.from('profiles')
.upsert({ id: userId })
.then((_data, error) => {
if (!error) {
router.push('/');
}
});
}
if (event === 'SIGNED_OUT') {
router.push('/signin');
}
});
return () => {
authListener.unsubscribe();
};
}, [router]);
useEffect(() => {
if (user) {
if (router.pathname === '/signin') {
router.push('/');
}
}
}, [router.pathname, user, router]);
const handleAuthSession = async (event, session) => {
await fetch('/api/auth', {
method: 'POST',
headers: new Headers({ 'Content-Type': 'application/json' }),
credentials: 'same-origin',
body: JSON.stringify({ event, session }),
});
};
return (
<ChakraProvider theme={customTheme}>
<Component {...pageProps} />
</ChakraProvider>
);
}
export default MyApp;
이제 api 디렉토리 내에서 hello.js라는 파일을 제거하고 auth.js파일을 생성후 안에서 다음과 같은 코드를 작성해줍니다.
import { supabaseClient } from '../../lib/client';
export default function handler(req, res) {
supabaseClient.auth.api.setAuthCookie(req, res);
}
위 코드는 사용자가 링크를 클릭할때 인증에 관해서 가장 중요합니다.
Supabase에서는 auth.onAuthStateChage 두 개의 이벤트 SIGNED_IN & SIGNED_OUT이 있고
우리는 이 이벤트들을 사용해 supabase에 의해 노출된 메서드를 사용하는 SIGNED_IN을 호출해
쿠키를 설정하고 있습니다. /api/auth 에 auth.api.setAuthCookie는 서버 측을 통해서
쿠키를 설정하는데 유용한 방식입니다. /auth 가 인증되면 로그인시 해당 페이지로 이동해주게 됩니다.
이제 직접 서버를 구동해봅시다.
서버를 구동하고 http://localhost:3000/signin 페이지로 이동하면
이메일을 추가하고 로그인을 해보면 https://localhost:3000 페이지로 리다이렉션 됩니다.
TodoList 표시, 추가 , 업데이트 삭제 방법
TodoList 를 구현하기 전 먼저 로그아웃 기능을 구현해보겠습니다. 기존 코드로 이동해서 index.js에서
코드를 살짝 바꿔줍니다.
import Head from 'next/head';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import Navbar from '../components/Navbar';
import { supabaseClient } from '../lib/client';
const Home = () => {
const router = useRouter();
const user = supabaseClient.auth.user();
useEffect(() => {
if (!user) {
router.push("/signin");
}
}, [user, router]);
return (
<div>
<Head>
<title>TodoApp</title>
<meta
name="description"
content="Awesome todoapp to store your awesome todos"
/>
<link rel="icon" href="/favicon.ico"/>
</Head>
<main>
<Navbar/>
</main>
</div>
);
};
export default Home;
루트 디렉토리 아래 component 디렉토리를 만들어주고 그 안에 Navbar.js파일을 만들어 줍니다.
import { Box, Button, ButtonGroup, Flex, Heading } from "@chakra-ui/react";
import NavLink from "next/link";
import { useRouter } from "next/router";
import { useState } from "react";
import { supabaseClient } from "../lib/client";
const Navbar = () => {
const router = useRouter();
const [isLogoutLoading, setIsLogoutLoading] = useState(false);
const logoutHandler = async () => {
try {
setIsLogoutLoading(true);
await supabaseClient.auth.signOut();
router.push("/signin");
} catch (error) {
router.push("/signin");
} finally {
setIsLogoutLoading(false);
}
};
return (
<Box height="100%" p="5" bg="gray.100">
<Box maxW="6xl" mx="auto">
<Flex
as="nav"
aria-label="Site navigation"
align="center"
justify="space-between"
>
<Heading mr="4">TodoApp</Heading>
<Box>
<NavLink href="/profile">Profile</NavLink>
<ButtonGroup spacing="4" ml="6">
<Button colorScheme="blue">Add Todo</Button>
<Button
colorScheme="red"
onClick={logoutHandler}
isLoading={isLogoutLoading}
>
Logout
</Button>
</ButtonGroup>
</Box>
</Flex>
</Box>
</Box>
);
};
export default Navbar;
프로필 링크, 할일 추가 및 로그아웃 버튼이 구현되있는 구성요소를 만들어주었습니다.
세션을 지우고 애플리케이션에 로그아웃 하기 위해서는 logoutHandlerSupabase 메서드를 사용해줍니다.
https://localhost:3000에 이동해 로그아웃 버튼을 클릭해봅시다.
쿠키는 현재 브라우저에서 지워지면서 다시 로그인 페이지로 리다이렉션 됩니다.
할일 추가하기
Navbar.js로 이동해 다음 코드를 추가해줍니다.
import { Box, Button, ButtonGroup, Flex, Heading } from '@chakra-ui/react';
import NavLink from 'next/link';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { supabaseClient } from '../../lib/client';
const Navbar = () => {
const router = useRouter();
const [isLogoutLoading, setIsLogoutLoading] = useState(false);
const logoutHandler = async () => {
try {
setIsLogoutLoading(true);
await supabaseClient.auth.signOut();
router.push('/signin');
} catch (error) {
router.push('/signin');
} finally {
setIsLogoutLoading(false);
}
};
return (
<Box height="100%" p="5" bg="gray.100">
<Box maxW="6xl" mx="auto">
<Flex as="nav" aria-label="Site navigation" align="center" justify="space-between">
<Heading mr="4">TodoApp</Heading>
<Box>
<NavLink href="/profile">Profile</NavLink>
<ButtonGroup spacing="4" ml="6">
<Button colorScheme="blue">Add Todo</Button>
<Button colorScheme="red" onClick={logoutHandler} isLoading={isLogoutLoading}>
Logout
</Button>
</ButtonGroup>
</Box>
</Flex>
</Box>
</Box>
);
};
export default Navbar;
이곳에서는 할 일을 추가하기 위해 버튼 클릭시 모달을 열어주는 기능을 할당했습니다.
이제 components/ManageTodo.js 를 생성해서 다음 코드를 넣어줍니다.
import {
Alert,
AlertIcon,
Button,
ButtonGroup,
FormControl,
FormHelperText,
FormLabel,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Switch,
Text,
Textarea,
} from '@chakra-ui/react';
import { useState } from 'react';
import { supabaseClient } from '../lib/client';
const ManageTodo = ({ isOpen, onClose, initialRef }) => {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [isComplete, setIsComplete] = useState(false);
const [isLoading, setIsLoading] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const submitHandler = async event => {
event.preventDefault();
setErrorMessage('');
if (description.length <= 10) {
setErrorMessage('Description must have more than 10 characters');
return;
}
setIsLoading(true);
const user = supabaseClient.auth.user();
const { error } = await supabaseClient.from('todos').insert([{ title, description, isComplete, user_id: user.id }]);
setIsLoading(false);
if (error) {
setErrorMessage(error.message);
} else {
closeHandler();
}
};
const closeHandler = () => {
setTitle('');
setDescription('');
setIsComplete(false);
onClose();
};
return (
<Modal isOpen={isOpen} onClose={onClose} isCentered initialFocusRef={initialRef}>
<ModalOverlay />
<ModalContent>
<form onSubmit={submitHandler}>
<ModalHeader>Add Todo</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
{errorMessage && (
<Alert status="error" borderRadius="lg" mb="6">
<AlertIcon />
<Text textAlign="center">{errorMessage}</Text>
</Alert>
)}
<FormControl isRequired={true}>
<FormLabel>Title</FormLabel>
<Input
ref={initialRef}
placeholder="Add your title here"
onChange={event => setTitle(event.target.value)}
value={title}
/>
</FormControl>
<FormControl mt={4} isRequired={true}>
<FormLabel>Description</FormLabel>
<Textarea
placeholder="Add your description here"
onChange={event => setDescription(event.target.value)}
value={description}
/>
<FormHelperText>Description must have more than 10 characters.</FormHelperText>
</FormControl>
<FormControl mt={4}>
<FormLabel>Is Completed?</FormLabel>
<Switch value={isComplete} id="is-completed" onChange={event => setIsComplete(!isComplete)} />
</FormControl>
</ModalBody>
<ModalFooter>
<ButtonGroup spacing="3">
<Button onClick={closeHandler} colorScheme="red" type="reset" isDisabled={isLoading}>
Cancel
</Button>
<Button colorScheme="blue" type="submit" isLoading={isLoading}>
Save
</Button>
</ButtonGroup>
</ModalFooter>
</form>
</ModalContent>
</Modal>
);
};
export default ManageTodo;
이 부분에서는 할 일을 추가하고 업데이트 해주는 역할을 담당합니다. 여기서 3개의 모달 양식을 만들어 주었습니다.
양식 작성이 완료될때 supabase 서버를 호출해줍니다.
const { error } = await supabaseClient
.from("todos")
.insert([{ title, description, isComplete, user_id: user.id }]);
이 코드는 우리의 supabase 테이블 안에 새로운 todo를 삽입해주는 코드 입니다.
이제 pages -> index.js로 이동해 다음 코드를 넣어줍니다.
import { useDisclosure } from '@chakra-ui/react';
import Head from 'next/head';
import { useRouter } from 'next/router';
import { useEffect, useRef } from 'react';
import ManageTodo from './components/ManageTodo';
import Navbar from './components/Navbar';
import { supabaseClient } from '../lib/client';
const Home = () => {
const initialRef = useRef();
const { isOpen, onOpen, onClose } = useDisclosure();
const router = useRouter();
const user = supabaseClient.auth.user();
useEffect(() => {
if (!user) {
router.push('/signin');
}
}, [user, router]);
return (
<div>
<Head>
<title>TodoApp</title>
<meta name="description" content="Awesome todoapp to store your awesome todos" />
<link rel="icon" href="/favicon.ico" />
</Head>
<main>
<Navbar onOpen={onOpen} />
<ManageTodo isOpen={isOpen} onClose={onClose} initialRef={initialRef} />
</main>
</div>
);
};
export default Home;
여기서 useDisclosure 모달 상태를 유지하기 위해서 chakra-ui의 hooks을 사용하고 있습니다. onOpen 그 외에도
Navbar에 전달하고 구성 요소를 추가한 것을 볼 수 있습니다. ManageTodo
이제 AddTodo 버튼을 클릭하면 다음과 같은 화면이 나옵니다.
양식을 작성후 저장을 클릭해준다음 Supabase Todo 표로 이동시켜줍니다. 새로운 todo 테이블이 추가됨을 알 수 있습니다.
모든 TodoList 불러오기
이제 우리가 추가한 TodoList를 불러올 차례입니다.
디렉토리안에 SingleTodo.js 파일을 추가후 다음 코드를 추가해줍니다.
import { Box, Divider, Heading, Text, Tag } from "@chakra-ui/react";
const SingleTodo = ({ todo }) => {
const getDateInMonthDayYear = (date) => {
const d = new Date(date);
const options = {
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "numeric",
};
const n = d.toLocaleDateString("en-US", options);
const replase = n.replace(new RegExp(",", "g"), " ");
return replase;
};
return (
<Box
position="relative"
maxW="sm"
borderWidth="1px"
borderRadius="lg"
overflow="hidden"
p="4"
>
<Heading size="md" mt="3">{todo.title}</Heading>
<Tag
position="absolute"
top="3"
right="2"
bg={todo.isComplete ? "green.500" : "yellow.400"}
borderRadius="3xl"
size="sm"
/>
<Text color="gray.400" mt="1" fontSize="sm">
{getDateInMonthDayYear(todo.insertedat)}
</Text>
<Divider my="4" />
<Text noOfLines={[1, 2, 3]} color="gray.800">
{todo.description}
</Text>
</Box>
);
};
export default SingleTodo;
이 코드는 추가한 Todo에 날짜를 시간 형식으로 구현해주고 변환하는 유틸리티 UI 코드입니다.
이제 Index.js에 가서 다음 코드로 변경해줍니다.
import { Box, HStack, SimpleGrid, Tag, useDisclosure } from '@chakra-ui/react';
import Head from 'next/head';
import { useRouter } from 'next/router';
import { useState, useEffect, useRef } from 'react';
import ManageTodo from './components/ManageTodo';
import Navbar from './components/Navbar';
import SingleTodo from './components/SingleTodo';
import { supabaseClient } from '../lib/client';
const Home = () => {
const initialRef = useRef();
const [todos, setTodos] = useState([]);
const router = useRouter();
const { isOpen, onOpen, onClose } = useDisclosure();
const user = supabaseClient.auth.user();
useEffect(() => {
if (!user) {
router.push('/signin');
}
}, [user, router]);
useEffect(() => {
if (user) {
supabaseClient
.from('todos')
.select('*')
.eq('user_id', user?.id)
.order('id', { ascending: false })
.then(({ data, error }) => {
if (!error) {
setTodos(data);
}
});
}
}, [user]);
useEffect(() => {
const todoListener = supabaseClient
.from('todos')
.on('*', payload => {
const newTodo = payload.new;
setTodos(oldTodos => {
const newTodos = [...oldTodos, newTodo];
newTodos.sort((a, b) => b.id - a.id);
return newTodos;
});
})
.subscribe();
return () => {
todoListener.unsubscribe();
};
}, []);
return (
<div>
<Head>
<title>TodoApp</title>
<meta name="description" content="Awesome todoapp to store your awesome todos" />
<link rel="icon" href="/favicon.ico" />
</Head>
<main>
<Navbar onOpen={onOpen} />
<ManageTodo isOpen={isOpen} onClose={onClose} initialRef={initialRef} />
<HStack m="10" spacing="4" justify="center">
<Box>
<Tag bg="green.500" borderRadius="3xl" size="sm" mt="1" /> Complete
</Box>
<Box>
<Tag bg="yellow.400" borderRadius="3xl" size="sm" mt="1" /> Incomplete
</Box>
</HStack>
<SimpleGrid columns={{ base: 2, md: 3, lg: 4 }} gap={{ base: '4', md: '6', lg: '8' }} m="10">
{todos.map(todo => (
<SingleTodo todo={todo} key={todo.id} />
))}
</SimpleGrid>
</main>
</div>
);
};
export default Home;
코드를 해석해보면 먼저 우리는 두가지 useEffect 기능을 추가했습니다.
useEffect(() => {
if (user) {
supabaseClient
.from('todos')
.select('*')
.eq('user_id', user?.id)
.order('id', { ascending: false })
.then(({ data, error }) => {
if (!error) {
setTodos(data);
}
});
}
}, [user]);
이 useEffect는 페이지가 처음으로 렌더링 될때 유용하게 사용하는 기능입니다. 특정 사용자에 대한 Supabase
테이블 데이터를 내림차순으로 query 해줍니다.
useEffect(() => {
const todoListener = supabaseClient
.from('todos')
.on('*', payload => {
const newTodo = payload.new;
setTodos(oldTodos => {
const newTodos = [...oldTodos, newTodo];
newTodos.sort((a, b) => b.id - a.id);
return newTodos;
});
})
.subscribe();
return () => {
todoListener.unsubscribe();
};
}, []);
두번째는 Supabase 실시간 서버와의 실시간 Subscribe 입니다. 새 할일이 추가될 떄마다 local 상태에서
할 일을 추가하는 데 사용하는 payload 이벤트가 발생합니다.
TodoList 업데이트 방법
TodoList를 업데이트 하는 방법에는
- todo 부모 구성 요소 상태를 만들어준다. 부모 구성요소의 역할을 SignleTodo를 클릭하면 업데이트 되는 역할
- openHandler를 위해 함수를 하나 전달해준다. 이 함수는 클릭한 TodoList에 세부 정보를 업데이트하는 모달을 열어준다.
- 값이 변경될 때마다. 값을 업데이트 하는 종속성을 사용해 ManageTodo.js를 작성했다.
- 마지마긍로 .NET 기반의 Supabase 업데이트 방법을 사용해 테이블에서 할 일을 업데이트 한다. todo.id
이제 코드로 구현해보자. 구성 요소 디렉토리 아래 SingleTodo.js로 이동해 코드를 바꿔준다..
import { Box, Divider, Heading, Text, Tag } from '@chakra-ui/react';
const SingleTodo = ({ todo, openHandler }) => {
const getDateInMonthDayYear = date => {
const d = new Date(date);
const options = {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
};
const n = d.toLocaleDateString('en-US', options);
const replase = n.replace(new RegExp(',', 'g'), ' ');
return replase;
};
return (
<Box position="relative" maxW="sm" borderWidth="1px" borderRadius="lg" overflow="hidden" p="4" onClick={() => openHandler(todo)}>
<Heading size="md" mt="3">
{todo.title}
</Heading>
<Tag position="absolute" top="3" right="2" bg={todo.isComplete ? 'green.500' : 'yellow.400'} borderRadius="3xl" size="sm" />
<Text color="gray.400" mt="1" fontSize="sm">
{getDateInMonthDayYear(todo.insertedat)}
</Text>
<Divider my="4" />
<Text noOfLines={[1, 2, 3]} color="gray.800">
{todo.description}
</Text>
</Box>
);
};
export default SingleTodo;
구성요소 디렉토리 아래 ManageTodo.js 에서 다음 코드로 바꿔준다.
import {
Alert,
AlertIcon,
Button,
ButtonGroup,
FormControl,
FormHelperText,
FormLabel,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Switch,
Text,
Textarea,
} from '@chakra-ui/react';
import { useEffect, useState } from 'react';
import { supabaseClient } from '../../lib/client';
const ManageTodo = ({ isOpen, onClose, initialRef, todo, setTodo }) => {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [isComplete, setIsComplete] = useState(false);
const [isLoading, setIsLoading] = useState('');
const [errorMessage, setErrorMessage] = useState('');
useEffect(() => {
if (todo) {
setTitle(todo.title);
setDescription(todo.description);
}
}, [todo]);
const submitHandler = async event => {
event.preventDefault();
setErrorMessage('');
if (description.length <= 10) {
setErrorMessage('Description must have more than 10 characters');
return;
}
setIsLoading(true);
const user = supabaseClient.auth.user();
let supabaseError;
if (todo) {
const { error } = await supabaseClient
.from('todos')
.update({ title, description, isComplete, user_id: user.id })
.eq('id', todo.id);
supabaseError = error;
} else {
const { error } = await supabaseClient.from('todos').insert([{ title, description, isComplete, user_id: user_id }]);
supabaseError = error;
}
setIsLoading(false);
if (supabaseError) {
setErrorMessage(supabaseError.message);
} else {
closeHandler();
}
};
const closeHandler = () => {
setTitle('');
setDescription('');
setIsComplete(false);
setTodo(null);
onClose();
};
return (
<Modal isOpen={isOpen} onClose={onClose} isCentered initialFocusRef={initialRef}>
<ModalOverlay />
<ModalContent>
<form onSubmit={submitHandler}>
<ModalHeader>{todo ? 'Update Todo' : 'Add Todo'}</ModalHeader>
<ModalCloseButton onClick={closeHandler} />
<ModalBody pb={6}>
{errorMessage && (
<Alert status="error" borderRadius="lg" mb="6">
<AlertIcon />
<Text textAlign="center">{errorMessage}</Text>
</Alert>
)}
<FormControl isRequired={true}>
<FormLabel>Title</FormLabel>
<Input
ref={initialRef}
placeholder="Add your title here"
onChange={event => setTitle(event.target.value)}
value={title}
/>
</FormControl>
<FormControl mt={4} isRequired={true}>
<FormLabel>Description</FormLabel>
<Textarea
placeholder="Add your description here"
onChange={event => setDescription(event.target.value)}
value={description}
/>
<FormHelperText>Description must have more than 10 characters.</FormHelperText>
</FormControl>
<FormControl mt={4}>
<FormLabel>Is Completed?</FormLabel>
<Switch isChecked={isComplete} id="is-completed" onChange={event => setIsComplete(!isComplete)} />
</FormControl>
</ModalBody>
<ModalFooter>
<ButtonGroup spacing="3">
<Button onClick={closeHandler} colorScheme="red" type="reset" isDisabled={isLoading}>
Cancel
</Button>
<Button colorScheme="blue" type="submit" isLoading={isLoading}>
{todo ? 'Update' : 'Save'}
</Button>
</ButtonGroup>
</ModalFooter>
</form>
</ModalContent>
</Modal>
);
};
export default ManageTodo;
이제 pages -> index.js 로 이동해 기존 코드를 바꿔줍니다.
할일 삭제하기
이 기능을 사용하기 위해 코드 일부분을 업데이트 해줘야 합니다.
SingleTodo.js 에 다음 코드를 추가합니다.
import { Box, Divider, Heading, Text, Tag, Center, Button } from '@chakra-ui/react';
const SingleTodo = ({ todo, openHandler, deleteHandler, isDeleteLoading }) => {
const getDateInMonthDayYear = date => {
const d = new Date(date);
const options = {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
};
const n = d.toLocaleDateString('en-US', options);
const replase = n.replace(new RegExp(',', 'g'), ' ');
return replase;
};
return (
<Box position="relative" maxW="sm" borderWidth="1px" borderRadius="lg" overflow="hidden" p="4" onClick={() => openHandler(todo)}>
<Heading size="md" mt="3">
{todo.title}
</Heading>
<Tag position="absolute" top="3" right="2" bg={todo.isComplete ? 'green.500' : 'yellow.400'} borderRadius="3xl" size="sm" />
<Text color="gray.400" mt="1" fontSize="sm">
{getDateInMonthDayYear(todo.insertedat)}
</Text>
<Divider my="4" />
<Text noOfLines={[1, 2, 3]} color="gray.800">
{todo.description}
</Text>
<Center>
<Button
mt="4"
size="sm"
colorScheme="red"
onClick={event => {
event.stopPropagation();
deleteHandler(todo.id);
}}
isDisabled={isDeleteLoading}
>
Delete
</Button>
</Center>
</Box>
);
};
export default SingleTodo;
이곳에 onClick 이벤트가 있는 삭제 버튼을 추가하고 이제 이 삭제 버튼을 클릭시 모달을 열어주는 기능을 작성하고
index.js에 디렉토리 내부로 이동해 기존 코드를 다음 코드로 바꿔줍니다.
import { useDisclosure } from '@chakra-ui/hooks';
import { Box, HStack, SimpleGrid, Tag } from '@chakra-ui/react';
import Head from 'next/head';
import { useRouter } from 'next/router';
import { useEffect, useRef, useState } from 'react';
import ManageTodo from './components/ManageTodo';
import Navbar from './components/Navbar';
import SingleTodo from './components/SingleTodo';
import { supabaseClient } from '../lib/client';
const Home = () => {
const initialRef = useRef();
const [todos, setTodos] = useState([]);
const [todo, setTodo] = useState(null);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const router = useRouter();
const { isOpen, onOpen, onClose } = useDisclosure();
const user = supabaseClient.auth.user();
useEffect(() => {
if (!user) {
router.push('/signin');
}
}, [user, router]);
useEffect(() => {
if (user) {
supabaseClient
.from('todos')
.select('*')
.eq('user_id', user?.id)
.order('id', { ascending: false })
.then(({ data, error }) => {
if (!error) {
setTodos(data);
}
});
}
}, [user]);
useEffect(() => {
const todoListener = supabaseClient
.from('todos')
.on('*', payload => {
const newTodo = payload.new;
setTodos(oldTodos => {
const exists = oldTodos.find(todo => todo.id === newTodo.id);
let newTodos;
if (exists) {
const oldTodoIndex = oldTodos.findIndex(obj => obj.id === newTodo.id);
oldTodos[oldTodoIndex] = newTodo;
newTodos = oldTodos;
} else {
newTodos = [...oldTodos, newTodo];
}
newTodos.sort((a, b) => b.id - a.id);
return newTodos;
});
})
.subscribe();
return () => {
todoListener.unsubscribe();
};
}, []);
const openHandler = clickedTodo => {
setTodo(clickedTodo);
onOpen();
};
const deleteHandler = async todoId => {
setIsDeleteLoading(true);
const { error } = await supabaseClient.from('todos').delete().eq('id', todoId);
if (!error) {
setTodos(todos.filter(todo => todo.id !== todoId));
}
setIsDeleteLoading(false);
};
return (
<div>
<Head>
<title>TodoApp</title>
<meta name="description" content="Awesome todoapp to store your awesome todos" />
<link rel="icon" href="/favicon.ico" />
</Head>
<main>
<Navbar onOpen={onOpen} />
<ManageTodo isOpen={isOpen} onClose={onClose} initialRef={initialRef} todo={todo} setTodo={setTodo} />
<HStack m="10" spacing="4" justify="center">
<Box>
<Tag bg="green.500" borderRadius="3xl" size="sm" mt="1" /> Complete
</Box>
<Box>
<Tag bg="yellow.400" borderRadius="3xl" size="sm" mt="1" /> Incomplete
</Box>
</HStack>
<SimpleGrid columns={{ base: 2, md: 3, lg: 4 }} gap={{ base: '4', md: '6', lg: '8' }} m="10">
{todos.map((todo, index) => (
<SingleTodo
todo={todo}
key={index}
openHandler={openHandler}
deleteHandler={deleteHandler}
isDeleteLoading={isDeleteLoading}
/>
))}
</SimpleGrid>
</main>
</div>
);
};
export default Home;
코드를 해석해보면 deleteHandler, 이 코드에서는 Supabase 클라이언트를 사용해 todo 테이블에서 레코드를 삭제 합니다. 성공적으로 삭제될때 이 filter 메소드를 사용해 로컬 상태에서 TodoList를 제거합니다.
유형에 따라서 조건이 붙는 todoListener 를 추가하는 useEffect의 경우 로컬 상태를 업데이트하고 있으므로
event에서는 아무것도 하고 있지 않습니다.
삭제버튼 클릭시 TodoList에서 해당 리스트가 삭제된것을 볼 수 있습니다.
이것으로 ToDoList의 CRUD에 대한 기초 지식을 배워보았습니다.
프로필 세부 정보 및 아바타 업데이트 하기
프로필 섹션에서 작업하기 이전에 프로필 페이지에서 홈 페이지로 돌아갈 수 있게 TodoApp 제목에 대한 경로를 만들어주 어야 합니다.
components 디렉토리 안에 Navbar.js에서 기존 코드를 다음 코드로 변경해줍니다.
import { Box, Button, ButtonGroup, Flex, Heading } from "@chakra-ui/react";
import NavLink from "next/link";
import { useRouter } from "next/router";
import { useState } from "react";
import { supabaseClient } from "../lib/client";
const Navbar = ({ onOpen }) => {
const router = useRouter();
const [isLogoutLoading, setIsLogoutLoading] = useState(false);
const logoutHandler = async () => {
try {
setIsLogoutLoading(true);
await supabaseClient.auth.signOut();
router.push("/signin");
} catch (error) {
router.push("/signin");
} finally {
setIsLogoutLoading(false);
}
};
return (
<Box height="100%" p="5" bg="gray.100">
<Box maxW="6xl" mx="auto">
<Flex
as="nav"
aria-label="Site navigation"
align="center"
justify="space-between"
>
<NavLink href="/">
<Heading mr="4" as="button">
TodoApp
</Heading>
</NavLink>
<Box>
<NavLink href="/profile">Profile</NavLink>
<ButtonGroup spacing="4" ml="6">
{router.pathname === "/" && (
<Button colorScheme="blue" onClick={onOpen}>
Add Todo
</Button>
)}
<Button
colorScheme="red"
onClick={logoutHandler}
isLoading={isLogoutLoading}
>
Logout
</Button>
</ButtonGroup>
</Box>
</Flex>
</Box>
</Box>
);
};
export default Navbar;
프로필 섹션 앱의 마지막 부분을 구축하는 작업입니다. 이 섹션에서는 사용자의 이름, 웹 사이트 설명 및 아바타를 업데이트 할 수 있는 양식입니다.
프로필 사진을 저장하기 위해 Supabase 스토리지를 사용합니다. 기본적으로 이러한 스토리지 버킷은 비공개 방식으로 토큰을 사용해 엑세스 할 수 있습니다.
Supabase로 이동해 storage 탭으로 이동해줍니다.
세 개의 점을 클릭하고 공개 옵션을 선택하십시오.
코드로 돌아가서: 페이지 디렉토리 안에 이름이 지정된 파일을 만들고 profile.js다음 코드를 복사하여 붙여넣습니다.
import {
Avatar,
Box,
Button,
Flex,
FormControl,
FormLabel,
Input,
Stack,
Textarea,
} from "@chakra-ui/react";
import { useEffect, useState } from "react";
import Navbar from "../components/Navbar";
import { supabaseClient } from "../lib/client";
const Profile = () => {
const [email, setEmail] = useState("");
const [username, setUsername] = useState("");
const [website, setWebsite] = useState("");
const [bio, setBio] = useState("");
const [avatarurl, setAvatarurl] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [isImageUploadLoading, setIsImageUploadLoading] = useState(false);
const user = supabaseClient.auth.user();
useEffect(() => {
if (user) {
setEmail(user.email);
supabaseClient
.from("profiles")
.select("*")
.eq("id", user.id)
.then(({ data, error }) => {
if (!error) {
setUsername(data[0].username || "");
setWebsite(data[0].website || "");
setBio(data[0].bio || "");
setAvatarurl(data[0].avatarurl || "");
}
});
}
}, [user]);
const updateHandler = async (event) => {
event.preventDefault();
setIsLoading(true);
const body = { username, website, bio };
const userId = user.id;
const { error } = await supabaseClient
.from("profiles")
.update(body)
.eq("id", userId);
if (!error) {
setUsername(body.username);
setWebsite(body.website);
setBio(body.bio);
}
setIsLoading(false);
};
function makeid(length) {
let result = "";
const characters =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
const charactersLength = characters.length;
for (var i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() * charactersLength));
}
return result;
}
const uploadHandler = async (event) => {
setIsImageUploadLoading(true);
const avatarFile = event.target.files[0];
const fileName = makeid(10);
const { error } = await supabaseClient.storage
.from("avatars")
.upload(fileName, avatarFile, {
cacheControl: "3600",
upsert: false,
});
if (error) {
setIsImageUploadLoading(false);
console.log("error", error);
return;
}
const { publicURL, error: publicURLError } = supabaseClient.storage
.from("avatars")
.getPublicUrl(fileName);
if (publicURLError) {
setIsImageUploadLoading(false);
console.log("publicURLError", publicURLError);
return;
}
const userId = user.id;
await supabaseClient
.from("profiles")
.update({
avatarurl: publicURL,
})
.eq("id", userId);
setAvatarurl(publicURL);
setIsImageUploadLoading(false);
};
return (
<Box>
<Navbar />
<Box mt="8" maxW="xl" mx="auto">
<Flex align="center" justify="center" direction="column">
<Avatar
size="2xl"
src={avatarurl || ""}
name={username || user?.email}
/>
<FormLabel
htmlFor="file-input"
my="5"
borderRadius="2xl"
borderWidth="1px"
textAlign="center"
p="2"
bg="blue.400"
color="white"
>
{isImageUploadLoading ? "Uploading....." : "Upload Profile Picture"}
</FormLabel>
<Input
type="file"
hidden
id="file-input"
onChange={uploadHandler}
multiple={false}
disabled={isImageUploadLoading}
/>
</Flex>
<Stack
borderWidth="1px"
borderRadius="lg"
overflow="hidden"
p={5}
mt="-2"
spacing="4"
as="form"
onSubmit={updateHandler}
>
<FormControl id="email" isRequired>
<FormLabel>Email</FormLabel>
<Input type="email" isDisabled={true} value={email} />
</FormControl>
<FormControl id="username" isRequired>
<FormLabel>Username</FormLabel>
<Input
placeholder="Add your username here"
type="text"
value={username}
onChange={(event) => setUsername(event.target.value)}
/>
</FormControl>
<FormControl id="website" isRequired>
<FormLabel>Website URL</FormLabel>
<Input
placeholder="Add your website here"
type="url"
value={website}
onChange={(event) => setWebsite(event.target.value)}
/>
</FormControl>
<FormControl id="bio" isRequired>
<FormLabel>Bio</FormLabel>
<Textarea
placeholder="Add your bio here"
value={bio}
onChange={(event) => setBio(event.target.value)}
/>
</FormControl>
<Button colorScheme="blue" type="submit" isLoading={isLoading}>
Update
</Button>
</Stack>
</Box>
</Box>
);
};
export default Profile;
여기에 4개의 FormControl요소가 있으며 값이 있으면 각각이 미리 채워집니다. useEffect이는 Supabase 클라이언트를 사용하여 auth및 profiles테이블 에서 사용자 레코드를 가져오는 렌더 실행 시 가능하기 때문입니다 .
참고: 인증 테이블은 Supabase에서 유지 관리하며 다음 명령을 사용하여 클라이언트를 통해 액세스할 수 있습니다.
이미지를 제외한 다른 기록은 updateHandler기능을 사용하여 업데이트할 수 있습니다. 이 기능은 를 사용하여 사용자 기록을 업데이트합니다 id.
이 uploadHandler기능은 이미지를 스토리지 버킷에 업로드하고 를 avatarurl기반으로 하는 레코드에 대한 프로필 테이블을 설정하는 역할을 합니다 id.
uploadSupabase의 메소드는 이미지를 업로드하는 반면 메소드 는 이미지 getPublicUrl의 공개 URL을 제공합니다. 이 from('profiles').update방법을 사용하여 레코드를 업데이트합니다.
방문 http://localhost:3000하여 프로필 링크를 클릭하세요. 다음 보기가 표시됩니다.
이제 업데이트 방법을 사용하여 사용자 이름, 웹사이트 URL 및 약력을 업데이트할 수 있습니다.
이것으로 우리의 TodoApp이 완성되었고 생산을 위한 준비가 되었습니다.
참고 자료