'use client'; import { useEffect, useRef, useState } from 'react'; import Image from 'next/image'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faPlus, faImage, faPlay, faStop } from '@fortawesome/free-solid-svg-icons'; import { Checkbox } from '@/components/ui/checkbox'; import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'; import { fetchApi } from '@/lib/utils/client'; import { useStudioContext } from '@/app/studio/context'; import { useAlertConfigContext } from '../context'; import type { AlertConfigItem } from '@/types/response/donation/alertConfig'; import { PER_PAGE_OPTIONS } from '@/constants/donation'; type Props = { items: AlertConfigItem[]; loading: boolean; saving: boolean; checkedIDs: Set; setCheckedIDs: React.Dispatch>>; page: number; setPage: React.Dispatch>; perPage: number; setPerPage: React.Dispatch>; onNew: () => void; onEdit: (item: AlertConfigItem) => void; onBatchDelete: () => void; }; export default function AlertListPanel({ items, loading, saving, checkedIDs, setCheckedIDs, page, setPage, perPage, setPerPage, onNew, onEdit, onBatchDelete }: Props) { const { channelID } = useStudioContext(); const { setItems } = useAlertConfigContext(); const [playingAudio, setPlayingAudio] = useState(null); const [previewImageUrl, setPreviewImageUrl] = useState(null); const [thumbErrors, setThumbErrors] = useState>(new Set()); const [togglingIDs, setTogglingIDs] = useState>(new Set()); const audioRef = useRef(null); // ── 활성/비활성 토글 ───────────────────────────── const handleToggleActive = async (item: AlertConfigItem) => { if (!channelID || togglingIDs.has(item.id)) { return; } const next = !item.isActive; // 1) in-flight guard (race 방지) setTogglingIDs(prev => { const s = new Set(prev); s.add(item.id); return s; }); // 2) optimistic update setItems(prev => prev.map(x => x.id === item.id ? { ...x, isActive: next } : x)); try { const res = await fetchApi<{ id: number; isActive: boolean }>( `/api/studio/donation/alert/config/${item.id}/active`, { method: 'PATCH', body: { channelID, isActive: next }, silent: true } ); if (!res.success) { // 롤백 setItems(prev => prev.map(x => x.id === item.id ? { ...x, isActive: item.isActive } : x)); alert(res.message || '활성 상태 변경에 실패했습니다.'); } else if (res.data && res.data.isActive !== next) { // 서버가 다른 값 반환 시 ground truth 반영 setItems(prev => prev.map(x => x.id === item.id ? { ...x, isActive: res.data!.isActive } : x)); } } catch (err) { // 네트워크/예외 → 롤백 setItems(prev => prev.map(x => x.id === item.id ? { ...x, isActive: item.isActive } : x)); alert(err instanceof Error ? err.message : '활성 상태 변경에 실패했습니다.'); } finally { setTogglingIDs(prev => { const s = new Set(prev); s.delete(item.id); return s; }); } }; // ── 페이징 ─────────────────────────────────────── const totalPages = Math.max(1, Math.ceil(items.length / perPage)); const pagedItems = items.slice((page - 1) * perPage, page * perPage); const handlePerPageChange = (value: number) => { setPerPage(value); setPage(1); }; // ── 전체선택 ───────────────────────────────────── const visibleIDs = pagedItems.map(i => i.id); const checkedCount = visibleIDs.filter(id => checkedIDs.has(id)).length; const allChecked = pagedItems.length > 0 && checkedCount === visibleIDs.length; const isIndeterminate = checkedCount > 0 && checkedCount < visibleIDs.length; const handleSelectAll = () => { setCheckedIDs(prev => { const next = new Set(prev); if (allChecked) { visibleIDs.forEach(id => next.delete(id)); } else { visibleIDs.forEach(id => next.add(id)); } return next; }); }; const handleToggleCheck = (id: number) => { setCheckedIDs(prev => { const next = new Set(prev); if (next.has(id)) { next.delete(id); } else { next.add(id); } return next; }); }; // ── 사운드 재생/정지 ───────────────────────────── const handlePlaySound = (itemId: number, soundUrl: string) => { // 이미 재생 중이면 정지 if (playingAudio === itemId && audioRef.current) { audioRef.current.pause(); audioRef.current = null; setPlayingAudio(null); return; } // 기존 재생 정지 if (audioRef.current) { audioRef.current.pause(); audioRef.current = null; } const audio = new Audio(soundUrl); audioRef.current = audio; setPlayingAudio(itemId); audio.play().catch(() => {}); audio.addEventListener('ended', () => { setPlayingAudio(null); audioRef.current = null; }); }; // unmount 시 오디오 정리 useEffect(() => { return () => { if (audioRef.current) { audioRef.current.pause(); audioRef.current = null; } }; }, []); return (
총 {items.length}개 {checkedIDs.size > 0 && ( ({checkedIDs.size}개 선택) )}
{loading ? (
준비 중...
) : items.length === 0 ? (
등록된 알림 설정이 없습니다.
) : ( {pagedItems.map(item => { const isChecked = checkedIDs.has(item.id); const hasImage = item.enableImage && item.imageUrl; const hasSound = item.enableSound && item.soundUrl; return ( ); })}
조건 금액 제목 보낼 내용 노출(초) 활성 미디어 비고
handleToggleCheck(item.id)} aria-label={`${item.id} 선택`} /> {item.matchType === 1 ? '정확히' : '이상'} {item.amount.toLocaleString()}원 {item.title || 미입력} {item.message} {item.displayDurationSec}초
{hasImage && ( thumbErrors.has(item.id) ? ( ) : ( ) )} {hasSound && ( )} {!hasImage && !hasSound && ( - )}
)}
{totalPages > 1 && (
{Array.from({ length: totalPages }, (_, i) => i + 1).map(p => ( ))}
)} {/* 이미지 확대 모달 */} { if (!open) { setPreviewImageUrl(null); } }}> 이미지 미리보기 {previewImageUrl && ( 알림 이미지 )}
); }