Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/features/sessions/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,11 @@ export async function endSession(sessionId: string): Promise<EndSessionResponse>
);
return data;
}

/** 참가자 강퇴 (호스트) */
export async function deleteParticipant(
sessionId: string,
participantId: string,
): Promise<void> {
await apiClient.delete(`/sessions/${sessionId}/participants/${participantId}`);
}
1 change: 1 addition & 0 deletions src/features/sessions/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export { useCreateSession } from './useCreateSession';
export { useStartSession } from './useStartSession';
export { useNextQuestion } from './useNextQuestion';
export { useEndSession } from './useEndSession';
export { useKickParticipant } from './useKickParticipant';
16 changes: 16 additions & 0 deletions src/features/sessions/hooks/useKickParticipant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
'use client';

import { useMutation } from '@tanstack/react-query';
import { deleteParticipant } from '../api';

/**
* 참가자 강퇴 (호스트)
*
* 성공 시 서버가 /topic/.../participants 로 갱신된 목록을 브로드캐스트하므로
* 별도 invalidate 없이 소켓 이벤트로 목록이 갱신됩니다.
*/
export function useKickParticipant(sessionId: string) {
return useMutation({
mutationFn: (participantId: string) => deleteParticipant(sessionId, participantId),
});
}
53 changes: 43 additions & 10 deletions src/features/sessions/ui/HostWaitingClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,26 @@

import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useSession, useStartSession } from '../hooks';
import { useSession, useStartSession, useKickParticipant } from '../hooks';
import { useSessionSocket } from '../socket/hooks';
import { getApiErrorMessage } from '@/shared/api/error';
import { CountUp } from '@/shared/ui/CountUp';
import type { SessionParticipantsEvent } from '@/shared/types/api';

export function HostWaitingClient({ sessionId }: { sessionId: string }) {
const router = useRouter();
const { data: session, isLoading } = useSession(sessionId);
const { mutate: startSession, isPending } = useStartSession();
const { mutate: kick, isPending: kicking } = useKickParticipant(sessionId);
const [errorMsg, setErrorMsg] = useState('');
const [participants, setParticipants] = useState<SessionParticipantsEvent['participants']>([]);

// WebSocket — 참가자 입장 이벤트 수신
// WebSocket — 참가자 입·퇴장 실시간 목록
const { connected } = useSessionSocket({
sessionId,
enabled: !!session,
onParticipants: () => {
// 참가자 목록 변경 시 session 쿼리 자동 refetch (3초 폴링)
},
onParticipants: (e) => setParticipants(e.participants),
onStatus: (e) => {
// 세션 상태 변경 감지
if (e.status === 'IN_PROGRESS') {
router.push(`/host/sessions/${sessionId}/play`);
}
Expand Down Expand Up @@ -52,7 +52,9 @@ export function HostWaitingClient({ sessionId }: { sessionId: string }) {
);
}

const canStart = !isPending && session.participantCount > 0;
// 실시간 인원: WS 목록이 있으면 그 길이, 없으면 폴링 카운트
const liveCount = participants.length > 0 ? participants.length : session.participantCount;
const canStart = !isPending && liveCount > 0;

return (
<div className="stage flex-1 min-h-screen flex flex-col items-center justify-center gap-8 p-8">
Expand All @@ -66,7 +68,7 @@ export function HostWaitingClient({ sessionId }: { sessionId: string }) {
</h1>
</div>

{/* PIN 디스플레이 — RED 네온 */}
{/* PIN — RED 네온 */}
<div
className="text-center"
style={{ background: 'var(--color-primary)', boxShadow: 'var(--glow-red-neon)', borderRadius: 24, padding: '32px 56px' }}
Expand All @@ -86,7 +88,7 @@ export function HostWaitingClient({ sessionId }: { sessionId: string }) {
style={{ background: connected ? 'var(--color-correct)' : 'var(--stage-muted)' }}
/>
<span className="text-xl font-bold tabular" style={{ color: 'var(--stage-text)' }}>
<CountUp value={session.participantCount} />
<CountUp value={liveCount} />
</span>
<span className="text-xl font-bold" style={{ color: 'var(--stage-text)' }}>
명 참가 중
Expand All @@ -98,6 +100,37 @@ export function HostWaitingClient({ sessionId }: { sessionId: string }) {
)}
</div>

{/* 참가자 목록 + 강퇴 */}
{participants.length > 0 && (
<div className="stage-card w-full max-w-md p-4 flex flex-col gap-2">
<p className="text-sm font-semibold" style={{ color: 'var(--stage-text)' }}>
참가자 목록
</p>
<div className="flex flex-col gap-1 overflow-y-auto" style={{ maxHeight: 240 }}>
{participants.map((p) => (
<div
key={p.participantId}
className="flex items-center justify-between px-3 py-2 rounded-xl"
style={{ background: 'rgba(255,255,255,0.05)' }}
>
<span className="text-sm truncate" style={{ color: 'var(--stage-text)' }}>
{p.nickname}
</span>
<button
type="button"
onClick={() => kick(p.participantId)}
disabled={kicking}
className="text-xs font-semibold shrink-0 ml-2 hover:opacity-70 transition-opacity"
style={{ color: 'var(--color-primary-disabled)', cursor: kicking ? 'not-allowed' : 'pointer' }}
>
강퇴
</button>
</div>
))}
</div>
</div>
)}

{/* 세션 정보 */}
<div className="flex gap-4 text-center">
<div className="stage-card px-5 py-3">
Expand Down Expand Up @@ -130,7 +163,7 @@ export function HostWaitingClient({ sessionId }: { sessionId: string }) {
{isPending ? '시작 중…' : '퀴즈 시작!'}
</button>

{session.participantCount === 0 && (
{liveCount === 0 && (
<p style={{ fontSize: 13, color: 'var(--stage-muted)' }}>
참가자가 1명 이상 있어야 시작할 수 있습니다.
</p>
Expand Down
Loading