| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390 |
- '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<number>;
- setCheckedIDs: React.Dispatch<React.SetStateAction<Set<number>>>;
- page: number;
- setPage: React.Dispatch<React.SetStateAction<number>>;
- perPage: number;
- setPerPage: React.Dispatch<React.SetStateAction<number>>;
- 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<number|null>(null);
- const [previewImageUrl, setPreviewImageUrl] = useState<string|null>(null);
- const [thumbErrors, setThumbErrors] = useState<Set<number>>(new Set());
- const [togglingIDs, setTogglingIDs] = useState<Set<number>>(new Set());
- const audioRef = useRef<HTMLAudioElement|null>(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 (
- <div className="alert-config__list-panel">
- <div className="alert-config__toolbar">
- <div className="alert-config__toolbar-left">
- <span className="alert-config__count">총 {items.length}개</span>
- {checkedIDs.size > 0 && (
- <span className="alert-config__count">({checkedIDs.size}개 선택)</span>
- )}
- </div>
- <div className="alert-config__toolbar-right">
- <select
- value={perPage}
- onChange={e => handlePerPageChange(Number(e.target.value))}
- className="alert-config__per-page"
- title="보여질 개수"
- >
- {PER_PAGE_OPTIONS.map(n => (
- <option key={n} value={n}>{n}개씩</option>
- ))}
- </select>
- <button type="button" className="alert-config__btn" onClick={onNew}>
- <FontAwesomeIcon icon={faPlus} />
- 추가
- </button>
- <button
- type="button"
- className="alert-config__btn alert-config__btn--danger"
- onClick={onBatchDelete}
- disabled={checkedIDs.size === 0 || saving}
- >
- 삭제
- </button>
- </div>
- </div>
- <div className="alert-config__table-wrap">
- {loading ? (
- <div className="alert-config__empty">준비 중...</div>
- ) : items.length === 0 ? (
- <div className="alert-config__empty">등록된 알림 설정이 없습니다.</div>
- ) : (
- <table className="alert-config__table">
- <thead>
- <tr>
- <th className="alert-config__th--check">
- <Checkbox
- checked={allChecked} indeterminate={isIndeterminate}
- onCheckedChange={handleSelectAll}
- aria-label="전체선택"
- />
- </th>
- <th>조건</th>
- <th>금액</th>
- <th>제목</th>
- <th>보낼 내용</th>
- <th>노출(초)</th>
- <th>활성</th>
- <th>미디어</th>
- <th>비고</th>
- </tr>
- </thead>
- <tbody>
- {pagedItems.map(item => {
- const isChecked = checkedIDs.has(item.id);
- const hasImage = item.enableImage && item.imageUrl;
- const hasSound = item.enableSound && item.soundUrl;
- return (
- <tr
- key={item.id}
- className={isChecked ? 'alert-config__row--checked' : ''}
- >
- <td className="alert-config__td--check">
- <Checkbox
- checked={isChecked}
- onCheckedChange={() => handleToggleCheck(item.id)}
- aria-label={`${item.id} 선택`}
- />
- </td>
- <td>
- <span className={`alert-config__match-badge alert-config__match-badge--${item.matchType === 1 ? 'exact' : 'min'}`}>
- {item.matchType === 1 ? '정확히' : '이상'}
- </span>
- </td>
- <td>{item.amount.toLocaleString()}원</td>
- <td>{item.title || <span className="text-muted-foreground">미입력</span>}</td>
- <td className="alert-config__td--message">{item.message}</td>
- <td>{item.displayDurationSec}초</td>
- <td>
- <button
- type="button"
- role="switch"
- aria-checked={item.isActive}
- aria-busy={togglingIDs.has(item.id)}
- aria-label={`${item.title || `#${item.id}`} 활성 상태 ${item.isActive ? '끄기' : '켜기'}`}
- className={`alert-config__active-toggle${item.isActive ? ' alert-config__active-toggle--on' : ''}${togglingIDs.has(item.id) ? ' alert-config__active-toggle--busy' : ''}`}
- onClick={() => handleToggleActive(item)}
- disabled={togglingIDs.has(item.id) || saving}
- title={item.isActive ? '비활성화' : '활성화'}
- >
- <span className="alert-config__active-toggle-knob" aria-hidden="true" />
- </button>
- </td>
- <td className="alert-config__td--media">
- <div>
- {hasImage && (
- thumbErrors.has(item.id) ? (
- <button
- type="button"
- className="alert-config__media-btn alert-config__media-btn--image"
- title="이미지 보기"
- onClick={() => setPreviewImageUrl(item.imageUrl!)}
- >
- <FontAwesomeIcon icon={faImage} />
- </button>
- ) : (
- <button
- type="button"
- className="alert-config__media-thumb"
- title="이미지 크게 보기"
- onClick={() => setPreviewImageUrl(item.imageUrl!)}
- >
- <Image
- src={item.imageUrl!}
- alt="알림 이미지"
- width={96}
- height={96}
- sizes="96px"
- quality={90}
- className="alert-config__media-thumb-img"
- loading="lazy"
- onError={() => setThumbErrors(prev => new Set(prev).add(item.id))}
- />
- </button>
- )
- )}
- {hasSound && (
- <button
- type="button"
- className={`alert-config__media-btn alert-config__media-btn--sound${playingAudio === item.id ? ' alert-config__media-btn--playing' : ''}`}
- title={playingAudio === item.id ? '사운드 정지' : '사운드 재생'}
- onClick={() => handlePlaySound(item.id, item.soundUrl!)}
- >
- <FontAwesomeIcon icon={playingAudio === item.id ? faStop : faPlay} />
- </button>
- )}
- {!hasImage && !hasSound && (
- <span className="text-muted-foreground">-</span>
- )}
- </div>
- </td>
- <td>
- <button
- type="button"
- className="alert-config__btn alert-config__btn--sm"
- onClick={() => onEdit(item)}
- disabled={isChecked}
- >
- 수정
- </button>
- </td>
- </tr>
- );
- })}
- </tbody>
- </table>
- )}
- </div>
- {totalPages > 1 && (
- <div className="alert-config__pagination">
- <button
- type="button"
- className="alert-config__page-btn"
- disabled={page <= 1}
- onClick={() => setPage(p => p - 1)}
- >
- ◀
- </button>
- {Array.from({ length: totalPages }, (_, i) => i + 1).map(p => (
- <button
- key={p}
- type="button"
- className={`alert-config__page-btn${p === page ? ' alert-config__page-btn--active' : ''}`}
- onClick={() => setPage(p)}
- >
- {p}
- </button>
- ))}
- <button
- type="button"
- className="alert-config__page-btn"
- disabled={page >= totalPages}
- onClick={() => setPage(p => p + 1)}
- >
- ▶
- </button>
- </div>
- )}
- {/* 이미지 확대 모달 */}
- <Dialog open={!!previewImageUrl} onOpenChange={open => { if (!open) { setPreviewImageUrl(null); } }}>
- <DialogContent className="alert-config__media-dialog">
- <DialogTitle className="sr-only">이미지 미리보기</DialogTitle>
- {previewImageUrl && (
- <img
- src={previewImageUrl}
- alt="알림 이미지"
- className="alert-config__media-dialog-img"
- />
- )}
- </DialogContent>
- </Dialog>
- </div>
- );
- }
|