세션 종료 UX 정리
This commit is contained in:
parent
f472883c31
commit
1007eaf7d9
15 changed files with 460 additions and 245 deletions
|
|
@ -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)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue