|
|
@@ -1,693 +0,0 @@
|
|
|
-'use client';
|
|
|
-
|
|
|
-import { use, useEffect, useMemo, useState, useRef, useCallback } from 'react';
|
|
|
-import { createPortal } from 'react-dom';
|
|
|
-import * as signalR from '@microsoft/signalr';
|
|
|
-import { Pause, Play, SkipForward, Bell, BellOff, Volume2, Image as ImageIcon, MoreVertical, X, AlertTriangle, Wifi, WifiOff, ChevronUp, ChevronDown } from 'lucide-react';
|
|
|
-import { DonationAlertData, DonationRemoteState, AlertQueueItem, AlertStatusKey, RemoteCrewItem, RemoteCrewMembersResponse, RemoteCrewMemberItem } from '@/types/donation';
|
|
|
-import { fetchApi } from '@/lib/utils/client';
|
|
|
-import './style.scss';
|
|
|
-
|
|
|
-type Props = {
|
|
|
- params: Promise<{ widgetToken: string }>;
|
|
|
-};
|
|
|
-
|
|
|
-const STATUS_LABEL: Record<AlertStatusKey, string> = {
|
|
|
- queued: '대기',
|
|
|
- playing: '재생 중',
|
|
|
- delivered: '완료',
|
|
|
- skipped: '건너뜀',
|
|
|
- ignored: '무시',
|
|
|
- failed: '실패'
|
|
|
-};
|
|
|
-
|
|
|
-type PlayingMeta = { startedAt: number; durationSec: number };
|
|
|
-
|
|
|
-/**
|
|
|
- * 둥근 시계형 카운트다운 — 보라색 호가 시계방향으로 차오르며 남은 초 표시.
|
|
|
- * 매 250ms 다시 렌더링 (성능 negligible).
|
|
|
- */
|
|
|
-function TimerRing({ meta }: { meta: PlayingMeta }) {
|
|
|
- const [now, setNow] = useState<number>(() => Date.now());
|
|
|
- useEffect(() => {
|
|
|
- const id = setInterval(() => setNow(Date.now()), 250);
|
|
|
- return () => clearInterval(id);
|
|
|
- }, []);
|
|
|
-
|
|
|
- const elapsedSec = (now - meta.startedAt) / 1000;
|
|
|
- const remainingSec = Math.max(0, meta.durationSec - elapsedSec);
|
|
|
- const progress = Math.min(1, elapsedSec / meta.durationSec);
|
|
|
-
|
|
|
- const r = 12;
|
|
|
- const c = 2 * Math.PI * r;
|
|
|
- const offset = c * (1 - progress);
|
|
|
-
|
|
|
- return (
|
|
|
- <span className="dpot-remote__timer-ring" aria-label={`남은 시간 ${Math.ceil(remainingSec)}초`}>
|
|
|
- <svg width="30" height="30" viewBox="0 0 30 30">
|
|
|
- <circle cx="15" cy="15" r={r} fill="none" stroke="rgba(255,255,255,0.15)" strokeWidth="2.5" />
|
|
|
- <circle
|
|
|
- cx="15" cy="15" r={r}
|
|
|
- fill="none"
|
|
|
- stroke="#7c3aed"
|
|
|
- strokeWidth="2.5"
|
|
|
- strokeLinecap="round"
|
|
|
- strokeDasharray={c}
|
|
|
- strokeDashoffset={offset}
|
|
|
- transform="rotate(-90 15 15)"
|
|
|
- style={{ transition: 'stroke-dashoffset 0.25s linear' }}
|
|
|
- />
|
|
|
- </svg>
|
|
|
- <span className="dpot-remote__timer-num">{Math.ceil(remainingSec)}</span>
|
|
|
- </span>
|
|
|
- );
|
|
|
-}
|
|
|
-
|
|
|
-type TabKey = 'all'|'queued'|'ignored'|'failed'|'skipped'|'delivered';
|
|
|
-
|
|
|
-const TAB_DEFS: { key: TabKey; label: string }[] = [
|
|
|
- { key: 'all', label: '전체' },
|
|
|
- { key: 'queued', label: '대기' },
|
|
|
- { key: 'ignored', label: '무시' },
|
|
|
- { key: 'failed', label: '실패' },
|
|
|
- { key: 'skipped', label: '건너뜀' },
|
|
|
- { key: 'delivered', label: '완료' }
|
|
|
-];
|
|
|
-
|
|
|
-export default function RemotePage({ params }: Props)
|
|
|
-{
|
|
|
- const { widgetToken } = use(params);
|
|
|
- const apiBase = '/api/donation/remote';
|
|
|
- const hubUrl = process.env.NEXT_PUBLIC_API_URL + '/hubs/donation';
|
|
|
-
|
|
|
- const [connected, setConnected] = useState(false);
|
|
|
- const [state, setState] = useState<DonationRemoteState>({ isPaused: false, isAccepting: true, isAudioOnly: false, isVideoOnly: false });
|
|
|
- const [queue, setQueue] = useState<AlertQueueItem[]>([]);
|
|
|
- const [openMenuID, setOpenMenuID] = useState<number|null>(null);
|
|
|
- const [menuPos, setMenuPos] = useState<{ top: number; right: number }|null>(null);
|
|
|
- const [activeTab, setActiveTab] = useState<TabKey>('all');
|
|
|
- const [isPopup, setIsPopup] = useState(false);
|
|
|
- const [playingMeta, setPlayingMeta] = useState<Record<number, PlayingMeta>>({});
|
|
|
-
|
|
|
- // 통합 테스트 후원 form (selectedCrewID null 이면 일반, 값 있으면 크루 후원)
|
|
|
- const [testName, setTestName] = useState('');
|
|
|
- const [testAmount, setTestAmount] = useState<number>(1000);
|
|
|
- const [testMessage, setTestMessage] = useState('');
|
|
|
- const [isTestMode, setIsTestMode] = useState(true);
|
|
|
- const [testSubmitting, setTestSubmitting] = useState(false);
|
|
|
- const [crews, setCrews] = useState<RemoteCrewItem[]>([]);
|
|
|
- const [crewMembers, setCrewMembers] = useState<RemoteCrewMemberItem[]>([]);
|
|
|
- const [selectedCrewID, setSelectedCrewID] = useState<number|null>(null);
|
|
|
- const [selectedMemberID, setSelectedMemberID] = useState<number|null>(null);
|
|
|
-
|
|
|
- const connectionRef = useRef<signalR.HubConnection|null>(null);
|
|
|
-
|
|
|
- useEffect(() => {
|
|
|
- setIsPopup(typeof window !== 'undefined' && !!window.opener);
|
|
|
- }, []);
|
|
|
-
|
|
|
- // 메뉴 외부 클릭 시 닫기
|
|
|
- useEffect(() => {
|
|
|
- if (openMenuID === null) {
|
|
|
- return;
|
|
|
- }
|
|
|
- const handler = (e: MouseEvent) => {
|
|
|
- const target = e.target as HTMLElement;
|
|
|
- if (!target.closest('.dpot-remote__menu-dropdown') && !target.closest('.dpot-remote__menu-btn')) {
|
|
|
- setOpenMenuID(null);
|
|
|
- setMenuPos(null);
|
|
|
- }
|
|
|
- };
|
|
|
- document.addEventListener('click', handler);
|
|
|
- return () => document.removeEventListener('click', handler);
|
|
|
- }, [openMenuID]);
|
|
|
-
|
|
|
- const toggleMenu = (alertID: number, e: React.MouseEvent<HTMLButtonElement>) => {
|
|
|
- if (openMenuID === alertID) {
|
|
|
- setOpenMenuID(null);
|
|
|
- setMenuPos(null);
|
|
|
- return;
|
|
|
- }
|
|
|
- const rect = e.currentTarget.getBoundingClientRect();
|
|
|
- setMenuPos({
|
|
|
- top: rect.bottom + 4,
|
|
|
- right: window.innerWidth - rect.right
|
|
|
- });
|
|
|
- setOpenMenuID(alertID);
|
|
|
- };
|
|
|
-
|
|
|
- const closeMenu = () => {
|
|
|
- setOpenMenuID(null);
|
|
|
- setMenuPos(null);
|
|
|
- };
|
|
|
-
|
|
|
- const loadState = useCallback(async () => {
|
|
|
- try {
|
|
|
- const res = await fetchApi<DonationRemoteState & { queue: AlertQueueItem[] }>(`${apiBase}/state/${widgetToken}`, { silent: true });
|
|
|
- if (res.data) {
|
|
|
- setState({ isPaused: res.data.isPaused, isAccepting: res.data.isAccepting, isAudioOnly: res.data.isAudioOnly, isVideoOnly: res.data.isVideoOnly });
|
|
|
- setQueue(res.data.queue || []);
|
|
|
- }
|
|
|
- } catch {}
|
|
|
- }, [apiBase, widgetToken]);
|
|
|
-
|
|
|
- const loadCrews = useCallback(async () => {
|
|
|
- try {
|
|
|
- const res = await fetchApi<{ crews: RemoteCrewItem[] }>(`${apiBase}/crews/${widgetToken}`, { silent: true });
|
|
|
- if (res.data) {
|
|
|
- setCrews(res.data.crews);
|
|
|
- }
|
|
|
- } catch {}
|
|
|
- }, [apiBase, widgetToken]);
|
|
|
-
|
|
|
- const loadCrewMembers = useCallback(async (crewID: number) => {
|
|
|
- try {
|
|
|
- const res = await fetchApi<RemoteCrewMembersResponse>(`${apiBase}/crews/${widgetToken}/${crewID}/members`, { silent: true });
|
|
|
- if (res.data) {
|
|
|
- setCrewMembers(res.data.members);
|
|
|
- setSelectedMemberID(null);
|
|
|
- }
|
|
|
- } catch {}
|
|
|
- }, [apiBase, widgetToken]);
|
|
|
-
|
|
|
- const connectHub = useCallback(() => {
|
|
|
- const conn = new signalR.HubConnectionBuilder().withUrl(hubUrl).withAutomaticReconnect().build();
|
|
|
-
|
|
|
- conn.on('ReceiveAlert', (data: DonationAlertData) => {
|
|
|
- setQueue(prev => {
|
|
|
- const existing = prev.find(q => q.alertID === data.alertID);
|
|
|
- if (existing) {
|
|
|
- return prev.map(q => q.alertID === data.alertID ? { ...q, status: 'queued' as AlertStatusKey } : q);
|
|
|
- }
|
|
|
- const item: AlertQueueItem = {
|
|
|
- alertID: data.alertID,
|
|
|
- donationID: data.donationID,
|
|
|
- status: 'queued',
|
|
|
- sponsorMemberID: data.sponsorMemberID,
|
|
|
- sendName: data.sendName,
|
|
|
- amount: data.amount,
|
|
|
- message: data.message,
|
|
|
- isTest: data.isTest,
|
|
|
- createdAt: data.createdAt
|
|
|
- };
|
|
|
- return [item, ...prev];
|
|
|
- });
|
|
|
- });
|
|
|
-
|
|
|
- conn.on('ReceiveState', (s: DonationRemoteState) => setState(s));
|
|
|
- conn.on('ReceiveSkip', () => {
|
|
|
- setQueue(prev => prev.map(q => q.status === 'playing' ? { ...q, status: 'skipped' } : q));
|
|
|
- });
|
|
|
-
|
|
|
- conn.on('ReceiveQueueReorder', (data: { swap?: number[] }) => {
|
|
|
- if (!data?.swap || data.swap.length !== 2) {
|
|
|
- return;
|
|
|
- }
|
|
|
- const [a, b] = data.swap;
|
|
|
- setQueue(prev => {
|
|
|
- const idxA = prev.findIndex(q => q.alertID === a);
|
|
|
- const idxB = prev.findIndex(q => q.alertID === b);
|
|
|
- if (idxA < 0 || idxB < 0) {
|
|
|
- return prev;
|
|
|
- }
|
|
|
- const next = [...prev];
|
|
|
- [next[idxA], next[idxB]] = [next[idxB], next[idxA]];
|
|
|
- return next;
|
|
|
- });
|
|
|
- });
|
|
|
-
|
|
|
- conn.on('ReceiveAlertStatusUpdate', (update: { alertID: number; status: AlertStatusKey; displayDurationSec?: number|null; startedAt?: string|null }) => {
|
|
|
- setQueue(prev => prev.map(q => q.alertID === update.alertID ? { ...q, status: update.status } : q));
|
|
|
-
|
|
|
- // playing 진입 시 타이머 ring 시작용 메타 저장
|
|
|
- if (update.status === 'playing' && update.displayDurationSec && update.displayDurationSec > 0) {
|
|
|
- const started = update.startedAt ? new Date(update.startedAt).getTime() : Date.now();
|
|
|
- setPlayingMeta(prev => ({ ...prev, [update.alertID]: { startedAt: started, durationSec: update.displayDurationSec! } }));
|
|
|
- } else if (update.status !== 'playing') {
|
|
|
- // playing 종료 → 메타 제거 (memory leak 방지)
|
|
|
- setPlayingMeta(prev => {
|
|
|
- if (!(update.alertID in prev)) {
|
|
|
- return prev;
|
|
|
- }
|
|
|
- const next = { ...prev };
|
|
|
- delete next[update.alertID];
|
|
|
- return next;
|
|
|
- });
|
|
|
- }
|
|
|
- });
|
|
|
-
|
|
|
- conn.start().then(() => {
|
|
|
- conn.invoke('JoinChannel', widgetToken);
|
|
|
- setConnected(true);
|
|
|
- }).catch(() => {});
|
|
|
-
|
|
|
- conn.onclose(() => setConnected(false));
|
|
|
- conn.onreconnected(() => { conn.invoke('JoinChannel', widgetToken); setConnected(true); });
|
|
|
- connectionRef.current = conn;
|
|
|
- }, [hubUrl, widgetToken]);
|
|
|
-
|
|
|
- useEffect(() => {
|
|
|
- loadState();
|
|
|
- loadCrews();
|
|
|
- connectHub();
|
|
|
- return () => { connectionRef.current?.stop(); };
|
|
|
- }, [loadState, loadCrews, connectHub]);
|
|
|
-
|
|
|
- useEffect(() => {
|
|
|
- if (selectedCrewID) {
|
|
|
- loadCrewMembers(selectedCrewID);
|
|
|
- } else {
|
|
|
- setCrewMembers([]);
|
|
|
- setSelectedMemberID(null);
|
|
|
- }
|
|
|
- }, [selectedCrewID, loadCrewMembers]);
|
|
|
-
|
|
|
- const togglePause = async () => {
|
|
|
- const next = { ...state, isPaused: !state.isPaused };
|
|
|
- setState(next);
|
|
|
- await fetchApi(`${apiBase}/state`, { method: 'POST', body: { ...next, widgetToken }, silent: true });
|
|
|
- };
|
|
|
-
|
|
|
- const toggleAccepting = async () => {
|
|
|
- const next = { ...state, isAccepting: !state.isAccepting };
|
|
|
- setState(next);
|
|
|
- await fetchApi(`${apiBase}/state`, { method: 'POST', body: { ...next, widgetToken }, silent: true });
|
|
|
- };
|
|
|
-
|
|
|
- const toggleAudioOnly = async () => {
|
|
|
- const next = { ...state, isAudioOnly: !state.isAudioOnly, isVideoOnly: false };
|
|
|
- setState(next);
|
|
|
- await fetchApi(`${apiBase}/state`, { method: 'POST', body: { ...next, widgetToken }, silent: true });
|
|
|
- };
|
|
|
-
|
|
|
- const toggleVideoOnly = async () => {
|
|
|
- const next = { ...state, isVideoOnly: !state.isVideoOnly, isAudioOnly: false };
|
|
|
- setState(next);
|
|
|
- await fetchApi(`${apiBase}/state`, { method: 'POST', body: { ...next, widgetToken }, silent: true });
|
|
|
- };
|
|
|
-
|
|
|
- const skipCurrent = async () => {
|
|
|
- await fetchApi(`${apiBase}/skip/${widgetToken}`, { method: 'POST', silent: true });
|
|
|
- };
|
|
|
-
|
|
|
- const ignoreAlert = async (alertID: number) => {
|
|
|
- await fetchApi(`${apiBase}/ignore/${alertID}`, { method: 'POST', silent: true });
|
|
|
- setQueue(prev => prev.map(q => q.alertID === alertID ? { ...q, status: 'ignored' } : q));
|
|
|
- closeMenu();
|
|
|
- };
|
|
|
-
|
|
|
- // 일시정지 중일 때만, queued 상태 인접 알림과 swap
|
|
|
- const swapAlert = async (alertID: number, direction: 'up'|'down') => {
|
|
|
- const queuedItems = filtered.filter(q => q.status === 'queued');
|
|
|
- const idx = queuedItems.findIndex(q => q.alertID === alertID);
|
|
|
- if (idx < 0) {
|
|
|
- return;
|
|
|
- }
|
|
|
- const targetIdx = direction === 'up' ? idx - 1 : idx + 1;
|
|
|
- if (targetIdx < 0 || targetIdx >= queuedItems.length) {
|
|
|
- return;
|
|
|
- }
|
|
|
- const partner = queuedItems[targetIdx];
|
|
|
- await fetchApi(`${apiBase}/reorder`, {
|
|
|
- method: 'POST',
|
|
|
- body: { widgetToken, alertIDA: alertID, alertIDB: partner.alertID },
|
|
|
- silent: true
|
|
|
- });
|
|
|
- // 백엔드가 broadcast 한 ReceiveQueueReorder 가 도착하면 화면 갱신됨
|
|
|
- };
|
|
|
-
|
|
|
- const resendAlert = async (alertID: number) => {
|
|
|
- await fetchApi(`${apiBase}/resend/${alertID}`, { method: 'POST', silent: true });
|
|
|
- setQueue(prev => prev.map(q => q.alertID === alertID ? { ...q, status: 'queued' } : q));
|
|
|
- closeMenu();
|
|
|
- };
|
|
|
-
|
|
|
- const playNow = async (alertID: number) => {
|
|
|
- closeMenu();
|
|
|
- // status 변경은 SignalR ReceiveAlertStatusUpdate에 맡김 (위젯이 재생 시작 → 'playing' broadcast)
|
|
|
- // 직접 setQueue 하면 race condition으로 'playing'을 덮어씀
|
|
|
- await fetchApi(`${apiBase}/play-now/${alertID}`, { method: 'POST', silent: true });
|
|
|
- };
|
|
|
-
|
|
|
- const markDelivered = async (alertID: number) => {
|
|
|
- closeMenu();
|
|
|
- // Playing 상태에서 stuck된 알림 강제 완료 처리 (위젯 종료/네트워크 단절 등)
|
|
|
- // status 변경은 SignalR broadcast로 동기화
|
|
|
- await fetchApi(`${apiBase}/mark-delivered/${alertID}`, { method: 'POST', silent: true });
|
|
|
- };
|
|
|
-
|
|
|
- const submitTestDonate = async () => {
|
|
|
- if (!testName.trim()) {
|
|
|
- alert('별명을 입력하세요.');
|
|
|
- return;
|
|
|
- }
|
|
|
- if (testAmount < 1000) {
|
|
|
- alert('최소 후원 금액은 1,000원입니다.');
|
|
|
- return;
|
|
|
- }
|
|
|
- if (selectedCrewID && (selectedMemberID === null || selectedMemberID <= 0)) {
|
|
|
- alert('크루원을 선택하세요.');
|
|
|
- return;
|
|
|
- }
|
|
|
- if (!isTestMode) {
|
|
|
- const isCrew = !!selectedCrewID;
|
|
|
- const msg = isCrew
|
|
|
- ? '영구 후원으로 기록됩니다. 활성 크루 세션이 있으면 리더보드에 합산됩니다. 계속하시겠습니까?'
|
|
|
- : '영구 후원으로 기록됩니다. 본인 채널 통계에 반영됩니다. 계속하시겠습니까?';
|
|
|
- const ok = window.confirm(msg);
|
|
|
- if (!ok) {
|
|
|
- return;
|
|
|
- }
|
|
|
- }
|
|
|
- setTestSubmitting(true);
|
|
|
- try {
|
|
|
- await fetchApi(`${apiBase}/test-donate`, {
|
|
|
- method: 'POST',
|
|
|
- body: {
|
|
|
- widgetToken,
|
|
|
- sendName: testName.trim(),
|
|
|
- amount: testAmount,
|
|
|
- message: testMessage.trim() || null,
|
|
|
- crewMemberID: selectedCrewID ? selectedMemberID : null,
|
|
|
- isTest: isTestMode
|
|
|
- },
|
|
|
- silent: false
|
|
|
- });
|
|
|
- setTestMessage('');
|
|
|
- } catch (e) {
|
|
|
- console.error('test-donate failed:', e);
|
|
|
- } finally {
|
|
|
- setTestSubmitting(false);
|
|
|
- }
|
|
|
- };
|
|
|
-
|
|
|
- const counts = useMemo(() => ({
|
|
|
- all: queue.length,
|
|
|
- queued: queue.filter(q => q.status === 'queued' || q.status === 'playing').length,
|
|
|
- ignored: queue.filter(q => q.status === 'ignored').length,
|
|
|
- failed: queue.filter(q => q.status === 'failed').length,
|
|
|
- skipped: queue.filter(q => q.status === 'skipped').length,
|
|
|
- delivered: queue.filter(q => q.status === 'delivered').length
|
|
|
- }), [queue]);
|
|
|
-
|
|
|
- const filtered = useMemo(() => {
|
|
|
- if (activeTab === 'all') {
|
|
|
- return queue;
|
|
|
- }
|
|
|
- if (activeTab === 'queued') {
|
|
|
- return queue.filter(q => q.status === 'queued' || q.status === 'playing');
|
|
|
- }
|
|
|
- return queue.filter(q => q.status === activeTab);
|
|
|
- }, [activeTab, queue]);
|
|
|
-
|
|
|
- const formatTime = (dateStr: string) => {
|
|
|
- const d = new Date(dateStr);
|
|
|
- return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
|
|
|
- };
|
|
|
-
|
|
|
- return (
|
|
|
- <div className="dpot-remote">
|
|
|
- {/* 상단 상태 배너 */}
|
|
|
- <div className={`dpot-remote__banner${state.isPaused ? ' dpot-remote__banner--paused' : ' dpot-remote__banner--playing'}`} role="status">
|
|
|
- {state.isPaused ? (
|
|
|
- <><Pause size={14} aria-hidden="true" /><span>현재 알림 일시정지 중입니다</span></>
|
|
|
- ) : (
|
|
|
- <><Play size={14} aria-hidden="true" /><span>현재 알림 재생 중입니다</span></>
|
|
|
- )}
|
|
|
- </div>
|
|
|
-
|
|
|
- {/* 헤더 */}
|
|
|
- <header className="dpot-remote__header">
|
|
|
- <div className="dpot-remote__brand">
|
|
|
- <span className="dpot-remote__brand-logo">DPOT</span>
|
|
|
- <span className="dpot-remote__brand-title">리모콘</span>
|
|
|
- </div>
|
|
|
- <div className="dpot-remote__header-actions">
|
|
|
- <span className={`dpot-remote__connection${connected ? ' dpot-remote__connection--on' : ' dpot-remote__connection--off'}`}>
|
|
|
- {connected ? <Wifi size={12} aria-hidden="true" /> : <WifiOff size={12} aria-hidden="true" />}
|
|
|
- <span>{connected ? '연결됨' : '연결 끊김'}</span>
|
|
|
- </span>
|
|
|
- {isPopup && (
|
|
|
- <button type="button" className="dpot-remote__close-btn" onClick={() => window.close()} aria-label="닫기">
|
|
|
- <X size={16} aria-hidden="true" />
|
|
|
- </button>
|
|
|
- )}
|
|
|
- </div>
|
|
|
- </header>
|
|
|
-
|
|
|
- {/* 운영 토글 (음성만/영상만/후원수신) */}
|
|
|
- <div className="dpot-remote__sub-controls">
|
|
|
- <button type="button" className={`dpot-remote__sub-btn${state.isAccepting ? '' : ' dpot-remote__sub-btn--off'}`} onClick={toggleAccepting}>
|
|
|
- {state.isAccepting ? <Bell size={14} aria-hidden="true" /> : <BellOff size={14} aria-hidden="true" />}
|
|
|
- <span>{state.isAccepting ? '후원 받는 중' : '후원 안 받음'}</span>
|
|
|
- </button>
|
|
|
- <button type="button" className={`dpot-remote__sub-btn${state.isAudioOnly ? ' dpot-remote__sub-btn--active' : ''}`} onClick={toggleAudioOnly}>
|
|
|
- <Volume2 size={14} aria-hidden="true" />
|
|
|
- <span>음성만</span>
|
|
|
- </button>
|
|
|
- <button type="button" className={`dpot-remote__sub-btn${state.isVideoOnly ? ' dpot-remote__sub-btn--active' : ''}`} onClick={toggleVideoOnly}>
|
|
|
- <ImageIcon size={14} aria-hidden="true" />
|
|
|
- <span>영상만</span>
|
|
|
- </button>
|
|
|
- </div>
|
|
|
-
|
|
|
- {/* 통합 테스트 후원 form (크루 미선택=일반, 선택=크루) */}
|
|
|
- <fieldset className="dpot-remote__test-form">
|
|
|
- <legend className="dpot-remote__test-form-legend">테스트 후원</legend>
|
|
|
- <div className="dpot-remote__test-row">
|
|
|
- <select
|
|
|
- className="dpot-remote__select"
|
|
|
- value={selectedCrewID ?? ''}
|
|
|
- onChange={e => setSelectedCrewID(e.target.value ? parseInt(e.target.value, 10) : null)}
|
|
|
- >
|
|
|
- <option value="">크루 없음 (일반 후원)</option>
|
|
|
- {crews.map(c => (
|
|
|
- <option key={c.id} value={c.id}>크루 · {c.name} ({c.memberCount}명)</option>
|
|
|
- ))}
|
|
|
- </select>
|
|
|
- <select
|
|
|
- className="dpot-remote__select"
|
|
|
- value={selectedMemberID ?? ''}
|
|
|
- onChange={e => setSelectedMemberID(e.target.value ? parseInt(e.target.value, 10) : null)}
|
|
|
- disabled={!selectedCrewID || crewMembers.length === 0}
|
|
|
- >
|
|
|
- <option value="">{selectedCrewID ? '크루원 선택' : '— 크루원 —'}</option>
|
|
|
- {crewMembers.map(m => (
|
|
|
- <option key={m.id} value={m.id}>{m.nickname}{m.role ? ` (${m.role})` : ''}</option>
|
|
|
- ))}
|
|
|
- </select>
|
|
|
- </div>
|
|
|
- <div className="dpot-remote__test-row">
|
|
|
- <input
|
|
|
- type="text"
|
|
|
- className="dpot-remote__input"
|
|
|
- placeholder="별명"
|
|
|
- value={testName}
|
|
|
- onChange={e => setTestName(e.target.value)}
|
|
|
- maxLength={30}
|
|
|
- />
|
|
|
- <input
|
|
|
- type="number"
|
|
|
- className="dpot-remote__input dpot-remote__input--amount"
|
|
|
- placeholder="금액"
|
|
|
- value={testAmount > 0 ? testAmount : ''}
|
|
|
- onChange={e => setTestAmount(parseInt(e.target.value || '0', 10))}
|
|
|
- min={1000}
|
|
|
- step={1000}
|
|
|
- />
|
|
|
- </div>
|
|
|
- <textarea
|
|
|
- className="dpot-remote__textarea"
|
|
|
- placeholder="메시지 (선택)"
|
|
|
- value={testMessage}
|
|
|
- onChange={e => setTestMessage(e.target.value)}
|
|
|
- rows={2}
|
|
|
- maxLength={200}
|
|
|
- />
|
|
|
- <div className="dpot-remote__test-form-footer">
|
|
|
- <label className="dpot-remote__checkbox">
|
|
|
- <input
|
|
|
- type="checkbox"
|
|
|
- checked={isTestMode}
|
|
|
- onChange={e => setIsTestMode(e.target.checked)}
|
|
|
- />
|
|
|
- <span>테스트 모드 (위젯에만 표시, DB 통계 미반영)</span>
|
|
|
- </label>
|
|
|
- <button
|
|
|
- type="button"
|
|
|
- className="dpot-remote__test-submit"
|
|
|
- onClick={submitTestDonate}
|
|
|
- disabled={testSubmitting}
|
|
|
- >
|
|
|
- {testSubmitting ? '전송 중...' : '후원하기'}
|
|
|
- </button>
|
|
|
- </div>
|
|
|
- {!isTestMode && (
|
|
|
- <p className="dpot-remote__warn" role="alert">
|
|
|
- <AlertTriangle size={14} aria-hidden="true" />
|
|
|
- <span>
|
|
|
- {selectedCrewID
|
|
|
- ? '영구 후원으로 기록됩니다. 활성 크루 세션이 있을 경우 리더보드에 합산됩니다.'
|
|
|
- : '영구 후원으로 기록됩니다. 통계에 반영됩니다.'}
|
|
|
- </span>
|
|
|
- </p>
|
|
|
- )}
|
|
|
- </fieldset>
|
|
|
-
|
|
|
- {/* 6개 탭 */}
|
|
|
- <div className="dpot-remote__tabs" role="tablist">
|
|
|
- {TAB_DEFS.map(t => (
|
|
|
- <button
|
|
|
- key={t.key}
|
|
|
- type="button"
|
|
|
- role="tab"
|
|
|
- aria-selected={activeTab === t.key}
|
|
|
- className={`dpot-remote__tab${activeTab === t.key ? ' dpot-remote__tab--active' : ''}`}
|
|
|
- onClick={() => setActiveTab(t.key)}
|
|
|
- >
|
|
|
- <span className="dpot-remote__tab-label">{t.label}</span>
|
|
|
- <span className="dpot-remote__tab-count">{counts[t.key]}</span>
|
|
|
- </button>
|
|
|
- ))}
|
|
|
- </div>
|
|
|
-
|
|
|
- {/* 후원 알림 listing */}
|
|
|
- <div className="dpot-remote__list-wrap">
|
|
|
- <div className="dpot-remote__list-header" role="row">
|
|
|
- <span className="dpot-remote__col dpot-remote__col--reorder">순서</span>
|
|
|
- <span className="dpot-remote__col dpot-remote__col--name">별명</span>
|
|
|
- <span className="dpot-remote__col dpot-remote__col--amount">금액</span>
|
|
|
- <span className="dpot-remote__col dpot-remote__col--msg">메시지</span>
|
|
|
- <span className="dpot-remote__col dpot-remote__col--time">시간</span>
|
|
|
- <span className="dpot-remote__col dpot-remote__col--status">상태</span>
|
|
|
- <span className="dpot-remote__col dpot-remote__col--menu">기능</span>
|
|
|
- </div>
|
|
|
- <div className="dpot-remote__list">
|
|
|
- {filtered.length === 0 && <div className="dpot-remote__empty">후원 알림이 없습니다</div>}
|
|
|
- {filtered.map(item => {
|
|
|
- // queued 항목 중 reorder 가능한 위치 판정
|
|
|
- const queuedItems = filtered.filter(q => q.status === 'queued');
|
|
|
- const qIdx = queuedItems.findIndex(q => q.alertID === item.alertID);
|
|
|
- const canMoveUp = item.status === 'queued' && qIdx > 0;
|
|
|
- const canMoveDown = item.status === 'queued' && qIdx >= 0 && qIdx < queuedItems.length - 1;
|
|
|
- return (
|
|
|
- <div key={item.alertID} className={`dpot-remote__row dpot-remote__row--${item.status}`} role="row">
|
|
|
- <span className="dpot-remote__col dpot-remote__col--reorder">
|
|
|
- {item.status === 'queued' ? (
|
|
|
- <>
|
|
|
- <button
|
|
|
- type="button"
|
|
|
- className="dpot-remote__reorder-btn"
|
|
|
- onClick={() => swapAlert(item.alertID, 'up')}
|
|
|
- disabled={!canMoveUp}
|
|
|
- aria-label="위로"
|
|
|
- title="위로"
|
|
|
- >
|
|
|
- <ChevronUp size={14} aria-hidden="true" />
|
|
|
- </button>
|
|
|
- <button
|
|
|
- type="button"
|
|
|
- className="dpot-remote__reorder-btn"
|
|
|
- onClick={() => swapAlert(item.alertID, 'down')}
|
|
|
- disabled={!canMoveDown}
|
|
|
- aria-label="아래로"
|
|
|
- title="아래로"
|
|
|
- >
|
|
|
- <ChevronDown size={14} aria-hidden="true" />
|
|
|
- </button>
|
|
|
- </>
|
|
|
- ) : null}
|
|
|
- </span>
|
|
|
- <span className="dpot-remote__col dpot-remote__col--name" title={item.sendName}>
|
|
|
- {item.isTest && <span className="dpot-remote__test-badge">TEST</span>}
|
|
|
- {item.sendName}
|
|
|
- </span>
|
|
|
- <span className="dpot-remote__col dpot-remote__col--amount">{item.amount.toLocaleString()}원</span>
|
|
|
- <span className="dpot-remote__col dpot-remote__col--msg" title={item.message ?? ''}>{item.message ?? '—'}</span>
|
|
|
- <span className="dpot-remote__col dpot-remote__col--time">{formatTime(item.createdAt)}</span>
|
|
|
- <span className={`dpot-remote__col dpot-remote__col--status dpot-remote__status-badge dpot-remote__status-badge--${item.status}`}>
|
|
|
- {item.status === 'playing' && playingMeta[item.alertID]
|
|
|
- ? <TimerRing meta={playingMeta[item.alertID]} />
|
|
|
- : (STATUS_LABEL[item.status] ?? item.status)}
|
|
|
- </span>
|
|
|
- <span className="dpot-remote__col dpot-remote__col--menu">
|
|
|
- <button
|
|
|
- type="button"
|
|
|
- className="dpot-remote__menu-btn"
|
|
|
- onClick={(e) => toggleMenu(item.alertID, e)}
|
|
|
- aria-label="메뉴 열기"
|
|
|
- aria-expanded={openMenuID === item.alertID}
|
|
|
- >
|
|
|
- <MoreVertical size={16} aria-hidden="true" />
|
|
|
- </button>
|
|
|
- </span>
|
|
|
- </div>
|
|
|
- );
|
|
|
- })}
|
|
|
- </div>
|
|
|
- </div>
|
|
|
-
|
|
|
- {/* 하단 메인 컨트롤 */}
|
|
|
- <div className="dpot-remote__main-controls">
|
|
|
- <button
|
|
|
- type="button"
|
|
|
- className={`dpot-remote__main-btn dpot-remote__main-btn--pause${state.isPaused ? ' dpot-remote__main-btn--active' : ''}`}
|
|
|
- onClick={() => { if (!state.isPaused) togglePause(); }}
|
|
|
- disabled={state.isPaused}
|
|
|
- >
|
|
|
- <Pause size={18} aria-hidden="true" />
|
|
|
- <span>일시정지</span>
|
|
|
- </button>
|
|
|
- <button
|
|
|
- type="button"
|
|
|
- className="dpot-remote__main-btn dpot-remote__main-btn--skip"
|
|
|
- onClick={skipCurrent}
|
|
|
- >
|
|
|
- <SkipForward size={18} aria-hidden="true" />
|
|
|
- <span>건너뛰기</span>
|
|
|
- </button>
|
|
|
- <button
|
|
|
- type="button"
|
|
|
- className={`dpot-remote__main-btn dpot-remote__main-btn--play${!state.isPaused ? ' dpot-remote__main-btn--active' : ''}`}
|
|
|
- onClick={() => { if (state.isPaused) togglePause(); }}
|
|
|
- disabled={!state.isPaused}
|
|
|
- >
|
|
|
- <Play size={18} aria-hidden="true" />
|
|
|
- <span>재생</span>
|
|
|
- </button>
|
|
|
- </div>
|
|
|
-
|
|
|
- {/* 메뉴 dropdown — Portal로 body에 렌더해서 list overflow 클립 회피 */}
|
|
|
- {openMenuID !== null && menuPos && typeof document !== 'undefined' && (() => {
|
|
|
- const activeItem = queue.find(q => q.alertID === openMenuID);
|
|
|
- if (!activeItem) {
|
|
|
- return null;
|
|
|
- }
|
|
|
- return createPortal(
|
|
|
- <div
|
|
|
- className="dpot-remote__menu-dropdown"
|
|
|
- role="menu"
|
|
|
- style={{ position: 'fixed', top: menuPos.top, right: menuPos.right }}
|
|
|
- >
|
|
|
- {activeItem.status === 'queued' && (
|
|
|
- <>
|
|
|
- <button type="button" className="dpot-remote__menu-item" onClick={() => playNow(activeItem.alertID)}>즉시 재생</button>
|
|
|
- <button type="button" className="dpot-remote__menu-item dpot-remote__menu-item--danger" onClick={() => ignoreAlert(activeItem.alertID)}>무시</button>
|
|
|
- </>
|
|
|
- )}
|
|
|
- {activeItem.status === 'playing' && (
|
|
|
- <button type="button" className="dpot-remote__menu-item" onClick={() => markDelivered(activeItem.alertID)}>완료 처리</button>
|
|
|
- )}
|
|
|
- {(activeItem.status === 'failed' || activeItem.status === 'skipped' || activeItem.status === 'ignored' || activeItem.status === 'delivered') && (
|
|
|
- <button type="button" className="dpot-remote__menu-item" onClick={() => resendAlert(activeItem.alertID)}>재전송</button>
|
|
|
- )}
|
|
|
- {activeItem.status === 'skipped' && (
|
|
|
- <button type="button" className="dpot-remote__menu-item dpot-remote__menu-item--danger" onClick={() => ignoreAlert(activeItem.alertID)}>무시</button>
|
|
|
- )}
|
|
|
- </div>,
|
|
|
- document.body
|
|
|
- );
|
|
|
- })()}
|
|
|
- </div>
|
|
|
- );
|
|
|
-}
|