세션 종료 UX 정리

This commit is contained in:
Yun Chan 2026-06-27 18:09:48 +09:00
parent f472883c31
commit 1007eaf7d9
15 changed files with 460 additions and 245 deletions

View file

@ -4,7 +4,7 @@
· (296): +
· 중앙: 어두운 STAGE(bg-stage) ClientAvatar + (4) + ()
· (300): ( 1, 6 ) + +
· (80): + + [||] segmented +
· (80): + + [||] segmented +
STT/TTS voice . UI + 1 :
lib/api sessionApi.stream(POST SSE)
@ -37,7 +37,6 @@ import {
} from "../lib/api";
import { useAuth } from "../lib/auth";
import { formatElapsed, formatTimecode, clamp01 } from "../lib/format";
import { SlideToEnd } from "./session/SlideToEnd";
import "./session/session.css";
/* ── 도메인 상수/타입 ───────────────────────────────────────────────── */
@ -428,6 +427,11 @@ export default function Session() {
const [voiceAvailable, setVoiceAvailable] = useState<boolean | null>(null);
const [resumedSessionLoaded, setResumedSessionLoaded] = useState(false);
const [voiceAnalyser, setVoiceAnalyser] = useState<AnalyserNode | null>(null);
const [endDialogOpen, setEndDialogOpen] = useState(false);
const [ending, setEnding] = useState(false);
const endCancelRef = useRef<HTMLButtonElement>(null);
const endConfirmRef = useRef<HTMLButtonElement>(null);
const endPreviousFocusRef = useRef<HTMLElement | null>(null);
// ── 라이브 신호(앰비언트 도트) ──
const [liveSignal, setLiveSignal] = useState<{ tone: SignalTone; text: string } | null>(null);
@ -1187,21 +1191,62 @@ export default function Session() {
};
}, [shutdownVoice]);
// 회기 종료(밀어서 확인) — end 호출 후 현재 화면을 정지 상태로 둔다.
useEffect(() => {
if (!endDialogOpen) return;
endPreviousFocusRef.current =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
window.setTimeout(() => endCancelRef.current?.focus(), 0);
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape" && !ending) {
setEndDialogOpen(false);
return;
}
if (event.key === "Tab") {
const controls = [endCancelRef.current, endConfirmRef.current].filter(
(control): control is HTMLButtonElement => control !== null && !control.disabled,
);
if (controls.length === 0) return;
const first = controls[0];
const last = controls[controls.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [endDialogOpen, ending]);
useEffect(() => {
if (endDialogOpen || ending) return;
endPreviousFocusRef.current?.focus();
endPreviousFocusRef.current = null;
}, [endDialogOpen, ending]);
// 회기 종료 — 명시 확인 후 end 호출, 현재 화면을 정지 상태로 둔다.
const handleEnd = useCallback(async () => {
if (ending) return;
setEnding(true);
try {
if (liveSessionId) await sessionApi.end(liveSessionId);
} catch {
/* 종료 실패는 UI 종료 처리를 막지 않는다. */
}
setEndDialogOpen(false);
setMicOn(false);
setPaused(true);
setAvatarState("idle");
pushSignal("neutral", "회기 종료");
if (liveSessionId) {
navigate(`/learn/session/${liveSessionId}/review`, { replace: true });
} else {
setEnding(false);
}
}, [liveSessionId, navigate, pushSignal]);
}, [ending, liveSessionId, navigate, pushSignal]);
const meters = metersFromOpenness(openness);
@ -1904,10 +1949,69 @@ export default function Session() {
<Icon name={paused ? "play" : "pause"} size={15} />
{paused ? "이어가기" : "일시정지"}
</button>
<SlideToEnd onConfirm={() => void handleEnd()} label="밀어서 종료" />
<button
type="button"
className="sx-end-button"
onClick={() => setEndDialogOpen(true)}
disabled={ending}
>
<Icon name="x" size={15} />
</button>
</div>
</div>
{endDialogOpen ? (
<div
className="sx-end-dialog"
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget && !ending) setEndDialogOpen(false);
}}
>
<section
className="sx-end-dialog__panel"
role="dialog"
aria-modal="true"
aria-labelledby="sx-end-dialog-title"
aria-describedby="sx-end-dialog-desc"
onMouseDown={(event) => event.stopPropagation()}
>
<div className="sx-end-dialog__head">
<span className="sx-end-dialog__icon" aria-hidden="true">
<Icon name="review" size={18} />
</span>
<div>
<h2 id="sx-end-dialog-title"> ?</h2>
<p id="sx-end-dialog-desc">
.
</p>
</div>
</div>
<div className="sx-end-dialog__actions">
<button
ref={endCancelRef}
type="button"
className="sx-end-dialog__secondary"
onClick={() => setEndDialogOpen(false)}
disabled={ending}
>
</button>
<button
ref={endConfirmRef}
type="button"
className="sx-end-dialog__danger"
onClick={() => void handleEnd()}
disabled={ending}
>
{ending ? "종료 중" : "종료하고 리뷰 보기"}
</button>
</div>
</section>
</div>
) : null}
{/* 경과 시간(접근성 — 보조 표기. 화면 우상단 톱바는 셸 소관) */}
<span className="sr-only" aria-live="polite" style={{ position: "absolute", left: -9999 }}>
{formatElapsed(elapsed)}