음성 재생과 운영 배포 정리
This commit is contained in:
parent
8ed185ce6c
commit
ac7db95542
1020 changed files with 46863 additions and 2175 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
48
apps/web/src/lib/personaViewModel.ts
Normal file
48
apps/web/src/lib/personaViewModel.ts
Normal 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(" · ");
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
595
apps/web/src/pages/Onboarding.tsx
Normal file
595
apps/web/src/pages/Onboarding.tsx
Normal 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%;
|
||||
}
|
||||
}
|
||||
`;
|
||||
176
apps/web/src/pages/PendingApproval.tsx
Normal file
176
apps/web/src/pages/PendingApproval.tsx
Normal 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%;
|
||||
}
|
||||
}
|
||||
`;
|
||||
2199
apps/web/src/pages/PersonaStudio.tsx
Normal file
2199
apps/web/src/pages/PersonaStudio.tsx
Normal file
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
|
|
@ -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" : ""}`}>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
1411
apps/web/src/pages/admin/admin-console.css
Normal file
1411
apps/web/src/pages/admin/admin-console.css
Normal file
File diff suppressed because it is too large
Load diff
134
apps/web/src/pages/onboarding.css
Normal file
134
apps/web/src/pages/onboarding.css
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue