feat(coach): AI 코칭 버튼 및 버블 팝업 대화형 조언 기능 구현

This commit is contained in:
Yun Chan 2026-08-18 22:15:19 +09:00
parent 8a34dd2afc
commit 70d7cb34a3
36 changed files with 4521 additions and 1264 deletions

View file

@ -1,5 +1,5 @@
import { useEffect, useLayoutEffect, useRef, type ReactNode } from "react";
import { useLocation } from "react-router-dom";
import { useLocation, useNavigate } from "react-router-dom";
import { Topbar } from "./Topbar";
import { Sidebar } from "./Sidebar";
import { canAccessRole, designRoleOf, useAuth, type Role } from "../../lib/auth";
@ -78,6 +78,79 @@ export function AppShell({ children, className, contextLabel, hideNav, bleed, wi
return () => window.removeEventListener("pageshow", handlePageShow);
}, []);
const navigate = useNavigate();
// ── 전역 단축키 핸들러 ──
useEffect(() => {
const handleGlobalKeyDown = (e: KeyboardEvent) => {
if (e.repeat) return;
const target = e.target instanceof HTMLElement ? e.target : null;
const isInput = target?.closest("input, textarea, select, [contenteditable='true']");
const isAlt = e.altKey && !e.ctrlKey && !e.metaKey;
const isCtrl = (e.ctrlKey || e.metaKey) && !e.altKey;
// Ctrl+W 또는 Escape: 활성 모달 닫기
if ((isCtrl && e.key.toLowerCase() === "w") || e.key === "Escape") {
const openModalCloseBtn = document.querySelector<HTMLButtonElement>(
"[role='dialog'] button:has(svg), [role='dialog'] .sx-panel-close-btn, .vg-dialog__close, [role='dialog'] button[aria-label*='닫기']"
);
if (openModalCloseBtn) {
e.preventDefault();
openModalCloseBtn.click();
return;
}
}
// Alt+H: 홈으로 이동
if (isAlt && e.key.toLowerCase() === "h") {
e.preventDefault();
const homePath = user?.role === "admin" ? "/admin" : user?.role === "teacher" ? "/professor" : "/learn";
navigate(homePath);
return;
}
// Alt+N 또는 Ctrl+N 또는 Ctrl+T (입력창 밖): 새 회기 시작 / 페르소나 선택
if ((isAlt && e.key.toLowerCase() === "n") || (!isInput && isCtrl && (e.key.toLowerCase() === "n" || e.key.toLowerCase() === "t"))) {
e.preventDefault();
navigate("/learn");
return;
}
// Alt+K: 학습 기록으로 이동
if (isAlt && e.key.toLowerCase() === "k") {
e.preventDefault();
navigate("/learn/history");
return;
}
// Alt+S: 설정 화면으로 이동
if (isAlt && e.key.toLowerCase() === "s") {
e.preventDefault();
navigate("/settings");
return;
}
// Alt+T: 다크/라이트 테마 전환
if (isAlt && e.key.toLowerCase() === "t") {
e.preventDefault();
const themeBtn = document.querySelector<HTMLButtonElement>(".vg-topbar__theme-btn, [data-testid='theme-toggle']");
if (themeBtn) {
themeBtn.click();
} else {
const currentTheme = document.documentElement.getAttribute("data-theme") || "light";
const nextTheme = currentTheme === "dark" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", nextTheme);
localStorage.setItem("vignette_theme", nextTheme);
}
return;
}
};
window.addEventListener("keydown", handleGlobalKeyDown);
return () => window.removeEventListener("keydown", handleGlobalKeyDown);
}, [navigate, user]);
return (
<div
className={[