기능 | 축하 메시지(방명록) - ssseok/wedding.invitation GitHub Wiki
- 하객분들의 축하 메시지를 받을 수 있고 공유하는 방명록입니다.
API KEY 설정하는 방법 & Supabase 설정하는 방법에 대한 설명을 적어놓았습니다.
bun add @supabase/supabase-js
튜토리얼를 이행하셨다면 @supabase/supabase-js
를 설치해주셔야 합니다.
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseKey = import.meta.env.VITE_SUPABASE_API_KEY;
const supabase = createClient(supabaseUrl, supabaseKey);
export default supabase;
- supabase-client.ts 파일을 src 루트 단위에 만들어서 supabase 설정을 맞춥니다.
export interface GuestbookEntry {
id: number;
name: string;
password: string;
comment: string;
created_at: string;
}
export interface GuestbookFormData {
name: string;
password: string;
comment: string;
}
- 방명록에 대한 type들을 지정해 줍니다.(supabase에도 똑같은 type)
bunx --bun shadcn@latest add button
bunx --bun shadcn@latest add dialog
bunx --bun shadcn@latest add input
bunx --bun shadcn@latest add label
import { useEffect, useState } from 'react';
import { GuestbookEntry } from '@/lib/types';
import supabase from '@/supabase-client';
import { CommentCard } from '@/components/comment-card';
import CommentDeleteDialog from './comment-delete-dialog';
import Intersect from '@/common/components/intersect';
import { ChevronDown } from 'lucide-react';
import { Button } from '@/common/components/ui/button';
import LoaderLoading from '@/common/components/loader-loading';
interface ICommentListProps {
onMessageAdded?: () => void;
}
const LIST_SIZE = 5;
export default function CommentList({ onMessageAdded }: ICommentListProps) {
const [messages, setMessages] = useState<GuestbookEntry[]>([]);
const [deleteId, setDeleteId] = useState<number | null>(null);
const [visibleCount, setVisibleCount] = useState(LIST_SIZE);
const [isLoading, setIsLoading] = useState(true);
const fetchMessages = async () => {
setIsLoading(true);
const { data, error } = await supabase
.from('guestbook')
.select('*')
.order('created_at', { ascending: false });
if (!error && data) {
setMessages(data);
}
setIsLoading(false);
};
const handleLoadMore = () => {
setVisibleCount((prev) => prev + LIST_SIZE);
};
useEffect(() => {
fetchMessages();
const channel = supabase
.channel('guestbook_changes')
.on(
'postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'guestbook' },
(payload) => {
setMessages((prev) => [payload.new as GuestbookEntry, ...prev]);
onMessageAdded?.();
},
)
.on(
'postgres_changes',
{ event: 'DELETE', schema: 'public', table: 'guestbook' },
(payload) => {
setMessages((prev) =>
prev.filter((msg) => msg.id !== payload.old.id),
);
onMessageAdded?.();
},
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [onMessageAdded]);
return (
<div className='space-y-4 px-8'>
{isLoading ? (
<LoaderLoading />
) : messages.length === 0 ? (
<div className='flex flex-col items-center justify-center py-10 space-y-2'>
<p className='text-sm text-muted-foreground dark:text-foreground'>
아직 메시지가 없습니다.
</p>
<p className='text-sm text-muted-foreground dark:text-foreground'>
첫 번째 메시지를 남겨보세요!
</p>
</div>
) : (
<>
{messages.slice(0, visibleCount).map((message, index) => (
<Intersect key={message.id} type='data-animate'>
<CommentCard
message={message}
onDeleteClick={() => setDeleteId(message.id)}
data-animate-stage={(index % LIST_SIZE) + 1}
/>
</Intersect>
))}
<CommentDeleteDialog
id={deleteId!}
isOpen={deleteId !== null}
onClose={() => setDeleteId(null)}
onDelete={fetchMessages}
/>
{visibleCount < messages.length && (
<div className='flex justify-center pt-4'>
<Button
variant='ghost'
size='sm'
onClick={handleLoadMore}
className='flex items-center gap-2'
>
더보기
<ChevronDown className='w-4 h-4' />
</Button>
</div>
)}
</>
)}
</div>
);
}
- 상태 관리
-
messages
=> 방명록 메시지 배열을 저장하는 상태입니다. -
deleteId
=> 삭제할 메시지의 ID를 저장하는 상태입니다. null이면 삭제 대화상자가 닫힙니다. -
visibleCount
=> 현재 화면에 표시할 메시지 수입니다. "더보기" 버튼을 클릭하면 증가합니다. -
isLoading
=> 메시지 로딩 중인지 여부를 나타내는 상태입니다.
-
-
fetchMessages
(guestbook 테이블에 데이터를 가져오는 함수)- 제 Supabase
guestbook
라는 테이블이 있고 테이블에 대한 모든 데이터(*
)를created_at
기준으로ascending: false
으로 최신순으로 정렬해서 가져옵니다.
- 제 Supabase
-
handleLoadMore
(방명록 더보기 함수)- 이전 값에
LIST_SIZE(5)
를 더하여 표시할 메시지 수를 증가시킵니다.
- 이전 값에
- realtime (실시간 업데이트 설정)
- 초기 데이터 로드는 컴포넌트 마운트 시
fetchMessages
함수를 호출하여 메시지를 가져옵니다. -
channel(
guestbook_changes)
로 실시간 업데이트를 위한 채널을 생성합니다. -
postgres_changes
이벤트를 구독하여 데이터베이스 변경사항을 감지합니다. - INSERT 이벤트 처리
- 새 메시지가 추가되면 messages 배열의 맨 앞에 추가합니다.
- onMessageAdded 콜백을 호출하여 상위 컴포넌트에 알립니다.
- DELETE 이벤트 처리
- 메시지가 삭제되면 해당 ID의 메시지를 messages 배열에서 제거합니다.
- onMessageAdded 콜백을 호출하여 상위 컴포넌트에 알립니다.
- 컴포넌트 언마운트 시 채널을 제거하여 리소스 누수를 방지합니다.
- 초기 데이터 로드는 컴포넌트 마운트 시
import { useState } from 'react';
import type { GuestbookFormData } from '@/lib/types';
import { toast } from 'react-hot-toast';
import supabase from '@/supabase-client';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/common/components/ui/dialog';
import { Button } from '@/common/components/ui/button';
import { Label } from '@/common/components/ui/label';
import { Input } from '@/common/components/ui/input';
import { Textarea } from '@/common/components/ui/textarea';
import { Send } from 'lucide-react';
interface CommentFormDialogProps {
onSuccess?: () => void;
}
export default function CommentFormDialog({
onSuccess,
}: CommentFormDialogProps) {
const [open, setOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (isSubmitting) return;
setIsSubmitting(true);
const formData = new FormData(e.currentTarget);
const data: GuestbookFormData = {
name: (formData.get('name') as string) || '익명',
password: formData.get('password') as string,
comment: formData.get('comment') as string,
};
if (!data.password) {
toast.error('비밀번호를 입력해주세요.');
setIsSubmitting(false);
return;
}
if (!data.comment) {
toast.error('메시지 내용을 작성해주세요.');
setIsSubmitting(false);
return;
}
try {
await toast.promise(
async () => {
await new Promise((resolve) => setTimeout(resolve, 500));
const result = await supabase.from('guestbook').insert([data]);
if (result.error) throw result.error;
return result;
},
{
loading: '요청중...',
success: '축하 메시지가 등록되었습니다! 🎉',
error: '오류가 발생했습니다. 다시 시도해주세요.',
},
);
setOpen(false);
e.currentTarget.reset();
onSuccess?.();
} catch (error) {
console.error('Error:', error);
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant='outline' className='rounded-full'>
<Send className='w-4 h-4 mr-2' />
보내기
</Button>
</DialogTrigger>
<DialogContent className='sm:max-w-[425px]'>
<DialogHeader>
<DialogTitle>축하 메시지 작성</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='name' className='text-sm'>
이름 (선택)
</Label>
<Input id='name' name='name' placeholder='익명' />
</div>
<div className='space-y-2'>
<Label htmlFor='password' className='text-sm'>
비밀번호
</Label>
<Input
id='password'
name='password'
type='password'
placeholder='삭제시 필요합니다'
/>
</div>
<div className='space-y-2'>
<Label htmlFor='comment' className='text-sm'>
축하 메시지
</Label>
<Textarea
id='comment'
name='comment'
placeholder='축하 메시지를 작성해주세요.'
className='min-h-[100px]'
/>
</div>
<Button type='submit' className='w-full'>
메시지 등록하기
</Button>
</form>
</DialogContent>
</Dialog>
);
}
- 상태 관리
- onSuccess => 메시지 등록 성공 시 호출될 선택적 콜백 함수입니다.
- open => 다이얼로그의 열림/닫힘 상태를 관리하는 상태 변수입니다.
- isSubmitting => 폼 제출 중인지 여부를 나타내는 상태 변수로, 중복 제출을 방지합니다.
-
handleSubmit
(방명록 form을 제출하는 함수)- e.preventDefault()로 폼의 기본 제출 동작을 방지합니다.(form에 기본 동작인 새로고침 방지)
- 중복 제출 방지를 위한
isSubmitting
상태를 확인하여 이미 제출 중이면 함수를 종료합니다.setIsSubmitting(true)
로 제출 중 상태를 설정합니다. -
FormData API
를 사용하여 폼 요소의 값을 수집합니다. 이름이 입력되지 않은 경우 '익명'으로 기본값을 설정합니다. - 유효성 검사
- 비밀번호가 없으면 오류 토스트를 표시하고 함수를 종료합니다.
- 메시지 내용이 없으면 오류 토스트를 표시하고 함수를 종료합니다.
-
toast.promise
를 사용하여 작업 상태를 toast로 표시합니다.- loading, success, error 로 표시를 표합니다.
- 약간의 지연(500ms)을 추가하여 UX를 향상시킵니다.
- Supabase의 guestbook 테이블에 데이터를 삽입합니다.
-
finally
isSubmitting 상태를 false로 재설정합니다.
import { toast } from 'react-hot-toast';
import supabase from '@/supabase-client';
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/common/components/ui/dialog';
import { Label } from '@/common/components/ui/label';
import { Input } from '@/common/components/ui/input';
import { Button } from '@/common/components/ui/button';
interface ICommentDeleteDialogProps {
id: number;
isOpen: boolean;
onClose: () => void;
onDelete: () => void;
}
export default function CommentDeleteDialog({
id,
isOpen,
onClose,
onDelete,
}: ICommentDeleteDialogProps) {
const [password, setPassword] = useState('');
const handleDelete = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const deleteMessage = async () => {
// 먼저 비밀번호 확인
const { data } = await supabase
.from('guestbook')
.select('id')
.eq('id', id)
.eq('password', password)
.single();
if (!data) {
throw new Error('비밀번호가 일치하지 않습니다.');
}
// 삭제 실행
const { error } = await supabase.from('guestbook').delete().eq('id', id);
if (error) throw error;
onDelete();
onClose();
};
toast.promise(deleteMessage(), {
loading: '메시지를 삭제하고 있습니다...',
success: '메시지가 삭제되었습니다.',
error: (err) => {
return err instanceof Error
? err.message
: '오류가 발생했습니다. 다시 시도해주세요.';
},
});
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className='sm:max-w-[425px]'>
<DialogHeader>
<DialogTitle>메시지 삭제</DialogTitle>
</DialogHeader>
<form onSubmit={handleDelete} className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='delete-password'>비밀번호</Label>
<Input
id='delete-password'
type='password'
value={password}
onChange={(e) => setPassword(e.target.value)}
required
placeholder='비밀번호를 입력해주세요'
/>
</div>
<div className='flex justify-end gap-2'>
<Button type='button' variant='outline' onClick={onClose}>
취소
</Button>
<Button type='submit' variant='destructive'>
삭제
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
- 상태 관리
- id => 삭제할 메시지의 고유 식별자입니다.
- isOpen => 다이얼로그의 열림/닫힘 상태를 제어하는 boolean 값입니다.
- onClose => 다이얼로그를 닫을 때 호출할 콜백 함수입니다.
- onDelete => 메시지 삭제 성공 시 호출할 콜백 함수입니다.
- password => 사용자가 입력한 비밀번호를 저장하는 상태 변수입니다.
-
handleDelete
(방명록 선택하여 삭제하는 함수)-
e.preventDefault()
로 폼의 기본 제출 동작을 방지합니다. - Supabase 쿼리를 사용하여 주어진 id와 사용자가 입력한 password에 일치하는 레코드를 찾습니다.
-
.select('id')
는 id 필드만 선택하여 필요한 데이터만 가져옵니다. -
.eq('id', id)
와.eq('password', password)
는 id와 비밀번호가 일치하는 조건을 설정합니다. -
.single()
은 정확히 하나의 데이터 행을 기대할 때 사용됩니다.
-
https://supabase.com/docs/reference/javascript/introduction
https://supabase.com/docs/guides/realtime
https://supabase.com/docs/guides/realtime/postgres-changes
https://inpa.tistory.com/entry/JS-%F0%9F%93%9A-FormData-%EC%A0%95%EB%A6%AC-fetch-api