기능 | 축하 메시지(방명록) - 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으로 최신순으로 정렬해서 가져옵니다.
  • 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

⚠️ **GitHub.com Fallback** ⚠️