음성 재생과 운영 배포 정리

This commit is contained in:
Yun Chan 2026-06-28 12:18:20 +09:00
parent 8ed185ce6c
commit ac7db95542
1020 changed files with 46863 additions and 2175 deletions

View file

@ -6,6 +6,8 @@
/learn/session/:sessionId -> Session
/learn/session/:sessionId/review -> SessionReview
/teach -> Professor (teacher data-role=instructor)
/teach/personas -> PersonaStudio (teacher/admin)
/teach/session/:sessionId/review -> SessionReview (teacher read-only)
/admin -> Admin (admin)
/settings -> Settings
/ -> Navigate( , /login)
@ -14,15 +16,18 @@
import type { ReactNode } from "react";
import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-router-dom";
import { AuthProvider, useAuth, roleHomePath, type Role } from "./lib/auth";
import { AuthProvider, canAccessRole, useAuth, roleHomePath, type Role } from "./lib/auth";
import Login from "./pages/Login";
import Onboarding from "./pages/Onboarding";
import PendingApproval from "./pages/PendingApproval";
import LearnerHome from "./pages/LearnerHome";
import AvatarExpressionLab from "./pages/AvatarExpressionLab";
import AvatarPreview from "./pages/AvatarPreview";
import Session from "./pages/Session";
import SessionReview from "./pages/SessionReview";
import Professor from "./pages/Professor";
import PersonaStudio from "./pages/PersonaStudio";
import Admin from "./pages/Admin";
import Settings from "./pages/Settings";
@ -48,7 +53,13 @@ function BootScreen() {
* RequireAuth /login . () roles .
* ( / ).
*/
function RequireAuth({ children, roles }: { children: ReactNode; roles?: Role[] }) {
function RequireAuth({
children,
roles,
}: {
children: ReactNode;
roles?: Role[];
}) {
const { user, loading } = useAuth();
const location = useLocation();
@ -56,95 +67,215 @@ function RequireAuth({ children, roles }: { children: ReactNode; roles?: Role[]
if (!user) {
return <Navigate to="/login" replace state={{ from: location.pathname }} />;
}
if (roles && !roles.includes(user.role)) {
if (roles && !roles.some((role) => canAccessRole(user, role))) {
return <Navigate to={roleHomePath(user.role)} replace />;
}
return <>{children}</>;
}
function OnboardingGate({ children }: { children: ReactNode }) {
const { user, loading } = useAuth();
const location = useLocation();
const path = location.pathname;
if (loading) return <BootScreen />;
if (path === "/pending") return <>{children}</>;
if (user && user.onboardingCompletedAt == null && path !== "/onboarding") {
return <Navigate to="/onboarding" replace state={{ from: path }} />;
}
if (user && user.onboardingCompletedAt != null && (path === "/login" || path === "/onboarding")) {
return <Navigate to={roleHomePath(user.role)} replace />;
}
return <>{children}</>;
}
function PendingApprovalGate({ children }: { children: ReactNode }) {
const { user, loading } = useAuth();
const location = useLocation();
const path = location.pathname;
if (loading) return <BootScreen />;
if (user && user.accountStatus !== "approved" && path !== "/pending") {
return <Navigate to="/pending" replace state={{ from: path }} />;
}
if (user && user.accountStatus === "approved" && path === "/pending") {
return (
<Navigate
to={user.onboardingCompletedAt == null ? "/onboarding" : roleHomePath(user.role)}
replace
/>
);
}
return <>{children}</>;
}
/** 루트(/) — 인증되면 역할 홈, 아니면 /login. */
function RootRedirect() {
const { user, loading } = useAuth();
if (loading) return <BootScreen />;
if (user && user.accountStatus !== "approved") {
return <Navigate to="/pending" replace />;
}
if (user && user.onboardingCompletedAt == null) {
return <Navigate to="/onboarding" replace />;
}
return <Navigate to={user ? roleHomePath(user.role) : "/login"} replace />;
}
function AppRoutes() {
return (
<Routes>
<Route path="/login" element={<Login />} />
<PendingApprovalGate>
<OnboardingGate>
<Routes>
<Route path="/login" element={<Login />} />
{/* dev: 인증 없는 아바타 컴포지션 튜닝 페이지 (실서비스 아님) */}
<Route path="/dev/avatar-preview" element={<AvatarPreview />} />
<Route
path="/pending"
element={
<RequireAuth>
<PendingApproval />
</RequireAuth>
}
/>
{/* 학습자 */}
<Route
path="/learn"
element={
<RequireAuth roles={["learner"]}>
<LearnerHome />
</RequireAuth>
}
/>
<Route
path="/learn/avatar-expressions"
element={
<RequireAuth roles={["learner"]}>
<AvatarExpressionLab />
</RequireAuth>
}
/>
<Route
path="/learn/session/:sessionId"
element={
<RequireAuth roles={["learner"]}>
<Session />
</RequireAuth>
}
/>
<Route
path="/learn/session/:sessionId/review"
element={
<RequireAuth roles={["learner"]}>
<SessionReview />
</RequireAuth>
}
/>
{/* dev: 인증 없는 아바타 컴포지션 튜닝 페이지 (실서비스 아님) */}
<Route path="/dev/avatar-preview" element={<AvatarPreview />} />
{/* 교수자 */}
<Route
path="/teach"
element={
<RequireAuth roles={["teacher"]}>
<Professor />
</RequireAuth>
}
/>
<Route
path="/onboarding"
element={
<RequireAuth>
<Onboarding />
</RequireAuth>
}
/>
{/* 관리자 */}
<Route
path="/admin"
element={
<RequireAuth roles={["admin"]}>
<Admin />
</RequireAuth>
}
/>
{/* 학습자 */}
<Route
path="/learn"
element={
<RequireAuth roles={["learner"]}>
<LearnerHome view="dashboard" />
</RequireAuth>
}
/>
<Route
path="/learn/practice"
element={
<RequireAuth roles={["learner"]}>
<LearnerHome view="practice" />
</RequireAuth>
}
/>
<Route
path="/learn/history"
element={
<RequireAuth roles={["learner"]}>
<LearnerHome view="history" />
</RequireAuth>
}
/>
<Route
path="/learn/avatar-expressions"
element={
<RequireAuth roles={["learner"]}>
<AvatarExpressionLab />
</RequireAuth>
}
/>
<Route
path="/learn/session/:sessionId"
element={
<RequireAuth roles={["learner"]}>
<Session />
</RequireAuth>
}
/>
<Route
path="/learn/session/:sessionId/review"
element={
<RequireAuth roles={["learner"]}>
<SessionReview />
</RequireAuth>
}
/>
{/* 설정 — 3역할 공통 */}
<Route
path="/settings"
element={
<RequireAuth>
<Settings />
</RequireAuth>
}
/>
{/* 교수자 */}
<Route
path="/teach"
element={
<RequireAuth roles={["teacher"]}>
<Professor />
</RequireAuth>
}
/>
<Route
path="/teach/personas"
element={
<RequireAuth roles={["teacher", "admin"]}>
<PersonaStudio />
</RequireAuth>
}
/>
<Route
path="/teach/session/:sessionId/review"
element={
<RequireAuth roles={["teacher"]}>
<SessionReview />
</RequireAuth>
}
/>
<Route path="/" element={<RootRedirect />} />
{/* 미정의 경로 → 루트로 */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
{/* 관리자 */}
<Route
path="/admin"
element={
<RequireAuth roles={["admin"]}>
<Admin section="overview" />
</RequireAuth>
}
/>
<Route
path="/admin/users"
element={
<RequireAuth roles={["admin"]}>
<Admin section="users" />
</RequireAuth>
}
/>
<Route
path="/admin/access"
element={
<RequireAuth roles={["admin"]}>
<Admin section="access" />
</RequireAuth>
}
/>
<Route
path="/admin/tickets"
element={
<RequireAuth roles={["admin"]}>
<Admin section="tickets" />
</RequireAuth>
}
/>
{/* 설정 — 3역할 공통 */}
<Route
path="/settings"
element={
<RequireAuth>
<Settings />
</RequireAuth>
}
/>
<Route path="/" element={<RootRedirect />} />
{/* 미정의 경로 → 루트로 */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</OnboardingGate>
</PendingApprovalGate>
);
}

View file

@ -35,7 +35,14 @@ const VARIANT_FOR_EXPRESSION: Record<AvatarExpression, RasterVariant> = {
};
const LIVE2D_PARTS_ART_SETS = new Set(["seoyeon-live2d-v3"]);
const PSB_GROUP_ART_SETS = new Set(["seoyeon-live2d-psb"]);
const PERSONA_GENERATED_ART_SETS = [
"p4-live2d-generated",
"p5-live2d-generated",
"p6-live2d-generated",
"p7-live2d-generated",
];
const PSB_GROUP_ART_SETS = new Set(["seoyeon-live2d-psb", "seoyeon-live2d-psd-v2", ...PERSONA_GENERATED_ART_SETS]);
const PSB_CRYING_ART_SETS = new Set(["seoyeon-live2d-psd-v2", ...PERSONA_GENERATED_ART_SETS]);
export function rasterVariantFor(expression: AvatarExpression): RasterVariant {
return VARIANT_FOR_EXPRESSION[expression];
@ -100,6 +107,13 @@ function mouthForVariant(variant: RasterVariant): string {
return "mouth-neutral";
}
function blushOpacity(artSet: string, variant: RasterVariant): number {
if (artSet === "seoyeon-live2d-psd-v2") {
return variant === "warm" ? 0.62 : 0.34;
}
return variant === "warm" ? 1 : 0.72;
}
function browPoseForVariant(variant: RasterVariant): { y: number; leftRotate: number; rightRotate: number } {
switch (variant) {
case "sad":
@ -255,6 +269,7 @@ export function RasterBust({
if (PSB_GROUP_ART_SETS.has(artSet)) {
const part = (name: string) => `${import.meta.env.BASE_URL}avatar/${artSet}/parts/${name}.png`;
const cryingArtSet = PSB_CRYING_ART_SETS.has(artSet);
const speakingOpen = speaking ? clamp01(Math.max(mouth, 0.1)) : 0;
const wideOpen = clamp01((speakingOpen - 0.34) / 0.5);
const smallOpen = speakingOpen > 0 ? 1 - wideOpen : 0;
@ -275,6 +290,10 @@ export function RasterBust({
const lowerLidY = eyePose.lowerY - closedEyes * 2.4;
const lashY = eyePose.lashY + closedEyes * 2.1;
const expressionMouth = psbMouthForVariant(variant);
const useSadCryingParts = cryingArtSet && variant === "sad";
const browLeftPart = useSadCryingParts ? "brow-sad-left" : "brow-left";
const browRightPart = useSadCryingParts ? "brow-sad-right" : "brow-right";
const tearOpacity = useSadCryingParts ? clamp01(0.82 + openEyes * 0.18 - closedEyes * 0.35) : 0;
const eyeDetailLayers: Array<{ name: string; mask: string; zIndex: number }> = [
{ name: "iris-left", mask: "eye-white-left", zIndex: 9 },
{ name: "iris-right", mask: "eye-white-right", zIndex: 9 },
@ -329,8 +348,8 @@ export function RasterBust({
{renderLayer("ear-left", "vg-raster__layer--ear", 5)}
{renderLayer("ear-right", "vg-raster__layer--ear", 5)}
{renderLayer("face-base", "vg-raster__layer--face", 6)}
{renderLayer("blush-left", "vg-raster__layer--face", 7, { opacity: variant === "warm" ? 1 : 0.72 })}
{renderLayer("blush-right", "vg-raster__layer--face", 7, { opacity: variant === "warm" ? 1 : 0.72 })}
{renderLayer("blush-left", "vg-raster__layer--face", 7, { opacity: blushOpacity(artSet, variant) })}
{renderLayer("blush-right", "vg-raster__layer--face", 7, { opacity: blushOpacity(artSet, variant) })}
{generatedEyeVariant ? (
<>
{renderLayer(`eyegen-${generatedEyeVariant}-eye-left`, "vg-raster__layer--eyes", 8, {
@ -419,13 +438,13 @@ export function RasterBust({
transform: layerTransform(`translateY(${lowerLidY.toFixed(2)}px)`),
transformOrigin: "60.8% 32.4%",
})}
{renderLayer("brow-left", "vg-raster__layer--brow", 14, {
{renderLayer(browLeftPart, "vg-raster__layer--brow", 14, {
transform: layerTransform(
`translateY(${(brow.y + browFloat - talkBounce * 0.28).toFixed(2)}px) rotate(${(brow.leftRotate - speakingOpen * 0.35 - browFloat * 0.08).toFixed(2)}deg)`,
),
transformOrigin: "40% 27%",
})}
{renderLayer("brow-right", "vg-raster__layer--brow", 14, {
{renderLayer(browRightPart, "vg-raster__layer--brow", 14, {
transform: layerTransform(
`translateY(${(brow.y + browFloat - talkBounce * 0.28).toFixed(2)}px) rotate(${(brow.rightRotate + speakingOpen * 0.35 + browFloat * 0.08).toFixed(2)}deg)`,
),
@ -433,6 +452,20 @@ export function RasterBust({
})}
</>
)}
{cryingArtSet ? (
<>
{renderLayer("tear-left", "vg-raster__layer--tear", 15, {
opacity: tearOpacity,
filter: "drop-shadow(0 0 1.5px rgba(112, 166, 218, 0.72)) brightness(1.18) saturate(1.18)",
transform: layerTransform(`translateY(${(closedEyes * 1.4 + breath * 0.08).toFixed(2)}px)`),
})}
{renderLayer("tear-right", "vg-raster__layer--tear", 15, {
opacity: tearOpacity,
filter: "drop-shadow(0 0 1.5px rgba(112, 166, 218, 0.72)) brightness(1.18) saturate(1.18)",
transform: layerTransform(`translateY(${(closedEyes * 1.4 + breath * 0.08).toFixed(2)}px)`),
})}
</>
) : null}
{renderLayer("nose", "vg-raster__layer--nose", 15)}
{renderLayer("hair-side-left-1", "vg-raster__layer--hair", 16, {
transform: layerTransform(

View file

@ -1,7 +1,7 @@
import type { ReactNode } from "react";
import { useEffect, type ReactNode } from "react";
import { Topbar } from "./Topbar";
import { Sidebar } from "./Sidebar";
import { useAuth } from "../../lib/auth";
import { designRoleOf, useAuth, type Role } from "../../lib/auth";
export interface AppShellProps {
children: ReactNode;
@ -11,8 +11,12 @@ export interface AppShellProps {
hideNav?: boolean;
/** 메인 패딩·최대폭 제거 (풀-블리드 레이아웃) */
bleed?: boolean;
/** 운영형 대시보드/저작 화면용 넓은 작업폭 */
wide?: boolean;
/** 톱바까지 제거하는 실제 전체화면 작업 공간 */
hideTopbar?: boolean;
/** 현재 화면 컨텍스트에 맞춘 네비/크롬 역할 */
navRole?: Role;
}
/**
@ -20,16 +24,33 @@ export interface AppShellProps {
* body[data-role] AuthProvider ( ).
* ( ) (RequireAuth) .
*/
export function AppShell({ children, contextLabel, hideNav, bleed, hideTopbar }: AppShellProps) {
export function AppShell({ children, contextLabel, hideNav, bleed, wide, hideTopbar, navRole }: AppShellProps) {
const { user } = useAuth();
const showNav = !hideNav && !!user;
const shellRole = navRole ?? user?.role;
const mainClassName = [
"vg-main",
bleed ? "vg-main--bleed" : "",
wide ? "vg-main--wide" : "",
]
.filter(Boolean)
.join(" ");
useEffect(() => {
const body = document.body;
if (shellRole) body.setAttribute("data-role", designRoleOf(shellRole));
return () => {
if (user) body.setAttribute("data-role", designRoleOf(user.role));
else body.removeAttribute("data-role");
};
}, [shellRole, user]);
return (
<div className={"vg-shell" + (hideTopbar ? " vg-shell--fullscreen" : "")}>
{hideTopbar ? null : <Topbar contextLabel={contextLabel} />}
<div className={"vg-shell__body" + (showNav ? "" : " vg-shell__body--bare")}>
{showNav ? <Sidebar role={user.role} /> : null}
<main className={"vg-main" + (bleed ? " vg-main--bleed" : "")}>
{showNav ? <Sidebar role={shellRole ?? user.role} /> : null}
<main className={mainClassName}>
<div className="vg-main__inner">{children}</div>
</main>
</div>

View file

@ -16,15 +16,22 @@ export interface NavItem {
*/
const NAV_BY_ROLE: Record<Role, NavItem[]> = {
learner: [
{ to: "/learn", label: "홈", icon: "home", end: true },
{ to: "/learn", label: "대시보드", icon: "home", end: true },
{ to: "/learn/practice", label: "학습", icon: "session" },
{ to: "/learn/history", label: "기록", icon: "review" },
{ to: "/settings", label: "설정", icon: "settings" },
],
teacher: [
{ to: "/teach", label: "콘솔", icon: "users", end: true },
{ to: "/teach/personas", label: "페르소나", icon: "review" },
{ to: "/settings", label: "설정", icon: "settings" },
],
admin: [
{ to: "/admin", label: "운영", icon: "shield", end: true },
{ to: "/admin/users", label: "사용자", icon: "users" },
{ to: "/admin/access", label: "권한", icon: "settings" },
{ to: "/admin/tickets", label: "티켓", icon: "review" },
{ to: "/teach/personas", label: "페르소나", icon: "review" },
{ to: "/settings", label: "설정", icon: "settings" },
],
};

View file

@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { Icon } from "../ui/Icon";
import { useAuth, roleLabel } from "../../lib/auth";
import { canAccessRole, roleHomePath, roleLabel, useAuth, type Role } from "../../lib/auth";
import { applyTheme, readInitialTheme, type AppTheme } from "../../lib/theme";
/** Vignette 워드마크 — 비네트(조리개) inline SVG. dev_dashboard 마크 계승. */
@ -45,9 +45,15 @@ export interface TopbarProps {
export function Topbar({ contextLabel }: TopbarProps) {
const { user, logout } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const [dark, toggleTheme] = useTheme();
const label = contextLabel ?? (user ? roleLabel(user.role) : null);
const switchRoles: Role[] = user?.superAdmin
? ["learner", "teacher", "admin"]
: user?.adminAccess
? ["admin"]
: [];
const onLogout = async () => {
await logout();
@ -69,6 +75,32 @@ export function Topbar({ contextLabel }: TopbarProps) {
<span className="vg-topbar__spacer" />
<div className="vg-topbar__actions">
{user && switchRoles.length > 0 ? (
<div className="vg-topbar__switch" aria-label="공간 전환">
{switchRoles
.filter((role) => canAccessRole(user, role))
.map((role) => {
const to = roleHomePath(role);
const active = location.pathname === to || location.pathname.startsWith(`${to}/`);
return (
<Link
key={role}
className={"vg-topbar__switch-link" + (active ? " is-active" : "")}
to={to}
aria-label={roleLabel(role)}
title={roleLabel(role)}
>
<Icon
name={role === "admin" ? "shield" : role === "teacher" ? "users" : "home"}
size={16}
/>
<span>{role === "admin" ? "관리자" : role === "teacher" ? "교수자" : "학습자"}</span>
</Link>
);
})}
</div>
) : null}
<button
type="button"
className="vg-iconbtn"
@ -83,7 +115,7 @@ export function Topbar({ contextLabel }: TopbarProps) {
<>
<span className="vg-topbar__user">
<span className="vg-topbar__avatar" aria-hidden="true">
{initials(user.name)}
{user.avatarUrl ? <img src={user.avatarUrl} alt="" /> : initials(user.name)}
</span>
<span className="vg-topbar__uname">{user.name}</span>
</span>

View file

@ -14,6 +14,7 @@
/* ── 톱바 ── */
.vg-topbar {
min-width: 0;
height: var(--topbar-h);
background: var(--bg-surface);
border-bottom: 1px solid var(--hair);
@ -66,10 +67,42 @@
flex: 1;
}
.vg-topbar__actions {
min-width: 0;
display: flex;
align-items: center;
gap: var(--sp-2);
}
.vg-topbar__switch {
min-width: 0;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px;
border: 1px solid var(--hair);
border-radius: var(--radius);
background: var(--bg-surface-2);
}
.vg-topbar__switch-link {
min-width: 0;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 0 8px;
border-radius: calc(var(--radius) - 2px);
color: var(--text-muted);
font-size: 12px;
font-weight: 650;
text-decoration: none;
white-space: nowrap;
}
.vg-topbar__switch-link:hover,
.vg-topbar__switch-link.is-active {
background: var(--bg-surface);
color: var(--text-strong);
text-decoration: none;
}
/* 운영 콘솔은 생성 시안처럼 어두운 크롬을 쓴다. 본문 컴포넌트 토큰은 그대로 유지한다. */
body[data-role="admin"] .vg-topbar {
@ -88,6 +121,18 @@ body[data-role="admin"] .vg-topbar__brand svg {
body[data-role="admin"] .vg-topbar__user {
background: rgba(255, 255, 255, 0.08);
}
body[data-role="admin"] .vg-topbar__switch {
border-color: rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.06);
}
body[data-role="admin"] .vg-topbar__switch-link {
color: rgba(238, 244, 242, 0.72);
}
body[data-role="admin"] .vg-topbar__switch-link:hover,
body[data-role="admin"] .vg-topbar__switch-link.is-active {
background: rgba(255, 255, 255, 0.1);
color: #ffffff;
}
body[data-role="admin"] .vg-topbar__avatar {
background: rgba(126, 184, 173, 0.18);
color: #bfe0d9;
@ -126,6 +171,8 @@ body[data-role="admin"] .vg-iconbtn:hover {
/* 사용자 칩 */
.vg-topbar__user {
min-width: 0;
max-width: min(320px, 34vw);
display: inline-flex;
align-items: center;
gap: 9px;
@ -145,8 +192,19 @@ body[data-role="admin"] .vg-iconbtn:hover {
font-size: 11px;
font-weight: 700;
flex: none;
overflow: hidden;
}
.vg-topbar__avatar img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.vg-topbar__uname {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-weight: 600;
color: var(--text-strong);
@ -327,6 +385,9 @@ body[data-role="instructor"] .vg-nav__foot {
max-width: var(--maxw);
margin: 0 auto;
}
.vg-main--wide .vg-main__inner {
max-width: min(1480px, 100%);
}
/* 풀-블리드(세션 화면 등): 패딩/최대폭 없이 셸만 */
.vg-main--bleed {
padding: 0;
@ -411,6 +472,21 @@ body[data-role="instructor"] .vg-nav__foot {
.vg-topbar__uname {
display: none;
}
.vg-topbar {
padding: 0 var(--sp-4);
gap: var(--sp-3);
}
.vg-topbar__user {
max-width: 42px;
padding-right: 6px;
}
.vg-topbar__switch-link span {
display: none;
}
.vg-topbar__switch-link {
width: 28px;
padding: 0;
}
.vg-main {
padding: var(--sp-5) var(--sp-4) var(--sp-7);
}
@ -448,3 +524,152 @@ body[data-role="instructor"] .vg-nav__foot {
padding: 0;
}
}
/* Unified role chrome
역할별 제품처럼 보이던 톱바/사이드바 변형을 하나의 크롬으로 통합한다.
역할 차이는 tokens.css의 accent만 사용하고, 형태·밀도·배경 체계는 동일하게 유지한다. */
body[data-role="admin"] .vg-topbar,
body[data-role="instructor"] .vg-topbar {
background: var(--bg-surface);
border-bottom-color: var(--hair);
color: var(--text-strong);
}
body[data-role="admin"] .vg-topbar__wm,
body[data-role="admin"] .vg-topbar__role,
body[data-role="admin"] .vg-topbar__uname,
body[data-role="admin"] .vg-iconbtn,
body[data-role="instructor"] .vg-topbar__wm,
body[data-role="instructor"] .vg-topbar__role,
body[data-role="instructor"] .vg-topbar__uname,
body[data-role="instructor"] .vg-iconbtn {
color: var(--text-strong);
}
body[data-role="admin"] .vg-topbar__wm .v,
body[data-role="instructor"] .vg-topbar__wm .v,
body[data-role="admin"] .vg-topbar__mark,
body[data-role="instructor"] .vg-topbar__mark,
body[data-role="admin"] .vg-topbar__brand svg,
body[data-role="instructor"] .vg-topbar__brand svg {
color: var(--accent);
}
body[data-role="admin"] .vg-topbar__role,
body[data-role="instructor"] .vg-topbar__role {
border-left-color: var(--hair);
}
body[data-role="admin"] .vg-topbar__user,
body[data-role="instructor"] .vg-topbar__user {
background: var(--bg-surface-2);
}
body[data-role="admin"] .vg-topbar__avatar,
body[data-role="instructor"] .vg-topbar__avatar {
background: var(--accent-tint);
color: var(--accent-deep);
}
body[data-role="admin"] .vg-iconbtn:hover,
body[data-role="instructor"] .vg-iconbtn:hover {
background: var(--bg-surface-2);
color: var(--text-strong);
}
body[data-role="admin"] .vg-shell__body,
body[data-role="instructor"] .vg-shell__body {
--nav-cur: var(--nav-w);
background-image: linear-gradient(var(--hair), var(--hair));
}
body[data-role="admin"] .vg-nav,
body[data-role="instructor"] .vg-nav {
background: var(--bg-surface);
color: var(--text-strong);
padding: var(--sp-5) var(--sp-3);
}
body[data-role="admin"] .vg-nav__label,
body[data-role="admin"] .vg-nav__ethic,
body[data-role="instructor"] .vg-nav__label,
body[data-role="instructor"] .vg-nav__ethic {
color: var(--text-muted);
}
body[data-role="admin"] .vg-nav__item,
body[data-role="instructor"] .vg-nav__item {
flex-direction: row;
justify-content: flex-start;
gap: 11px;
min-height: 0;
padding: 9px 12px;
color: var(--text-body);
font-size: 14px;
text-align: left;
}
body[data-role="admin"] .vg-nav__item:hover,
body[data-role="instructor"] .vg-nav__item:hover {
background: var(--bg-surface-2);
color: var(--text-strong);
}
body[data-role="admin"] .vg-nav__item .vg-nav__ic,
body[data-role="instructor"] .vg-nav__item .vg-nav__ic {
color: var(--text-muted);
}
body[data-role="admin"] .vg-nav__item.is-active,
body[data-role="instructor"] .vg-nav__item.is-active {
background: var(--accent-tint);
color: var(--accent-deep);
}
body[data-role="admin"] .vg-nav__item.is-active .vg-nav__ic,
body[data-role="instructor"] .vg-nav__item.is-active .vg-nav__ic {
color: var(--accent);
}
body[data-role="admin"] .vg-nav__foot,
body[data-role="instructor"] .vg-nav__foot {
border-top-color: var(--hair);
}
@media (max-width: 1024px) {
body[data-role="admin"] .vg-shell__body,
body[data-role="instructor"] .vg-shell__body {
--nav-cur: var(--nav-w-collapsed);
}
body[data-role="admin"] .vg-nav,
body[data-role="instructor"] .vg-nav {
padding: var(--sp-4) var(--sp-2);
}
body[data-role="admin"] .vg-nav__item,
body[data-role="instructor"] .vg-nav__item {
justify-content: center;
padding: 11px;
}
}
@media (max-width: 720px) {
body[data-role="admin"] .vg-shell__body,
body[data-role="instructor"] .vg-shell__body {
display: block;
background-image: none;
}
body[data-role="admin"] .vg-nav,
body[data-role="instructor"] .vg-nav {
position: sticky;
top: var(--topbar-h);
z-index: 29;
height: 58px;
padding: 7px max(12px, env(safe-area-inset-left)) 7px max(12px, env(safe-area-inset-right));
border-bottom: 1px solid var(--hair);
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 8px;
overflow-x: auto;
overflow-y: hidden;
}
body[data-role="admin"] .vg-nav__item,
body[data-role="instructor"] .vg-nav__item {
min-width: 112px;
height: 44px;
justify-content: center;
padding: 0 14px;
gap: 8px;
white-space: nowrap;
}
body[data-role="admin"] .vg-nav__item span:not(.vg-nav__ic),
body[data-role="instructor"] .vg-nav__item span:not(.vg-nav__ic) {
display: inline;
font-size: 13px;
}
}

View file

@ -18,6 +18,7 @@ export type IconName =
| "chevron-right"
| "chevron-left"
| "check"
| "share"
| "mic"
| "mic-off"
| "pause"
@ -93,6 +94,14 @@ const PATHS: Record<IconName, ReactNode> = {
"chevron-right": <polyline points="9 6 15 12 9 18" />,
"chevron-left": <polyline points="15 6 9 12 15 18" />,
check: <polyline points="20 6 9 17 4 12" />,
share: (
<>
<circle cx="18" cy="5" r="3" />
<circle cx="6" cy="12" r="3" />
<circle cx="18" cy="19" r="3" />
<path d="M8.6 10.5 15.4 6.5M8.6 13.5l6.8 4" />
</>
),
mic: (
<>
<path d="M12 2a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z" />

File diff suppressed because it is too large Load diff

View file

@ -173,23 +173,39 @@ export type PersonaSummary = ApiSchema<"PersonaSummary">;
export type PersonaReviewStatus = "draft" | "review" | "approved" | "archived";
export type PersonaReviewAction = ApiSchema<"PersonaReviewDecisionRequest">["action"];
export type PersonaReviewSummary = ApiSchema<"PersonaReviewSummary">;
export type PersonaRevisionRequest = ApiSchema<"PersonaRevisionRequest">;
export type PersonaDraftPayload = ApiSchema<"PersonaDraftPayload">;
export type PersonaDraftDetail = ApiSchema<"PersonaDraftDetail">;
export type PersonaSourceDocumentRequest = ApiSchema<"PersonaSourceDocumentRequest">;
export type PersonaSourceDocumentResponse = ApiSchema<"PersonaSourceDocumentResponse">;
export type PersonaGenerationEvidence = ApiSchema<"PersonaGenerationEvidence">;
export type PersonaDraftGenerateRequest = ApiSchema<"PersonaDraftGenerateRequest">;
export type PersonaDraftGenerateResponse = ApiSchema<"PersonaDraftGenerateResponse">;
/** POST /sessions — sessions.py SessionStartResponse */
export type SessionStartResponse = ApiSchema<"SessionStartResponse">;
/** POST /sessions/{id}/turn — sessions.py TurnResponse */
export type TurnResponse = ApiSchema<"TurnResponse">;
export type LiveCoachRequest = ApiSchema<"LiveCoachRequest">;
export type LiveCoachEvent = ApiSchema<"LiveCoachEvent">;
export type LiveCoachHistoryResponse = ApiSchema<"LiveCoachHistoryResponse">;
export type LiveCoachSuggestion = ApiSchema<"LiveCoachSuggestion">;
export type LiveCoachSource = ApiSchema<"LiveCoachSource">;
/** POST /sessions/{id}/end — sessions.py SessionEndResponse */
export type SessionEndResponse = ApiSchema<"SessionEndResponse">;
export type CrisisResource = ApiSchema<"CrisisResourceResponse">;
export type LearnerSessionSummary = ApiSchema<"LearnerSessionSummary">;
export type SessionArchiveResponse = ApiSchema<"SessionArchiveResponse">;
export type LearnerSessionsResponse = ApiSchema<"LearnerSessionsResponse">;
export type LearnerDashboardResponse = ApiSchema<"LearnerDashboardResponse">;
export type LearnerDashboardPersonaProgress = ApiSchema<"LearnerDashboardPersonaProgress">;
export type LearnerDashboardAchievement = ApiSchema<"LearnerDashboardAchievement">;
export type LearnerDashboardFeedbackItem = ApiSchema<"LearnerDashboardFeedbackItem">;
export type SessionDetailTurn = ApiSchema<"SessionDetailTurn">;
@ -213,6 +229,8 @@ export type ReviewWorksheetSection = ApiSchema<"ReviewWorksheetSection-Output">;
export type ReviewCaseWorksheet = ApiSchema<"ReviewCaseWorksheet">;
export type ReviewCaseWorksheetSaveRequest = ApiSchema<"ReviewCaseWorksheetSaveRequest">;
export type SessionReviewResponse = ApiSchema<"SessionReviewResponse">;
export type SessionShareResponse = ApiSchema<"SessionShareResponse">;
export type SessionShareDeleteResponse = ApiSchema<"SessionShareDeleteResponse">;
/* =====================================================================
SSE POST /sessions/{id}/stream
@ -381,14 +399,23 @@ export const personaReviewApi = {
}),
createDraft: (payload: PersonaDraftPayload) =>
api.post<PersonaReviewSummary>("/personas/drafts", payload),
createSource: (payload: PersonaSourceDocumentRequest) =>
api.post<PersonaSourceDocumentResponse>("/personas/sources", payload),
generateDraft: (payload: PersonaDraftGenerateRequest) =>
api.post<PersonaDraftGenerateResponse>("/personas/drafts/generate", payload),
getDraft: (personaId: string) =>
api.get<PersonaDraftDetail>(`/personas/drafts/${encodeURIComponent(personaId)}`),
updateDraft: (personaId: string, payload: PersonaDraftPayload) =>
api.put<PersonaReviewSummary>(`/personas/drafts/${encodeURIComponent(personaId)}`, payload),
reviseApproved: (personaId: string, payload: PersonaRevisionRequest = { submit_for_review: false }) =>
api.post<PersonaDraftDetail>(`/personas/${encodeURIComponent(personaId)}/revisions`, payload),
archive: (personaId: string) =>
api.del<PersonaReviewSummary>(`/personas/${encodeURIComponent(personaId)}`),
};
export const sessionApi = {
list: () => api.get<LearnerSessionsResponse>("/sessions"),
dashboard: () => api.get<LearnerDashboardResponse>("/sessions/dashboard"),
/**
* (STT/TTS provider ). degraded 503
* available ( ).
@ -408,8 +435,19 @@ export const sessionApi = {
api.post<SessionStartResponse>("/sessions", { persona_code, theory_mode }),
turn: (sessionId: string, text: string) =>
api.post<TurnResponse>(`/sessions/${encodeURIComponent(sessionId)}/turn`, { text }),
liveCoach: (sessionId: string, payload: LiveCoachRequest) =>
api.post<LiveCoachSuggestion>(
`/sessions/${encodeURIComponent(sessionId)}/live-coach`,
payload,
),
liveCoachHistory: (sessionId: string) =>
api.get<LiveCoachHistoryResponse>(`/sessions/${encodeURIComponent(sessionId)}/live-coach`),
end: (sessionId: string) =>
api.post<SessionEndResponse>(`/sessions/${encodeURIComponent(sessionId)}/end`),
archive: (sessionId: string) =>
api.post<SessionArchiveResponse>(`/sessions/${encodeURIComponent(sessionId)}/archive`, {}),
restore: (sessionId: string) =>
api.post<SessionArchiveResponse>(`/sessions/${encodeURIComponent(sessionId)}/restore`, {}),
review: (sessionId: string) =>
api.get<SessionReviewResponse>(`/sessions/${encodeURIComponent(sessionId)}/review`),
saveWorksheet: (sessionId: string, payload: ReviewCaseWorksheetSaveRequest) =>
@ -417,6 +455,10 @@ export const sessionApi = {
`/sessions/${encodeURIComponent(sessionId)}/review/worksheet`,
payload,
),
createShare: (sessionId: string) =>
api.post<SessionShareResponse>(`/sessions/${encodeURIComponent(sessionId)}/share`, {}),
revokeShare: (sessionId: string) =>
api.del<SessionShareDeleteResponse>(`/sessions/${encodeURIComponent(sessionId)}/share`),
stream: openSessionStream,
};
@ -426,10 +468,44 @@ export type AdminHealthResponse = ApiSchema<"AdminHealthResponse">;
export type AdminUsageBreakdown = ApiSchema<"AdminUsageBreakdown">;
export type AdminUsageBudget = ApiSchema<"AdminUsageBudget">;
export type AdminUsageResponse = ApiSchema<"AdminUsageResponse">;
export type AdminHealthEvent = ApiSchema<"AdminHealthEvent">;
export type AdminUptimeServiceSummary = ApiSchema<"AdminUptimeServiceSummary">;
export type AdminUptimeResponse = ApiSchema<"AdminUptimeResponse">;
export type AdminSupportTicket = ApiSchema<"AdminSupportTicketResponse">;
export type AdminTicketsResponse = ApiSchema<"AdminTicketsResponse">;
export type AdminTicketPatchRequest = ApiSchema<"AdminTicketPatch">;
export type AdminTicketFilters = {
status?: AdminSupportTicket["status"] | "";
category?: AdminSupportTicket["category"] | "";
priority?: AdminSupportTicket["priority"] | "";
assignedGroup?: string;
sourcePath?: string;
staleOnly?: boolean;
search?: string;
windowDays?: number;
};
export const adminApi = {
health: () => api.get<AdminHealthResponse>("/admin/health"),
usage: (windowDays = 7) => api.get<AdminUsageResponse>(`/admin/usage?window_days=${windowDays}`),
uptime: (windowHours = 24) =>
api.get<AdminUptimeResponse>(`/admin/uptime?window_hours=${windowHours}`),
tickets: (filters: AdminTicketFilters = {}) => {
const params = new URLSearchParams({ window_days: String(filters.windowDays ?? 30) });
if (filters.status) params.set("status", filters.status);
if (filters.category) params.set("category", filters.category);
if (filters.priority) params.set("priority", filters.priority);
if (filters.assignedGroup?.trim()) params.set("assigned_group", filters.assignedGroup.trim());
if (filters.sourcePath?.trim()) params.set("source_path", filters.sourcePath.trim());
if (filters.staleOnly) params.set("stale_only", "true");
if (filters.search?.trim()) params.set("search", filters.search.trim());
return api.get<AdminTicketsResponse>(`/admin/tickets?${params.toString()}`);
},
updateTicket: (ticketId: string, body: AdminTicketPatchRequest) =>
apiFetch<AdminSupportTicket>(`/admin/tickets/${encodeURIComponent(ticketId)}`, {
method: "PATCH",
body,
}),
};
export type AdminManagedUser = ApiSchema<"AdminUserResponse">;
@ -462,24 +538,52 @@ export type TeacherGrowthPoint = ApiSchema<"TeacherGrowthPoint">;
export type TeacherLearnerGrowth = ApiSchema<"TeacherLearnerGrowth">;
export type TeacherDashboardResponse = ApiSchema<"TeacherDashboardResponse">;
export type TeacherSessionReviewStatusRequest = ApiSchema<"TeacherSessionReviewStatusRequest">;
export type TeacherSessionReviewStatusResponse = ApiSchema<"TeacherSessionReviewStatusResponse">;
export const teacherApi = {
dashboard: () => api.get<TeacherDashboardResponse>("/teacher/dashboard"),
updateSessionReviewStatus: (
sessionId: string,
body: TeacherSessionReviewStatusRequest,
) =>
apiFetch<TeacherSessionReviewStatusResponse>(
`/teacher/sessions/${encodeURIComponent(sessionId)}/review-status`,
{ method: "PUT", body },
),
};
export type UserProfileResponse = ApiSchema<"UserProfileResponse">;
export type LegalDocumentsResponse = ApiSchema<"LegalDocumentsResponse">;
export type NotificationPreferences = ApiSchema<"NotificationPreferences">;
export type UserPreferencesResponse = ApiSchema<"UserPreferencesResponse">;
export type UserPreferencesPatchRequest = ApiSchema<"UserPreferencesPatch">;
export type UserProfilePatchRequest = ApiSchema<"UserProfilePatch">;
export type OnboardingRequest = ApiSchema<"OnboardingRequest">;
export type AvatarUploadResponse = ApiSchema<"AvatarUploadResponse">;
export type VoicePresetResponse = ApiSchema<"VoicePresetResponse">;
export type RoleString = "learner" | "teacher" | "admin" | string;
export const userApi = {
legalDocs: () => api.get<LegalDocumentsResponse>("/users/legal-docs"),
me: () => api.get<UserProfileResponse>("/users/me"),
updateMe: (body: UserProfilePatchRequest) =>
apiFetch<UserProfileResponse>("/users/me", { method: "PATCH", body }),
uploadAvatar: async (file: File) => {
const form = new FormData();
form.append("file", file);
const res = await fetch(joinUrl("/users/me/avatar"), {
method: "POST",
credentials: "include",
headers: { Accept: "application/json" },
body: form,
});
if (!res.ok) throw await parseError(res);
return (await res.json()) as AvatarUploadResponse;
},
completeOnboarding: (body: OnboardingRequest) =>
apiFetch<UserProfileResponse>("/users/me/onboarding", { method: "POST", body }),
preferences: () => api.get<UserPreferencesResponse>("/users/me/preferences"),
updatePreferences: (body: UserPreferencesPatchRequest) =>
apiFetch<UserPreferencesResponse>("/users/me/preferences", { method: "PATCH", body }),

View file

@ -7,9 +7,10 @@ import {
useState,
type ReactNode,
} from "react";
import { api, authApi, type MeResponse } from "./api";
import { api, apiUrl, authApi, type MeResponse } from "./api";
export type Role = "learner" | "teacher" | "admin";
export type AccountStatus = "pending" | "approved" | "suspended";
export type DesignRole = "learner" | "instructor" | "admin";
export interface AuthUser {
@ -17,8 +18,16 @@ export interface AuthUser {
email: string;
name: string;
role: Role;
adminAccess: boolean;
superAdmin: boolean;
accountStatus: AccountStatus;
approvalRequired: boolean;
cohortIds: string[];
consentAt: number | null;
onboardingCompletedAt: number | null;
nickname: string;
selfIntroduction: string;
avatarUrl: string;
}
export interface AuthContextValue {
@ -27,6 +36,7 @@ export interface AuthContextValue {
loading: boolean;
login: (role: Role, opts?: { email?: string; displayName?: string }) => Promise<AuthUser>;
logout: () => Promise<void>;
refresh: () => Promise<AuthUser | null>;
acceptConsent: () => Promise<void>;
withdrawConsent: () => Promise<void>;
}
@ -57,6 +67,12 @@ export function roleHomePath(role: Role): string {
}
}
export function canAccessRole(user: AuthUser, role: Role): boolean {
if (user.role === role) return true;
if (user.superAdmin) return true;
return role === "admin" && user.adminAccess;
}
const DEV_EMAIL_BY_ROLE: Record<Role, string> = {
learner: "learner@hs.ac.kr",
teacher: "teacher@hs.ac.kr",
@ -72,13 +88,24 @@ const DEV_NAME_BY_ROLE: Record<Role, string> = {
const AuthContext = createContext<AuthContextValue | null>(null);
function userFromMe(me: MeResponse): AuthUser {
const onboarding = me as MeResponse & { onboarding_completed_at?: number | null };
const nickname = (me.nickname ?? "").trim();
const avatarUrl = (me.avatar_url ?? "").trim();
return {
userId: me.user_id,
email: me.email,
name: me.display_name || me.email || me.user_id,
name: nickname || me.display_name || me.email || me.user_id,
role: (me.role as Role) ?? "learner",
adminAccess: Boolean(me.admin_access),
superAdmin: Boolean(me.super_admin),
accountStatus: (me.account_status as AccountStatus | undefined) ?? "approved",
approvalRequired: me.approval_required ?? false,
cohortIds: me.cohort_ids ?? [],
consentAt: me.consent_at ?? null,
onboardingCompletedAt: onboarding.onboarding_completed_at ?? null,
nickname,
selfIntroduction: (me.self_introduction ?? "").trim(),
avatarUrl: avatarUrl ? apiUrl(avatarUrl) : "",
};
}
@ -120,6 +147,18 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return next;
}, []);
const refresh = useCallback<AuthContextValue["refresh"]>(async () => {
try {
const me = await api.get<MeResponse>("/auth/me");
const next = userFromMe(me);
setUser(next);
return next;
} catch {
setUser(null);
return null;
}
}, []);
const logout = useCallback<AuthContextValue["logout"]>(async () => {
try {
await api.post("/auth/logout");
@ -141,8 +180,17 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, []);
const value = useMemo<AuthContextValue>(
() => ({ user, role: user?.role ?? null, loading, login, logout, acceptConsent, withdrawConsent }),
[user, loading, login, logout, acceptConsent, withdrawConsent],
() => ({
user,
role: user?.role ?? null,
loading,
login,
logout,
refresh,
acceptConsent,
withdrawConsent,
}),
[user, loading, login, logout, refresh, acceptConsent, withdrawConsent],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;

View file

@ -0,0 +1,48 @@
import type { AvatarPersona } from "../components/avatar/ClientAvatar";
import type { PersonaSummary } from "./api";
export const DIFFICULTY_LABEL: Record<string, string> = {
easy: "기초",
moderate: "중간",
hard: "고난도",
};
export function isUsablePersona(persona: PersonaSummary): boolean {
return !persona.degraded && persona.source === "database";
}
export function unavailablePersonaMessage(persona: PersonaSummary): string {
if (persona.degraded) {
return "카탈로그 원본을 확인하지 못해 현재 연습에 사용할 수 없습니다.";
}
return "데이터베이스에서 확인된 내담자가 아니어서 현재 연습에 사용할 수 없습니다.";
}
export function demographicText(value: unknown): string | null {
if (typeof value === "string") return value.trim() || null;
if (typeof value === "number") return String(value);
return null;
}
export function shortPersonaName(displayName: string): string {
return displayName.split("·")[0]?.replace(/\(.*?\)/g, "").trim() || displayName.trim();
}
export function personaAgeBand(summary: PersonaSummary): AvatarPersona["ageBand"] {
const ageBand = demographicText(summary.demographics.age_band) ?? "";
const grade = demographicText(summary.demographics.grade) ?? "";
if (ageBand.includes("16") || ageBand.includes("18") || grade.includes("고")) return "teen";
if (ageBand.includes("25") || ageBand.includes("30")) return "youngAdult";
if (ageBand.includes("60") || ageBand.includes("70")) return "senior";
return "adult";
}
export function personaMeta(summary: PersonaSummary): string {
const fields = [
demographicText(summary.demographics.age_band),
demographicText(summary.demographics.grade),
demographicText(summary.demographics.job),
demographicText(summary.demographics.status),
].filter(Boolean);
return [...fields, "교육용 가상 내담자"].join(" · ");
}

View file

@ -7,6 +7,7 @@ import {
type AdminHealthResponse,
type AdminHealthStatus,
type AdminManagedUser,
type AdminTicketFilters,
type AdminSupportTicket,
type AdminTicketsResponse,
type AdminUsageResponse,
@ -27,9 +28,36 @@ type NewUserDraft = Required<Pick<AdminUserCreateRequest, "email" | "display_nam
Pick<UserDraft, "admin_access" | "account_status" | "affiliation" | "cohort_ids">;
type UserTab = "approval" | "manage" | "register" | "activity";
type AccessTab = "roles" | "groups" | "matrix";
type TicketStatusFilter = AdminSupportTicket["status"] | "all";
type TicketCategoryFilter = AdminSupportTicket["category"] | "all";
type TicketPriorityFilter = AdminSupportTicket["priority"] | "all";
const MAX_RENDERED_USERS = 40;
const MAX_RESOLVED_TICKET_HISTORY = 6;
const TICKET_STATUS_OPTIONS: Array<{ value: TicketStatusFilter; label: string }> = [
{ value: "all", label: "전체 상태" },
{ value: "open", label: "미해결" },
{ value: "triaged", label: "분류됨" },
{ value: "in_progress", label: "처리 중" },
{ value: "resolved", label: "해결" },
{ value: "closed", label: "종결" },
];
const TICKET_CATEGORY_OPTIONS: Array<{ value: TicketCategoryFilter; label: string }> = [
{ value: "all", label: "전체 카테고리" },
{ value: "account_access", label: "계정/권한" },
{ value: "session_review", label: "세션/리뷰" },
{ value: "voice_browser", label: "음성/브라우저" },
{ value: "content_scenario", label: "콘텐츠/시나리오" },
{ value: "safety", label: "안전" },
{ value: "other", label: "기타" },
];
const TICKET_PRIORITY_OPTIONS: Array<{ value: TicketPriorityFilter; label: string }> = [
{ value: "all", label: "전체 우선순위" },
{ value: "urgent", label: "긴급" },
{ value: "high", label: "높음" },
{ value: "normal", label: "보통" },
{ value: "low", label: "낮음" },
];
const EMPTY_NEW_USER: NewUserDraft = {
email: "",
@ -310,6 +338,12 @@ export default function Admin({ section = "overview" }: AdminProps) {
const [ticketsLoading, setTicketsLoading] = useState(true);
const [ticketsError, setTicketsError] = useState<string | null>(null);
const [updatingTicketId, setUpdatingTicketId] = useState<string | null>(null);
const [ticketStatusFilter, setTicketStatusFilter] = useState<TicketStatusFilter>("all");
const [ticketCategoryFilter, setTicketCategoryFilter] = useState<TicketCategoryFilter>("all");
const [ticketPriorityFilter, setTicketPriorityFilter] = useState<TicketPriorityFilter>("all");
const [ticketSearch, setTicketSearch] = useState("");
const [ticketAssignedGroup, setTicketAssignedGroup] = useState("");
const [ticketStaleOnly, setTicketStaleOnly] = useState(false);
const [userTab, setUserTab] = useState<UserTab>("approval");
const [accessTab, setAccessTab] = useState<AccessTab>("roles");
const canGrantAdminAccess = currentUser?.superAdmin === true;
@ -384,13 +418,29 @@ export default function Admin({ section = "overview" }: AdminProps) {
setTicketsLoading(true);
setTicketsError(null);
try {
setTickets(await adminApi.tickets(undefined, 30));
const filters: AdminTicketFilters = {
status: ticketStatusFilter === "all" ? "" : ticketStatusFilter,
category: ticketCategoryFilter === "all" ? "" : ticketCategoryFilter,
priority: ticketPriorityFilter === "all" ? "" : ticketPriorityFilter,
assignedGroup: ticketAssignedGroup,
staleOnly: ticketStaleOnly,
search: ticketSearch,
windowDays: 30,
};
setTickets(await adminApi.tickets(filters));
} catch (err) {
setTicketsError(err instanceof Error ? err.message : "운영 티켓을 불러오지 못했습니다.");
} finally {
setTicketsLoading(false);
}
}, []);
}, [
ticketAssignedGroup,
ticketCategoryFilter,
ticketPriorityFilter,
ticketSearch,
ticketStaleOnly,
ticketStatusFilter,
]);
useEffect(() => {
void loadHealth();
@ -636,6 +686,28 @@ export default function Admin({ section = "overview" }: AdminProps) {
.slice(0, MAX_RESOLVED_TICKET_HISTORY),
[tickets],
);
const categoryQueues = useMemo(
() =>
Object.entries(tickets?.summary.by_category ?? {})
.filter(([, count]) => count > 0)
.sort((a, b) => b[1] - a[1]),
[tickets],
);
const hasTicketFilters =
ticketStatusFilter !== "all" ||
ticketCategoryFilter !== "all" ||
ticketPriorityFilter !== "all" ||
ticketStaleOnly ||
ticketAssignedGroup.trim().length > 0 ||
ticketSearch.trim().length > 0;
const clearTicketFilters = () => {
setTicketStatusFilter("all");
setTicketCategoryFilter("all");
setTicketPriorityFilter("all");
setTicketStaleOnly(false);
setTicketAssignedGroup("");
setTicketSearch("");
};
const filteredUsers = useMemo(() => {
const query = userSearch.trim().toLowerCase();
if (!query) return users;
@ -1473,6 +1545,79 @@ export default function Admin({ section = "overview" }: AdminProps) {
<div className="ad-users-note">
DB . .
</div>
<section className="ad-ticket-filter" aria-label="운영 티켓 필터">
<input
value={ticketSearch}
onChange={(event) => setTicketSearch(event.target.value)}
placeholder="제목, 본문, 신고자, 경로 검색"
aria-label="티켓 검색"
/>
<select
value={ticketStatusFilter}
onChange={(event) => setTicketStatusFilter(event.target.value as TicketStatusFilter)}
aria-label="상태 필터"
>
{TICKET_STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<select
value={ticketCategoryFilter}
onChange={(event) => setTicketCategoryFilter(event.target.value as TicketCategoryFilter)}
aria-label="카테고리 필터"
>
{TICKET_CATEGORY_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<select
value={ticketPriorityFilter}
onChange={(event) => setTicketPriorityFilter(event.target.value as TicketPriorityFilter)}
aria-label="우선순위 필터"
>
{TICKET_PRIORITY_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<input
value={ticketAssignedGroup}
onChange={(event) => setTicketAssignedGroup(event.target.value)}
placeholder="담당 그룹"
aria-label="담당 그룹 필터"
/>
<label className="ad-ticket-filter__check">
<input
type="checkbox"
checked={ticketStaleOnly}
onChange={(event) => setTicketStaleOnly(event.target.checked)}
/>
<span></span>
</label>
<Button variant="secondary" onClick={clearTicketFilters} disabled={!hasTicketFilters}>
</Button>
</section>
{categoryQueues.length > 0 ? (
<div className="ad-ticket-queues" aria-label="카테고리 큐">
{categoryQueues.map(([category, count]) => (
<button
key={category}
type="button"
className={ticketCategoryFilter === category ? "is-active" : ""}
onClick={() => setTicketCategoryFilter(category as TicketCategoryFilter)}
>
<span>{ticketCategoryLabel(category as AdminSupportTicket["category"])}</span>
<b>{countLabel(count)}</b>
</button>
))}
</div>
) : null}
<section className="ad-section">
{ticketsError ? <InlineError message={ticketsError} /> : null}
@ -1525,6 +1670,8 @@ export default function Admin({ section = "overview" }: AdminProps) {
<p>{ticket.body}</p>
<small>
{ticket.source_path || "출처 없음"} · {dateTimeLabel(ticket.created_at)}
{ticket.assigned_group ? ` · 담당 ${ticket.assigned_group}` : ""}
{ticket.event_count > 0 ? ` · 처리 이력 ${countLabel(ticket.event_count)}` : ""}
</small>
</div>
<Badge tone={ticketTone(ticket)}>{ticketPriorityLabel(ticket.priority)}</Badge>
@ -1572,6 +1719,8 @@ export default function Admin({ section = "overview" }: AdminProps) {
<p>{ticket.body}</p>
<small>
{ticket.source_path || "출처 없음"} · {dateTimeLabel(ticket.created_at)}
{ticket.assigned_group ? ` · 담당 ${ticket.assigned_group}` : ""}
{ticket.event_count > 0 ? ` · 처리 이력 ${countLabel(ticket.event_count)}` : ""}
</small>
</div>
<Badge tone="neutral">{ticketPriorityLabel(ticket.priority)}</Badge>

View file

@ -43,7 +43,7 @@ export default function AvatarExpressionLab() {
const renderedAvatarCount = models.length * expressionCount;
return (
<AppShell contextLabel="Live2D QA">
<AppShell contextLabel="Live2D QA" navRole="learner">
<div
className="axl"
data-avatar-expression-lab="true"

View file

@ -16,7 +16,7 @@ const SEOYEON: AvatarPersona = {
outfitColor: "#7C8A92",
eyeColor: "#7A5A3C",
accentColor: "#6B5E7D",
rasterArtSet: "seoyeon-live2d-psb",
rasterArtSet: "seoyeon-live2d-psd-v2",
realism: 0.4,
expressionBias: "sad",
};
@ -46,14 +46,16 @@ export default function AvatarPreview() {
? "seoyeon"
: rig === "v3"
? "seoyeon-live2d-v3"
: SEOYEON.rasterArtSet,
: rig === "psb"
? "seoyeon-live2d-psb"
: SEOYEON.rasterArtSet,
};
return (
<div
className="ap"
data-avatar-preview="true"
data-avatar-rig={rig === "safe" ? "safe" : rig === "v3" ? "v3" : "psb"}
data-avatar-rig={rig === "safe" ? "safe" : rig === "v3" ? "v3" : rig === "psb" ? "psb" : "psd-v2"}
>
<header className="ap__head">
<h1> (raster / Live2D식)</h1>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,595 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Button } from "../components/ui";
import { roleHomePath, useAuth } from "../lib/auth";
import { apiUrl, userApi, type LegalDocumentsResponse, type UserProfileResponse } from "../lib/api";
interface OnboardingForm {
legal_name: string;
affiliation: string;
department: string;
grade_level: string;
phone: string;
contact_address: string;
nickname: string;
self_introduction: string;
avatar_url: string;
terms_accepted: boolean;
privacy_accepted: boolean;
}
const EMPTY_FORM: OnboardingForm = {
legal_name: "",
affiliation: "한신대학교",
department: "",
grade_level: "",
phone: "",
contact_address: "",
nickname: "",
self_introduction: "",
avatar_url: "",
terms_accepted: false,
privacy_accepted: false,
};
function profileToForm(profile: UserProfileResponse | null): OnboardingForm {
return {
...EMPTY_FORM,
legal_name: profile?.legal_name || profile?.display_name || "",
affiliation: profile?.affiliation || EMPTY_FORM.affiliation,
department: profile?.department || "",
grade_level: profile?.grade_level || "",
phone: profile?.phone || "",
contact_address: profile?.contact_address || "",
nickname: profile?.nickname || profile?.display_name || "",
self_introduction: profile?.self_introduction || "",
avatar_url: profile?.avatar_url || "",
};
}
export default function Onboarding() {
const navigate = useNavigate();
const { user, refresh } = useAuth();
const [docs, setDocs] = useState<LegalDocumentsResponse | null>(null);
const [form, setForm] = useState<OnboardingForm>(EMPTY_FORM);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [uploadingAvatar, setUploadingAvatar] = useState(false);
const [error, setError] = useState("");
const [avatarError, setAvatarError] = useState("");
useEffect(() => {
let alive = true;
(async () => {
setLoading(true);
setError("");
try {
const [profileResult, docsResult] = await Promise.all([userApi.me(), userApi.legalDocs()]);
if (!alive) return;
setDocs(docsResult);
setForm(profileToForm(profileResult));
} catch (err) {
console.warn("[onboarding] failed to load", err);
if (alive) setError("온보딩 정보를 불러오지 못했습니다.");
} finally {
if (alive) setLoading(false);
}
})();
return () => {
alive = false;
};
}, []);
const canSubmit = useMemo(
() =>
form.legal_name.trim() &&
form.affiliation.trim() &&
form.department.trim() &&
form.grade_level.trim() &&
form.phone.trim() &&
form.contact_address.trim() &&
form.nickname.trim() &&
form.self_introduction.trim() &&
form.terms_accepted &&
form.privacy_accepted,
[form],
);
const avatarSrc = form.avatar_url ? apiUrl(form.avatar_url) : "";
const update = <K extends keyof OnboardingForm>(key: K, value: OnboardingForm[K]) => {
setForm((current) => ({ ...current, [key]: value }));
};
const uploadAvatar = async (file: File | null) => {
if (!file || uploadingAvatar) return;
setUploadingAvatar(true);
setAvatarError("");
try {
const uploaded = await userApi.uploadAvatar(file);
update("avatar_url", uploaded.avatar_url);
} catch (err) {
console.warn("[onboarding] avatar upload failed", err);
setAvatarError("아바타 업로드에 실패했습니다. PNG, JPG, WebP 파일을 3MB 이하로 올려 주세요.");
} finally {
setUploadingAvatar(false);
}
};
const submit = async () => {
if (!canSubmit || submitting) return;
setSubmitting(true);
setError("");
try {
await userApi.completeOnboarding({
legal_name: form.legal_name.trim(),
affiliation: form.affiliation.trim(),
department: form.department.trim(),
grade_level: form.grade_level.trim(),
phone: form.phone.trim(),
contact_address: form.contact_address.trim(),
nickname: form.nickname.trim(),
self_introduction: form.self_introduction.trim(),
avatar_url: form.avatar_url.trim(),
terms_accepted: form.terms_accepted,
privacy_accepted: form.privacy_accepted,
});
const nextUser = await refresh();
navigate(roleHomePath(nextUser?.role ?? user?.role ?? "learner"), { replace: true });
} catch (err) {
console.warn("[onboarding] failed to submit", err);
setError("온보딩 저장에 실패했습니다. 입력값과 서버 상태를 확인해 주세요.");
} finally {
setSubmitting(false);
}
};
return (
<>
<style>{ONBOARDING_CSS}</style>
<main className="ob-page">
<section className="ob-shell" aria-label="가입 정보 입력">
<header className="ob-head">
<p>Vignette </p>
<h1> .</h1>
</header>
<form
className="ob-form"
onSubmit={(event) => {
event.preventDefault();
void submit();
}}
>
<section className="ob-section">
<div className="ob-section__head">
<h2></h2>
</div>
<div className="ob-avatar">
<div className="ob-avatar__preview" aria-hidden="true">
{avatarSrc ? <img src={avatarSrc} alt="" /> : <span>{form.nickname.trim().slice(0, 1) || "V"}</span>}
</div>
<div className="ob-avatar__body">
<span> </span>
<p> . .</p>
<label className="ob-avatar__button">
<input
type="file"
accept="image/png,image/jpeg,image/webp"
aria-label="아바타 이미지"
disabled={loading || uploadingAvatar}
onChange={(event) => {
void uploadAvatar(event.currentTarget.files?.[0] ?? null);
event.currentTarget.value = "";
}}
/>
{uploadingAvatar ? "업로드 중" : avatarSrc ? "다른 이미지 선택" : "이미지 선택"}
</label>
{avatarError ? <p className="ob-error" role="alert">{avatarError}</p> : null}
</div>
</div>
<div className="ob-fields">
<label>
<span></span>
<input
value={form.nickname}
onChange={(event) => update("nickname", event.target.value)}
autoComplete="nickname"
disabled={loading}
required
/>
</label>
<label className="ob-field--wide">
<span></span>
<textarea
value={form.self_introduction}
onChange={(event) => update("self_introduction", event.target.value)}
disabled={loading}
maxLength={600}
required
placeholder="상담 훈련에서 집중하고 싶은 점이나 본인을 소개할 문장을 적어 주세요."
/>
</label>
</div>
</section>
<section className="ob-section">
<div className="ob-section__head">
<h2> </h2>
</div>
<div className="ob-fields">
<label>
<span></span>
<input
value={form.legal_name}
onChange={(event) => update("legal_name", event.target.value)}
autoComplete="name"
disabled={loading}
required
/>
</label>
<label>
<span></span>
<input
value={form.affiliation}
onChange={(event) => update("affiliation", event.target.value)}
disabled={loading}
required
/>
</label>
<label>
<span>/</span>
<input
value={form.department}
onChange={(event) => update("department", event.target.value)}
disabled={loading}
required
/>
</label>
<label>
<span>/</span>
<input
value={form.grade_level}
onChange={(event) => update("grade_level", event.target.value)}
disabled={loading}
required
/>
</label>
<label>
<span></span>
<input
value={form.phone}
onChange={(event) => update("phone", event.target.value)}
autoComplete="tel"
disabled={loading}
required
/>
</label>
<label>
<span>/</span>
<input
value={form.contact_address}
onChange={(event) => update("contact_address", event.target.value)}
autoComplete="street-address"
disabled={loading}
required
/>
</label>
</div>
</section>
<section className="ob-section">
<div className="ob-section__head">
<h2></h2>
</div>
<div className="ob-checks">
<label>
<input
type="checkbox"
checked={form.terms_accepted}
onChange={(event) => update("terms_accepted", event.target.checked)}
disabled={loading}
/>
<span> .</span>
</label>
<label>
<input
type="checkbox"
checked={form.privacy_accepted}
onChange={(event) => update("privacy_accepted", event.target.checked)}
disabled={loading}
/>
<span> .</span>
</label>
</div>
<div className="ob-legal">
<details>
<summary>
<span>{docs?.terms.title ?? "서비스 이용약관"}</span>
<b>{docs?.terms.version ?? "확인 중"}</b>
</summary>
<div className="ob-doc-text">{docs?.terms.body ?? "문서를 불러오는 중입니다."}</div>
</details>
<details>
<summary>
<span>{docs?.privacy.title ?? "개인정보 처리방침"}</span>
<b>{docs?.privacy.version ?? "확인 중"}</b>
</summary>
<div className="ob-doc-text">{docs?.privacy.body ?? "문서를 불러오는 중입니다."}</div>
</details>
<p className="ob-note">{docs?.source_note ?? "운영 전 초안 문서입니다."}</p>
</div>
</section>
{error ? <p className="ob-error" role="alert">{error}</p> : null}
{loading ? <p className="ob-note"> .</p> : null}
<div className="ob-actions">
<Button type="submit" size="lg" disabled={!canSubmit || submitting || loading}>
{submitting ? "저장 중" : "가입 설정 완료"}
</Button>
</div>
</form>
</section>
</main>
</>
);
}
const ONBOARDING_CSS = `
.ob-page{
min-height:100dvh;
width:100%;
background:var(--bg-app);
color:var(--text-body);
padding:clamp(20px,5vw,56px);
}
.ob-shell{
width:100%;
max-width:900px;
margin:0 auto;
display:grid;
gap:var(--sp-6);
}
.ob-head{
display:grid;
gap:8px;
}
.ob-head p{
margin:0;
color:var(--accent-deep);
font-size:12px;
font-weight:800;
}
.ob-head h1{
margin:0;
color:var(--text-strong);
font-size:clamp(28px,4vw,44px);
line-height:1.18;
letter-spacing:0;
}
.ob-form{
min-width:0;
display:grid;
gap:var(--sp-6);
}
.ob-section{
min-width:0;
display:grid;
gap:var(--sp-4);
padding-bottom:var(--sp-5);
border-bottom:1px solid var(--border-subtle);
}
.ob-section__head h2{
margin:0;
color:var(--text-strong);
font-size:18px;
line-height:1.35;
letter-spacing:0;
}
.ob-avatar{
display:grid;
grid-template-columns:auto minmax(0,1fr);
gap:var(--sp-3);
align-items:center;
}
.ob-avatar__preview{
width:72px;
height:72px;
border-radius:50%;
display:grid;
place-items:center;
overflow:hidden;
background:var(--accent-tint);
color:var(--accent-deep);
font-size:28px;
font-weight:800;
border:1px solid var(--border-subtle);
}
.ob-avatar__preview img{
width:100%;
height:100%;
display:block;
object-fit:cover;
}
.ob-avatar__body{
min-width:0;
display:grid;
gap:7px;
}
.ob-avatar__body span{
color:var(--text-strong);
font-size:13px;
font-weight:780;
}
.ob-avatar__body p{
margin:0;
color:var(--text-muted);
font-size:12.5px;
line-height:1.45;
}
.ob-avatar__button{
position:relative;
width:max-content;
min-height:34px;
display:inline-flex;
align-items:center;
justify-content:center;
padding:0 12px;
border:1px solid var(--border-subtle);
border-radius:var(--radius-sm);
background:var(--bg-surface);
color:var(--text-strong);
font-size:12px;
font-weight:760;
cursor:pointer;
}
.ob-avatar__button input{
position:absolute;
inline-size:1px;
block-size:1px;
opacity:0;
pointer-events:none;
}
.ob-field--wide{
grid-column:1 / -1;
}
.ob-fields{
display:grid;
grid-template-columns:repeat(2,minmax(0,1fr));
gap:var(--sp-3);
}
.ob-form label{
min-width:0;
display:grid;
gap:7px;
}
.ob-form label span{
color:var(--text-muted);
font-size:12px;
font-weight:730;
}
.ob-form input[type="text"],
.ob-form input:not([type]){
min-width:0;
}
.ob-form input,
.ob-form textarea{
width:100%;
border:1px solid var(--border-subtle);
border-radius:var(--radius-sm);
background:var(--bg-surface-2);
color:var(--text-strong);
padding:0 12px;
font:500 var(--fs-sm)/1.2 var(--font-sans);
}
.ob-form input{
min-height:42px;
}
.ob-form textarea{
min-height:92px;
padding:11px 12px;
resize:vertical;
line-height:1.45;
}
.ob-form input:focus,
.ob-form textarea:focus{
outline:2px solid color-mix(in srgb,var(--accent) 24%,transparent);
border-color:var(--accent);
}
.ob-checks{
display:grid;
gap:10px;
padding:14px;
border:1px solid var(--border-subtle);
border-radius:var(--radius);
background:var(--bg-surface-2);
}
.ob-checks label{
grid-template-columns:auto minmax(0,1fr);
align-items:center;
gap:10px;
}
.ob-checks input{
width:18px;
height:18px;
min-height:18px;
padding:0;
accent-color:var(--accent);
}
.ob-legal{
min-width:0;
display:grid;
gap:8px;
}
.ob-legal details{
min-width:0;
border:1px solid var(--border-subtle);
border-radius:var(--radius-sm);
background:var(--bg-surface-2);
}
.ob-legal summary{
min-width:0;
display:flex;
align-items:center;
justify-content:space-between;
gap:10px;
min-height:42px;
padding:0 12px;
cursor:pointer;
}
.ob-legal summary span{
min-width:0;
color:var(--text-strong);
font-size:13px;
font-weight:760;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.ob-legal summary b{
flex:none;
color:var(--text-muted);
font-size:11px;
font-weight:700;
}
.ob-note,
.ob-error{
margin:0;
color:var(--text-muted);
font-size:12.5px;
line-height:1.5;
}
.ob-doc-text{
max-height:260px;
overflow:auto;
white-space:pre-wrap;
color:var(--text-body);
font-size:12.5px;
line-height:1.55;
padding:0 12px 12px;
}
.ob-error{
color:var(--crit-text);
}
.ob-actions{
display:flex;
justify-content:flex-start;
}
@media (max-width:620px){
.ob-page{
padding:14px;
}
.ob-fields{
grid-template-columns:1fr;
}
.ob-avatar{
grid-template-columns:1fr;
}
.ob-actions .vg-btn{
width:100%;
}
}
`;

View file

@ -0,0 +1,176 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Button, Icon } from "../components/ui";
import { roleHomePath, useAuth } from "../lib/auth";
export default function PendingApproval() {
const navigate = useNavigate();
const { user, logout, refresh } = useAuth();
const [checking, setChecking] = useState(false);
const checkAgain = async () => {
if (checking) return;
setChecking(true);
try {
const nextUser = await refresh();
if (nextUser?.accountStatus === "approved") {
navigate(
nextUser.onboardingCompletedAt == null ? "/onboarding" : roleHomePath(nextUser.role),
{ replace: true },
);
}
} finally {
setChecking(false);
}
};
const signOut = async () => {
await logout();
navigate("/login", { replace: true });
};
const statusText = user?.accountStatus === "suspended" ? "접속 보류" : "승인 대기";
return (
<>
<style>{PENDING_APPROVAL_CSS}</style>
<main className="pa-page" aria-label="계정 승인 대기">
<section className="pa-panel">
<div className="pa-mark" aria-hidden="true">
<Icon name="shield" size={30} strokeWidth={1.9} />
</div>
<p className="pa-kicker">{statusText}</p>
<h1> .</h1>
<p className="pa-copy">
{user?.email ? <b>{user.email}</b> : "현재 계정"} .
Vignette .
</p>
<div className="pa-status" role="status">
<span aria-hidden="true" />
<div>
<b> </b>
<small> .</small>
</div>
</div>
<div className="pa-actions">
<Button
type="button"
size="lg"
leading={<Icon name="settings" size={16} />}
onClick={() => void checkAgain()}
disabled={checking}
>
{checking ? "확인 중" : "승인 상태 새로고침"}
</Button>
<Button
type="button"
size="lg"
variant="secondary"
leading={<Icon name="logout" size={16} />}
onClick={() => void signOut()}
>
</Button>
</div>
</section>
</main>
</>
);
}
const PENDING_APPROVAL_CSS = `
.pa-page{
min-height:100dvh;
display:grid;
place-items:center;
padding:clamp(18px,5vw,56px);
background:var(--bg-app);
color:var(--text-body);
}
.pa-panel{
width:min(100%,620px);
display:grid;
gap:var(--sp-4);
padding:clamp(24px,5vw,44px);
border:1px solid var(--border-subtle);
border-radius:var(--radius);
background:color-mix(in srgb,var(--bg-surface) 92%,white 8%);
box-shadow:0 18px 50px rgba(28,43,40,.10);
}
.pa-mark{
width:62px;
height:62px;
display:grid;
place-items:center;
border-radius:var(--radius);
background:var(--accent-tint);
color:var(--accent-deep);
border:1px solid color-mix(in srgb,var(--accent) 20%,transparent);
}
.pa-kicker{
margin:0;
color:var(--accent-deep);
font-size:12px;
font-weight:820;
}
.pa-panel h1{
margin:0;
color:var(--text-strong);
font-size:clamp(30px,5vw,46px);
line-height:1.17;
letter-spacing:0;
}
.pa-copy{
margin:0;
color:var(--text-muted);
font-size:15px;
line-height:1.7;
}
.pa-copy b{
color:var(--text-strong);
font-weight:760;
}
.pa-status{
min-width:0;
display:grid;
grid-template-columns:auto minmax(0,1fr);
gap:12px;
align-items:center;
padding:14px;
border:1px solid var(--border-subtle);
border-radius:var(--radius);
background:var(--bg-surface-2);
}
.pa-status > span{
width:10px;
height:10px;
border-radius:50%;
background:#d89a2b;
box-shadow:0 0 0 5px rgba(216,154,43,.14);
}
.pa-status b{
display:block;
color:var(--text-strong);
font-size:14px;
}
.pa-status small{
display:block;
margin-top:3px;
color:var(--text-muted);
font-size:12.5px;
line-height:1.45;
}
.pa-actions{
display:flex;
flex-wrap:wrap;
gap:10px;
}
@media (max-width:560px){
.pa-panel{
border-radius:var(--radius);
}
.pa-actions .vg-btn{
width:100%;
}
}
`;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import { AppShell } from "../components/shell/AppShell";
import {
Badge,
@ -12,6 +12,7 @@ import {
} from "../components/ui";
import {
sessionApi,
teacherApi,
type ReviewCaseWorksheet,
type ReviewNonverbalEvent,
type ReviewNote,
@ -41,6 +42,120 @@ function renderSummary(summary: string) {
});
}
function renderInlineMarkdown(text: string): ReactNode[] {
const nodes: ReactNode[] = [];
let cursor = 0;
let key = 0;
const pushText = (value: string) => {
if (value) nodes.push(value);
};
while (cursor < text.length) {
const codeAt = text.indexOf("`", cursor);
const strongAt = text.indexOf("**", cursor);
const hasCode = codeAt >= 0;
const hasStrong = strongAt >= 0;
if (!hasCode && !hasStrong) {
pushText(text.slice(cursor));
break;
}
const useCode = hasCode && (!hasStrong || codeAt < strongAt);
const markerAt = useCode ? codeAt : strongAt;
if (markerAt > cursor) pushText(text.slice(cursor, markerAt));
if (useCode) {
const end = text.indexOf("`", markerAt + 1);
if (end < 0) {
pushText(text.slice(markerAt));
break;
}
const value = text.slice(markerAt + 1, end);
nodes.push(<code key={`code-${key++}`}>{value}</code>);
cursor = end + 1;
} else {
const end = text.indexOf("**", markerAt + 2);
if (end < 0) {
pushText(text.slice(markerAt));
break;
}
const value = text.slice(markerAt + 2, end);
nodes.push(<strong key={`strong-${key++}`}>{value}</strong>);
cursor = end + 2;
}
}
return nodes;
}
function ReviewMarkdown({ text }: { text: string }) {
const lines = text.replace(/\r\n/g, "\n").split("\n");
const blocks: ReactNode[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
const trimmed = line.trim();
if (!trimmed) {
i += 1;
continue;
}
if (trimmed.startsWith(">")) {
const quoteLines: string[] = [];
while (i < lines.length && lines[i].trim().startsWith(">")) {
quoteLines.push(lines[i].trim().replace(/^>\s?/, ""));
i += 1;
}
blocks.push(
<blockquote key={`quote-${blocks.length}`} className="sr-md__quote">
{quoteLines.map((quoteLine, index) => (
<span key={`${quoteLine}-${index}`}>{renderInlineMarkdown(quoteLine)}</span>
))}
</blockquote>,
);
continue;
}
if (/^[-*]\s+/.test(trimmed)) {
const items: string[] = [];
while (i < lines.length && /^[-*]\s+/.test(lines[i].trim())) {
items.push(lines[i].trim().replace(/^[-*]\s+/, ""));
i += 1;
}
blocks.push(
<ul key={`list-${blocks.length}`} className="sr-md__list">
{items.map((item, index) => (
<li key={`${item}-${index}`}>{renderInlineMarkdown(item)}</li>
))}
</ul>,
);
continue;
}
const paragraph: string[] = [];
while (
i < lines.length &&
lines[i].trim() &&
!lines[i].trim().startsWith(">") &&
!/^[-*]\s+/.test(lines[i].trim())
) {
paragraph.push(lines[i].trim());
i += 1;
}
blocks.push(
<p key={`p-${blocks.length}`} className="sr-md__p">
{renderInlineMarkdown(paragraph.join(" "))}
</p>,
);
}
return <div className="sr-md">{blocks}</div>;
}
function EmptyBlock({ title, desc }: { title: string; desc: string }) {
return (
<div className="vg-empty sr-empty">
@ -69,10 +184,18 @@ function NonverbalChip({ event }: { event: ReviewNonverbalEvent }) {
);
}
function noteAuthorLabel(author: string) {
const normalized = author.trim();
if (normalized === "ai") return "AI";
if (normalized === "transcript") return "기록";
return normalized || "교수자";
}
function SupervisorCallout({ note }: { note: ReviewNote }) {
const toneCls = note.tone === "good" ? "sr-note--ai" : "sr-note--warn";
const iconName = note.tone === "good" ? "check" : "info";
const tag = note.author === "ai" ? "AI" : note.author === "transcript" ? "기록" : "교수자";
const positiveTone = note.tone === "good" || note.tone === "ai";
const toneCls = positiveTone ? "sr-note--ai" : "sr-note--warn";
const iconName = positiveTone ? "check" : "info";
const tag = noteAuthorLabel(note.author);
return (
<div className={`sr-note ${toneCls}`}>
<div className="sr-note__head">
@ -80,15 +203,15 @@ function SupervisorCallout({ note }: { note: ReviewNote }) {
{note.title}
<span className="sr-note__tag">{tag}</span>
</div>
<p className="sr-note__txt">
{note.body}
<div className="sr-note__body">
<ReviewMarkdown text={note.body} />
{note.quote ? (
<>
{" "}
<span className="sr-quote">{note.quote}</span>
</>
<blockquote className="sr-note__quote">
<span> </span>
<p>{note.quote}</p>
</blockquote>
) : null}
</p>
</div>
</div>
);
}
@ -146,11 +269,13 @@ function CaseWorksheetCard({
worksheet,
onJump,
onSaved,
readOnly,
}: {
sessionId: string;
worksheet: ReviewCaseWorksheet | null | undefined;
onJump: (id: string) => void;
onSaved: (worksheet: ReviewCaseWorksheet) => void;
readOnly: boolean;
}) {
const sections = worksheet?.sections ?? [];
const limitations = worksheet?.limitations ?? [];
@ -161,6 +286,13 @@ function CaseWorksheetCard({
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const isSaved = worksheet?.status === "saved_by_learner";
const stateLabel = readOnly
? isSaved
? "학습자 저장본 읽기 전용"
: "축어록 자동 초안 읽기 전용"
: isSaved
? "저장된 학습자 제출본"
: "축어록 기반 자동 초안";
useEffect(() => {
setDraftSections(cloneWorksheetSections(sections));
@ -213,16 +345,20 @@ function CaseWorksheetCard({
<div className="sr-ws-toolbar">
<div>
<Kicker> </Kicker>
<span className="sr-ws-state">{isSaved ? "저장된 학습자 제출본" : "축어록 기반 자동 초안"}</span>
<span className="sr-ws-state">{stateLabel}</span>
</div>
<Button
variant="secondary"
size="sm"
disabled={!sections.length || !dirty || saving}
onClick={saveWorksheet}
>
{saving ? "저장 중" : dirty ? "저장" : "저장됨"}
</Button>
{readOnly ? (
<span className="sr-ws-readonly"> </span>
) : (
<Button
variant="secondary"
size="sm"
disabled={!sections.length || !dirty || saving}
onClick={saveWorksheet}
>
{saving ? "저장 중" : dirty ? "저장" : "저장됨"}
</Button>
)}
</div>
<div className="sr-worksheet">
{draftSections.length > 0 ? (
@ -241,10 +377,11 @@ function CaseWorksheetCard({
</span>
</div>
<textarea
className="sr-ws-input"
className={`sr-ws-input ${readOnly ? "is-readonly" : ""}`}
value={item.value ?? ""}
placeholder={item.emptyReason || "근거 대기"}
rows={3}
readOnly={readOnly}
onChange={(event) =>
updateValue(section.key, item.key, event.currentTarget.value)
}
@ -285,16 +422,27 @@ function CaseWorksheetCard({
export default function SessionReview() {
const { sessionId } = useParams<{ sessionId: string }>();
const navigate = useNavigate();
const location = useLocation();
const [data, setData] = useState<SessionReviewResponse | null>(null);
const [loadState, setLoadState] = useState<LoadState>("loading");
const [error, setError] = useState<string | null>(null);
const [learnerOnly, setLearnerOnly] = useState(false);
const [activeTurn, setActiveTurn] = useState<string | null>(null);
const [shareState, setShareState] = useState<"idle" | "creating" | "copied" | "error">("idle");
const [shareUrl, setShareUrl] = useState<string | null>(null);
const [shareError, setShareError] = useState<string | null>(null);
const [teacherNote, setTeacherNote] = useState("");
const [teacherReviewSaving, setTeacherReviewSaving] = useState<"viewed" | "closed" | null>(null);
const [teacherReviewError, setTeacherReviewError] = useState<string | null>(null);
const turnRefs = useRef<Record<string, HTMLDivElement | null>>({});
const handleWorksheetSaved = (worksheet: ReviewCaseWorksheet) => {
setData((current) => (current ? { ...current, caseWorksheet: worksheet } : current));
};
const isSupervisorView = location.pathname.startsWith("/teach/");
const reviewNavRole = isSupervisorView ? "teacher" : "learner";
const contextLabel = isSupervisorView ? "교수자 회기 리뷰" : "회기 리뷰";
useEffect(() => {
let alive = true;
@ -313,6 +461,8 @@ export default function SessionReview() {
const next = await sessionApi.review(sessionId);
if (!alive) return;
setData(next);
setTeacherNote(next.teacherReview?.note ?? "");
setTeacherReviewError(null);
setLoadState("ready");
} catch (err) {
if (!alive) return;
@ -351,9 +501,61 @@ export default function SessionReview() {
});
}
async function handleCreateShare() {
if (!sessionId) return;
setShareState("creating");
setShareError(null);
try {
const share = await sessionApi.createShare(sessionId);
setShareUrl(share.shareUrl);
try {
await navigator.clipboard?.writeText(share.shareUrl);
setShareState("copied");
} catch {
setShareState("copied");
}
} catch (err) {
setShareState("error");
setShareError(err instanceof Error ? err.message : "공유 URL을 만들지 못했습니다.");
}
}
async function saveTeacherReviewStatus(status: "viewed" | "closed") {
if (!sessionId || !data) return;
setTeacherReviewSaving(status);
setTeacherReviewError(null);
try {
const saved = await teacherApi.updateSessionReviewStatus(sessionId, {
status,
note: teacherNote,
});
setData((current) =>
current
? {
...current,
teacherReview: {
status: saved.status,
note: saved.note,
reviewerId: saved.reviewer_id ?? null,
reviewedAt: saved.reviewed_at ?? null,
updatedAt: saved.updated_at ?? null,
},
}
: current,
);
setTeacherNote(saved.note);
} catch (err) {
setTeacherReviewError(
err instanceof Error ? err.message : "교수자 검토 상태를 저장하지 못했습니다.",
);
} finally {
setTeacherReviewSaving(null);
}
}
if (loadState === "loading") {
return (
<AppShell contextLabel="회기 리뷰">
<AppShell contextLabel={contextLabel} navRole={reviewNavRole}>
<div className="sr-root">
<Card>
<Kicker> </Kicker>
@ -366,7 +568,7 @@ export default function SessionReview() {
if (loadState === "error" || !data) {
return (
<AppShell contextLabel="회기 리뷰">
<AppShell contextLabel={contextLabel} navRole={reviewNavRole}>
<div className="sr-root">
<Card>
<Kicker> </Kicker>
@ -393,12 +595,22 @@ export default function SessionReview() {
const canOpenAudio = Boolean(data.audioUrl);
const canExportPdf = Boolean(data.pdfExportUrl);
const hasTranscript = turns.length > 0;
const canCreateShare = !isSupervisorView && data.sessionSignal === "종료됨";
const teacherReview = data.teacherReview;
const teacherReviewStatus = teacherReview?.status ?? "pending";
const teacherReviewStatusLabel =
teacherReviewStatus === "closed"
? "검토 완료"
: teacherReviewStatus === "viewed"
? "검토 중"
: "검토 대기";
const canCloseTeacherReview = isSupervisorView && data.sessionSignal === "종료됨";
const reviewReadiness = hasTranscript
? `${turns.length}개 발화 기반`
: "축어록 저장 후 생성";
return (
<AppShell contextLabel="회기 리뷰">
<AppShell contextLabel={contextLabel} navRole={reviewNavRole}>
<div className={`sr-root ${hasTranscript ? "" : "sr-root--empty"}`}>
<Card className="sr-head">
<div className="sr-head__id">
@ -413,6 +625,9 @@ export default function SessionReview() {
<span>{data.durationLabel}</span>
<span className="sr-meta-sep" aria-hidden="true" />
<span>{data.client.persona}</span>
<Badge tone={isSupervisorView ? "neutral" : "accent"}>
{isSupervisorView ? "교수자 검토 화면" : "학습자 리뷰"}
</Badge>
<Badge tone="accent">{data.reachedPhase}</Badge>
</div>
</div>
@ -438,10 +653,10 @@ export default function SessionReview() {
</div>
</Card>
<div className="sr-cols">
<div className={`sr-cols ${isSupervisorView ? "sr-cols--supervisor" : ""}`}>
<div className="sr-left">
<Card className="sr-overview">
<Kicker> </Kicker>
<Kicker> </Kicker>
<p className="sr-summary">
{renderSummary(data.summary)}
</p>
@ -460,6 +675,16 @@ export default function SessionReview() {
</span>
</div>
<div className="sr-actions">
{isSupervisorView ? (
<Button
variant="secondary"
size="sm"
leading={<Icon name="users" size={14} />}
onClick={() => navigate("/teach")}
>
</Button>
) : null}
<Button
variant="primary"
size="sm"
@ -482,6 +707,28 @@ export default function SessionReview() {
>
PDF
</Button>
{!isSupervisorView ? (
<>
<Button
variant="secondary"
size="sm"
leading={<Icon name="share" size={14} />}
disabled={!canCreateShare || shareState === "creating"}
onClick={handleCreateShare}
>
{shareState === "creating"
? "공유 링크 생성 중"
: shareState === "copied"
? "공유 URL 복사됨"
: "공유 URL 복사"}
</Button>
{shareUrl || shareError ? (
<p className={`sr-share-note ${shareError ? "sr-share-note--error" : ""}`}>
{shareError ? shareError : shareUrl}
</p>
) : null}
</>
) : null}
</div>
</Card>
@ -623,6 +870,69 @@ export default function SessionReview() {
</div>
<div className="sr-right">
{isSupervisorView ? (
<Card className="sr-card sr-card--side sr-card--teacher-review">
<div className="sr-teacher-review__head">
<div>
<Kicker> </Kicker>
<b>{teacherReviewStatusLabel}</b>
</div>
<Badge tone={teacherReviewStatus === "closed" ? "accent" : "neutral"}>
{teacherReviewStatusLabel}
</Badge>
</div>
<label className="sr-teacher-review__note">
<span> </span>
<textarea
value={teacherNote}
rows={4}
placeholder="다음 지도에서 확인할 점을 남깁니다."
onChange={(event) => setTeacherNote(event.target.value)}
maxLength={2000}
/>
</label>
{teacherReview?.reviewedAt ? (
<p className="sr-teacher-review__meta">
{teacherReview.reviewedAt}
</p>
) : canCloseTeacherReview ? (
<p className="sr-teacher-review__meta">
.
</p>
) : (
<p className="sr-teacher-review__meta">
.
</p>
)}
{teacherReviewError ? (
<p className="sr-teacher-review__error" role="alert">
{teacherReviewError}
</p>
) : null}
<div className="sr-teacher-review__actions">
<Button
variant="secondary"
size="sm"
disabled={teacherReviewSaving !== null}
onClick={() => void saveTeacherReviewStatus("viewed")}
>
{teacherReviewSaving === "viewed" ? "저장 중" : "메모 저장"}
</Button>
<Button
size="sm"
disabled={
!canCloseTeacherReview ||
teacherReviewSaving !== null ||
teacherReviewStatus === "closed"
}
onClick={() => void saveTeacherReviewStatus("closed")}
>
{teacherReviewSaving === "closed" ? "완료 중" : "검토 완료"}
</Button>
</div>
</Card>
) : null}
<Card className="sr-card sr-card--side sr-card--rubric">
<Kicker> </Kicker>
<div className="sr-rubric" style={{ marginTop: "var(--sp-4)" }}>
@ -713,6 +1023,7 @@ export default function SessionReview() {
worksheet={data.caseWorksheet}
onJump={jumpToTurn}
onSaved={handleWorksheetSaved}
readOnly={isSupervisorView}
/>
<div className={`sr-feedback${data.clientFeedback ? " sr-feedback--filled" : ""}`}>

View file

@ -388,7 +388,7 @@ export default function Settings() {
const notificationPreferences = completeNotificationPreferences(preferences?.notifications);
return (
<AppShell contextLabel="설정" hideNav hideTopbar bleed>
<AppShell contextLabel="설정">
{error ? (
<div className="vg-set__callout vg-set__callout--warn" role="alert">
<span className="vg-set__callout-ico">

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,134 @@
.ob-root {
width: min(100%, 1040px);
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 16px;
}
.ob-head h1 {
margin: 6px 0 0;
color: var(--text-strong);
font-size: var(--fs-h2);
line-height: 1.28;
letter-spacing: 0;
}
.ob-head p {
margin: 8px 0 0;
color: var(--text-body);
font-size: var(--fs-sm);
line-height: 1.55;
}
.ob-alert {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
border-radius: var(--radius);
background: var(--crit-tint);
color: var(--crit-text);
font-size: var(--fs-sm);
}
.ob-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
gap: 14px;
align-items: start;
}
.ob-panel {
min-width: 0;
border: 1px solid var(--hair);
border-radius: var(--radius);
background: var(--bg-surface);
box-shadow: var(--shadow-sm);
}
.ob-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
padding: 16px;
}
.ob-form .vg-btn,
.ob-check {
grid-column: 1 / -1;
}
.ob-check {
display: flex;
align-items: center;
gap: 9px;
color: var(--text-body);
font-size: var(--fs-sm);
line-height: 1.4;
}
.ob-check input {
width: 17px;
height: 17px;
accent-color: var(--accent);
}
.ob-docs {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
}
.ob-docs h2 {
margin: 6px 0 0;
color: var(--text-strong);
font-size: var(--fs-body);
}
.ob-docs p {
margin: 6px 0 0;
color: var(--text-body);
font-size: var(--fs-xs);
line-height: 1.55;
}
.ob-docs article {
min-width: 0;
max-height: 220px;
overflow: auto;
padding: 12px;
border: 1px solid var(--hair);
border-radius: var(--radius-sm);
background: var(--bg-surface-2);
}
.ob-docs b,
.ob-docs small {
display: block;
}
.ob-docs b {
color: var(--text-strong);
font-size: var(--fs-sm);
}
.ob-docs small {
margin-top: 2px;
color: var(--text-muted);
font-family: var(--font-num);
font-size: var(--fs-xs);
}
@media (max-width: 860px) {
.ob-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 560px) {
.ob-form {
grid-template-columns: 1fr;
}
}

View file

@ -3,23 +3,9 @@
.sr-root {
width: min(100%, 1360px);
margin: 0 auto;
position: relative;
isolation: isolate;
display: grid;
gap: var(--sp-5);
}
.sr-root::before {
content: "";
position: absolute;
z-index: -1;
inset: -90px 0 auto auto;
width: min(620px, 56vw);
height: 440px;
background: var(--asset-warm-elements) center / cover no-repeat;
opacity: .05;
filter: saturate(.8);
pointer-events: none;
}
.sr-root--empty {
--sr-empty-tone: var(--neutral-sig);
}
@ -128,18 +114,29 @@
.sr-cols {
min-width: 0;
display: grid;
grid-template-columns: minmax(230px, 0.62fr) minmax(0, 1.22fr) minmax(280px, 0.68fr);
grid-template-columns: minmax(236px, 280px) minmax(0, 1fr) minmax(286px, 340px);
grid-template-areas:
"overview chart rubric"
"overview flow good"
"transcript transcript growth"
"transcript transcript feedback"
"worksheet worksheet worksheet"
"overview flow rubric"
"overview transcript good"
"overview transcript growth"
"overview transcript worksheet"
"overview transcript feedback"
"session session session";
gap: var(--sp-4);
align-items: start;
margin-top: 0;
}
.sr-cols--supervisor {
grid-template-areas:
"overview chart teacher"
"overview flow rubric"
"overview transcript good"
"overview transcript growth"
"overview transcript worksheet"
"overview transcript feedback"
"session session session";
}
.sr-left,
.sr-right {
display: contents;
@ -148,10 +145,14 @@
grid-area: overview;
min-width: 0;
display: grid;
gap: var(--sp-4);
gap: var(--sp-3);
align-self: start;
position: sticky;
top: calc(var(--topbar-h) + var(--sp-4));
z-index: 1;
max-height: calc(100vh - var(--topbar-h) - var(--sp-5) - var(--sp-5));
overflow: auto;
scrollbar-gutter: stable;
}
.sr-feedback {
grid-area: feedback;
@ -165,6 +166,9 @@
.sr-card--rubric {
grid-area: rubric;
}
.sr-card--teacher-review {
grid-area: teacher;
}
.sr-card--good {
grid-area: good;
}
@ -192,6 +196,68 @@
padding-top: var(--sp-5);
padding-bottom: var(--sp-5);
}
.sr-teacher-review__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--sp-3);
margin-bottom: var(--sp-3);
}
.sr-teacher-review__head b {
display: block;
margin-top: 6px;
color: var(--text-strong);
font-size: var(--fs-body);
line-height: 1.35;
}
.sr-teacher-review__note {
display: grid;
gap: 7px;
}
.sr-teacher-review__note span {
color: var(--text-muted);
font-size: var(--fs-xs);
font-weight: 700;
}
.sr-teacher-review__note textarea {
width: 100%;
min-height: 104px;
resize: vertical;
border: 1px solid var(--border-subtle);
border-radius: var(--radius);
background: var(--bg-surface-2);
color: var(--text-body);
padding: 10px 12px;
font: inherit;
font-size: var(--fs-sm);
line-height: 1.5;
}
.sr-teacher-review__note textarea:focus {
outline: 2px solid var(--accent);
outline-offset: 1px;
border-color: var(--accent);
}
.sr-teacher-review__meta,
.sr-teacher-review__error {
margin: var(--sp-3) 0 0;
font-size: var(--fs-xs);
line-height: 1.45;
}
.sr-teacher-review__meta {
color: var(--text-muted);
}
.sr-teacher-review__error {
color: var(--crit-text);
}
.sr-teacher-review__actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--sp-2);
margin-top: var(--sp-3);
}
.sr-teacher-review__actions .vg-btn {
width: 100%;
}
.sr-summary {
max-width: 68ch;
@ -203,27 +269,32 @@
line-height: 1.45;
}
.sr-overview .sr-summary {
font-size: clamp(18px, 1.26vw, 21px);
font-size: var(--fs-sm);
font-weight: 520;
line-height: 1.58;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 12;
-webkit-line-clamp: 5;
-webkit-box-orient: vertical;
}
.sr-summary .sr-hl {
color: var(--accent-deep);
}
.sr-overview .sr-summary .sr-hl {
font-weight: 650;
}
.sr-readiness {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
padding-top: var(--sp-4);
gap: 8px;
padding-top: var(--sp-3);
border-top: 1px solid var(--hair);
}
.sr-readiness span {
min-width: 0;
display: grid;
gap: 4px;
padding: 10px 12px;
padding: 8px 10px;
border-radius: var(--radius);
background: var(--bg-surface-2);
}
@ -246,6 +317,24 @@
.sr-actions .vg-btn {
width: 100%;
}
.sr-share-note {
min-width: 0;
margin: calc(var(--sp-2) * -1) 0 0;
padding: 8px 10px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius);
background: var(--bg-surface-2);
color: var(--text-muted);
font-family: var(--font-num);
font-size: var(--fs-xs);
line-height: 1.45;
overflow-wrap: anywhere;
}
.sr-share-note--error {
color: var(--crit-text);
background: var(--crit-tint);
border-color: color-mix(in srgb, var(--crit-text) 28%, var(--border-subtle));
}
.sr-empty {
max-width: none;
@ -646,13 +735,66 @@
letter-spacing: 0.04em;
text-transform: uppercase;
}
.sr-note__txt {
margin: 0;
.sr-note__body {
display: grid;
gap: 8px;
color: var(--text-body);
font-size: var(--fs-sm);
line-height: 1.58;
}
.sr-note__txt .sr-quote {
.sr-md {
display: grid;
gap: 7px;
}
.sr-md__p {
margin: 0;
}
.sr-md code {
display: inline;
padding: 1px 5px;
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
border-radius: 5px;
font-family: var(--font-num);
font-size: .92em;
color: var(--text-strong);
background: color-mix(in srgb, var(--bg-surface) 74%, transparent);
white-space: normal;
word-break: break-word;
}
.sr-md strong {
color: var(--text-strong);
font-weight: 720;
}
.sr-md__list {
display: grid;
gap: 4px;
margin: 0;
padding-left: 18px;
}
.sr-md__quote,
.sr-note__quote {
margin: 0;
padding: 8px 10px;
border-left: 3px solid color-mix(in srgb, var(--accent) 70%, var(--border-strong));
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
color: var(--text-strong);
background: color-mix(in srgb, var(--bg-surface) 72%, var(--accent-tint));
}
.sr-md__quote {
display: grid;
gap: 3px;
}
.sr-note__quote span {
display: block;
margin-bottom: 3px;
color: var(--text-muted);
font-family: var(--font-num);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
}
.sr-note__quote p {
margin: 0;
color: var(--text-strong);
font-style: italic;
}
@ -737,6 +879,20 @@
font-size: 11px;
line-height: 1.35;
}
.sr-ws-readonly {
flex: none;
min-height: 28px;
display: inline-flex;
align-items: center;
padding: 0 9px;
border: 1px solid var(--hair);
border-radius: var(--radius-sm);
color: var(--text-muted);
background: var(--bg-surface-2);
font-size: 11px;
font-weight: 700;
white-space: nowrap;
}
.sr-ws-section {
min-width: 0;
display: grid;
@ -815,6 +971,16 @@
outline: 2px solid var(--accent-tint);
border-color: var(--accent);
}
.sr-ws-input.is-readonly {
resize: none;
cursor: default;
color: var(--text-body);
background: color-mix(in srgb, var(--bg-surface) 82%, var(--bg-surface-2));
}
.sr-ws-input.is-readonly:focus {
outline: none;
border-color: var(--border-subtle);
}
.sr-ws-input::placeholder {
color: var(--text-muted);
}
@ -867,9 +1033,7 @@
/* Filled state: only a real client quote earns the high-contrast stage card. */
.sr-feedback--filled {
border-color: transparent;
background:
linear-gradient(135deg, rgba(30, 39, 36, .95), rgba(30, 39, 36, .82)),
var(--asset-warm-elements) center / cover no-repeat;
background: linear-gradient(135deg, rgba(30, 39, 36, .95), rgba(30, 39, 36, .82));
box-shadow: var(--shadow-sm);
}
.sr-feedback__kicker {
@ -1006,25 +1170,17 @@
overflow-wrap: anywhere;
}
[data-theme="dark"] .sr-root::before {
opacity: 0.1;
filter: saturate(0.9) contrast(1.06);
}
[data-theme="dark"] .sr-head,
[data-theme="dark"] .sr-card,
[data-theme="dark"] .sr-overview {
background:
linear-gradient(180deg, rgba(27, 42, 38, 0.92), rgba(18, 31, 28, 0.96)),
var(--asset-warm-elements) center / cover no-repeat;
background: linear-gradient(180deg, rgba(27, 42, 38, 0.92), rgba(18, 31, 28, 0.96));
border-color: rgba(203, 227, 220, 0.13);
box-shadow:
0 16px 42px rgba(3, 9, 8, 0.24),
inset 0 1px 0 rgba(255, 255, 255, 0.035);
}
[data-theme="dark"] .sr-card--transcript {
background:
linear-gradient(180deg, rgba(13, 24, 22, 0.98), rgba(11, 19, 17, 0.98)),
var(--asset-warm-elements) center / cover no-repeat;
background: linear-gradient(180deg, rgba(13, 24, 22, 0.98), rgba(11, 19, 17, 0.98));
}
[data-theme="dark"] .sr-stat,
[data-theme="dark"] .sr-readiness span,
@ -1057,9 +1213,7 @@
}
[data-theme="dark"] .sr-feedback--filled {
border-color: rgba(203, 227, 220, 0.12);
background:
linear-gradient(135deg, rgba(12, 24, 21, 0.98), rgba(30, 44, 40, 0.92)),
var(--asset-warm-elements) center / cover no-repeat;
background: linear-gradient(135deg, rgba(12, 24, 21, 0.98), rgba(30, 44, 40, 0.92));
}
@media (max-width: 1180px) {
@ -1075,8 +1229,23 @@
"worksheet worksheet"
"session session";
}
.sr-cols--supervisor {
grid-template-areas:
"overview overview"
"chart flow"
"teacher teacher"
"rubric rubric"
"good growth"
"transcript transcript"
"feedback feedback"
"worksheet worksheet"
"session session";
}
.sr-overview {
position: static;
max-height: none;
overflow: visible;
scrollbar-gutter: auto;
}
.sr-readiness {
grid-template-columns: repeat(3, minmax(0, 1fr));
@ -1097,9 +1266,6 @@
.sr-root {
gap: var(--sp-4);
}
.sr-root::before {
display: none;
}
.sr-head {
grid-template-columns: 1fr;
gap: var(--sp-4);
@ -1123,12 +1289,28 @@
"session";
gap: var(--sp-4);
}
.sr-cols--supervisor {
grid-template-areas:
"overview"
"chart"
"flow"
"teacher"
"rubric"
"good"
"growth"
"transcript"
"feedback"
"worksheet"
"session";
}
.sr-summary {
font-size: 20px;
line-height: 1.45;
}
.sr-overview .sr-summary {
-webkit-line-clamp: 8;
font-size: clamp(18px, 1.26vw, 21px);
font-weight: 650;
}
.sr-readiness {
grid-template-columns: repeat(3, minmax(0, 1fr));
@ -1200,6 +1382,9 @@
flex-basis: 100%;
min-height: 38px;
}
.sr-teacher-review__actions {
grid-template-columns: 1fr;
}
.sr-feedback,
.sr-card,
.sr-overview {

File diff suppressed because it is too large Load diff

View file

@ -7,12 +7,10 @@
/* ── 레이아웃: 좌측 섹션 내비(sticky) + 우측 폼 ── */
body[data-page="settings"] .vg-shell {
background:
linear-gradient(135deg, rgba(251, 250, 248, 0.96), rgba(244, 242, 238, 0.92)),
var(--asset-warm-elements) center / cover no-repeat;
background: var(--bg-app);
}
body[data-page="settings"] .vg-topbar {
background: rgba(251, 250, 248, 0.94);
background: var(--bg-surface);
border-bottom-color: var(--hair);
color: var(--text-strong);
}
@ -1539,22 +1537,14 @@ body[data-page="settings"] .vg-set__pill {
}
html[data-theme="dark"] body[data-page="settings"] .vg-shell {
background:
radial-gradient(circle at 12% 0%, rgba(111, 179, 164, 0.18), transparent 34%),
radial-gradient(circle at 92% 12%, rgba(205, 154, 116, 0.12), transparent 32%),
linear-gradient(135deg, rgba(11, 25, 22, 0.98), rgba(18, 30, 27, 0.96)),
var(--asset-warm-elements) center / cover no-repeat;
background: var(--bg-app);
}
html[data-theme="dark"] body[data-page="settings"] .vg-set__rail,
html[data-theme="dark"] body[data-page="settings"] .vg-set__group {
border-color: rgba(203, 227, 220, 0.13);
background:
linear-gradient(180deg, rgba(28, 44, 39, 0.93), rgba(16, 29, 26, 0.95)),
rgba(14, 27, 24, 0.88);
box-shadow:
0 24px 58px rgba(0, 0, 0, 0.26),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
border-color: var(--border-subtle);
background: var(--bg-surface);
box-shadow: var(--shadow-sm);
}
html[data-theme="dark"] body[data-page="settings"] .vg-set__rail-title h1,

View file

@ -101,6 +101,8 @@
/* ── 그리드·형태 §3.8 ── */
--maxw: 1200px; /* 대시보드 콘텐츠 최대폭 */
--maxw-read: 680px; /* 읽기 영역 */
--border: var(--border-subtle);
--font-mono: var(--font-num);
--nav-w: 240px; /* 좌측 네비 펼침 */
--nav-w-collapsed: 72px; /* 좌측 네비 아이콘 only */
--topbar-h: 56px;
@ -177,9 +179,8 @@
}
/* 역할별 accent 오버라이드 §6.2
같은 골격, accent 토큰만 교체. 모두 저채도.
API role(learner/teacher/admin) data-role 매핑:
teacher 화면은 data-role="instructor"(인디고-블루), admin은 슬레이트. */
역할별 페이지를 다른 제품처럼 보이게 하지 않는다.
API role(learner/teacher/admin) data-role 매핑은 유지하되, 기본 accent는 공통 세이지로 통일한다. */
[data-role="learner"] {
--accent: #3e7a6e;
--accent-deep: #316257;
@ -188,18 +189,18 @@
--focus-ring: rgba(62, 122, 110, 0.45);
}
[data-role="instructor"] {
--accent: #3a5ba0; /* 인디고-블루: 감독 */
--accent-deep: #2f4a85;
--accent-bright: #5478c4;
--accent-tint: #eaeff7;
--focus-ring: rgba(58, 91, 160, 0.4);
--accent: #3e7a6e;
--accent-deep: #316257;
--accent-bright: #5f968b;
--accent-tint: #eef4f2;
--focus-ring: rgba(62, 122, 110, 0.45);
}
[data-role="admin"] {
--accent: #5b5f6b; /* 슬레이트: 운영 */
--accent-deep: #494d57;
--accent-bright: #7d818e;
--accent-tint: #eeeff1;
--focus-ring: rgba(91, 95, 107, 0.4);
--accent: #3e7a6e;
--accent-deep: #316257;
--accent-bright: #5f968b;
--accent-tint: #eef4f2;
--focus-ring: rgba(62, 122, 110, 0.45);
}
/* 다크 + 역할 동시 적용 시 accent 명도 보정 (가독) */
@ -212,17 +213,17 @@
}
[data-theme="dark"] body[data-role="instructor"],
[data-theme="dark"][data-role="instructor"] {
--accent: #7d9fde;
--accent-deep: #94b1e6;
--accent-bright: #7d9fde;
--accent-tint: #19222f;
--focus-ring: rgba(125, 159, 222, 0.4);
--accent: #6fb3a4;
--accent-deep: #84c2b4;
--accent-bright: #6fb3a4;
--accent-tint: #1e2e2a;
--focus-ring: rgba(111, 179, 164, 0.45);
}
[data-theme="dark"] body[data-role="admin"],
[data-theme="dark"][data-role="admin"] {
--accent: #9aa0ad;
--accent-deep: #b0b5c0;
--accent-bright: #9aa0ad;
--accent-tint: #1d2026;
--focus-ring: rgba(154, 160, 173, 0.4);
--accent: #6fb3a4;
--accent-deep: #84c2b4;
--accent-bright: #6fb3a4;
--accent-tint: #1e2e2a;
--focus-ring: rgba(111, 179, 164, 0.45);
}