173 lines
4.8 KiB
TypeScript
173 lines
4.8 KiB
TypeScript
'use client';
|
|
|
|
import { useCallback, useEffect, useState } from 'react';
|
|
|
|
export type PublicRoomState = {
|
|
id: string;
|
|
settings: {
|
|
chzzkEnabled: boolean;
|
|
liveUrl: string;
|
|
channelId: string | null;
|
|
cheesePerDice: number;
|
|
multiplyByCheese: boolean;
|
|
maxMultiplier: number;
|
|
};
|
|
diceQueue: number;
|
|
channelName: string | null;
|
|
chatChannelId: string | null;
|
|
chatStatus: 'idle' | 'connecting' | 'connected' | 'error' | 'disconnected';
|
|
chatError: string | null;
|
|
lastDonations: Array<{
|
|
id: string;
|
|
at: number;
|
|
payAmount: number;
|
|
diceAdded: number;
|
|
donatorNickname: string;
|
|
donationText?: string;
|
|
simulated?: boolean;
|
|
}>;
|
|
chatFeed: Array<{
|
|
id: string;
|
|
at: number;
|
|
nickname: string;
|
|
message: string;
|
|
kind: 'chat' | 'donation';
|
|
payAmount?: number;
|
|
}>;
|
|
};
|
|
|
|
export function useRoom(roomId: string | null) {
|
|
const [room, setRoom] = useState<PublicRoomState | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!roomId) return;
|
|
|
|
const clientId =
|
|
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
|
? crypto.randomUUID()
|
|
: `c-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
|
|
let es: EventSource | null = null;
|
|
let cancelled = false;
|
|
|
|
void fetch(`/api/rooms/${roomId}`)
|
|
.then(async (r) => {
|
|
const j = await r.json();
|
|
if (cancelled) return;
|
|
if (!r.ok) {
|
|
setError(j.error || '방을 불러오지 못했습니다.');
|
|
return;
|
|
}
|
|
if (j.room) setRoom(j.room as PublicRoomState);
|
|
else setError(j.error || '방을 불러오지 못했습니다.');
|
|
|
|
es = new EventSource(
|
|
`/api/rooms/${roomId}/events?clientId=${encodeURIComponent(clientId)}`,
|
|
);
|
|
es.addEventListener('room', (ev) => {
|
|
try {
|
|
setRoom(JSON.parse((ev as MessageEvent).data) as PublicRoomState);
|
|
setError(null);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
});
|
|
es.onerror = () => {
|
|
// keep last state; browser will retry SSE
|
|
};
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) setError('방을 불러오지 못했습니다.');
|
|
});
|
|
|
|
const leave = () => {
|
|
try {
|
|
const payload = JSON.stringify({ clientId });
|
|
if (typeof navigator !== 'undefined' && navigator.sendBeacon) {
|
|
navigator.sendBeacon(
|
|
`/api/rooms/${roomId}/leave`,
|
|
new Blob([payload], { type: 'application/json' }),
|
|
);
|
|
} else {
|
|
void fetch(`/api/rooms/${roomId}/leave`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: payload,
|
|
keepalive: true,
|
|
});
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
};
|
|
|
|
window.addEventListener('pagehide', leave);
|
|
return () => {
|
|
cancelled = true;
|
|
window.removeEventListener('pagehide', leave);
|
|
es?.close();
|
|
leave();
|
|
};
|
|
}, [roomId]);
|
|
|
|
const consumeDice = useCallback(
|
|
async (count = 1) => {
|
|
if (!roomId) return { ok: true as const };
|
|
const res = await fetch(`/api/rooms/${roomId}/consume-dice`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ count }),
|
|
});
|
|
const json = await res.json();
|
|
if (!res.ok) {
|
|
return {
|
|
ok: false as const,
|
|
error: (json.error as string) || '주사위를 소모할 수 없습니다.',
|
|
};
|
|
}
|
|
setRoom(json.room as PublicRoomState);
|
|
return { ok: true as const };
|
|
},
|
|
[roomId],
|
|
);
|
|
|
|
const simulateDonation = useCallback(
|
|
async (payAmount: number) => {
|
|
if (!roomId) return;
|
|
const res = await fetch(`/api/rooms/${roomId}/simulate-donation`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ payAmount }),
|
|
});
|
|
const json = await res.json();
|
|
if (!res.ok) throw new Error(json.error || '시뮬레이션 실패');
|
|
setRoom(json.room as PublicRoomState);
|
|
},
|
|
[roomId],
|
|
);
|
|
|
|
const addDice = useCallback(
|
|
async (count = 1) => {
|
|
if (!roomId) return { ok: false as const, error: '방이 없습니다.' };
|
|
const res = await fetch(`/api/rooms/${roomId}/add-dice`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ count }),
|
|
});
|
|
const json = await res.json();
|
|
if (!res.ok) {
|
|
return {
|
|
ok: false as const,
|
|
error: (json.error as string) || '주사위를 추가할 수 없습니다.',
|
|
};
|
|
}
|
|
setRoom(json.room as PublicRoomState);
|
|
return { ok: true as const };
|
|
},
|
|
[roomId],
|
|
);
|
|
|
|
return { room, error, consumeDice, simulateDonation, addDice };
|
|
}
|