platform/lms-adm-fe release promotion snapshot

This commit is contained in:
2026-07-07 09:15:47 +00:00
parent 1bf81f86bb
commit d029bdeda7
59 changed files with 12096 additions and 2 deletions
+256
View File
@@ -0,0 +1,256 @@
import { useEffect, useState } from 'react';
import { Navigate, useLocation, useNavigate } from 'react-router-dom';
import { ToastProvider } from './components/ToastProvider';
import { InstructorLayout } from './layouts/InstructorLayout';
import { clearStoredSession, loadStoredSession, logoutInstructor, refreshCurrentUser, refreshInstructorSession } from './lib/auth';
import { listInstructorCourses, type InstructorCourse } from './lib/courses';
import { AnalyticsPage } from './pages/analytics/AnalyticsPage';
import { CurriculumPage } from './pages/curriculum/CurriculumPage';
import { DashboardPage } from './pages/dashboard/DashboardPage';
import { GroupsPage } from './pages/groups/GroupsPage';
import { LoginPage } from './pages/login/LoginPage';
import { PlatformsPage } from './pages/platforms/PlatformsPage';
import { ProfilePage } from './pages/profile/ProfilePage';
import { SettingsPage } from './pages/settings/SettingsPage';
import { SubmissionsPage } from './pages/submissions/SubmissionsPage';
import { StudentsPage } from './pages/students/StudentsPage';
import { defaultRoute, getRouteFromPathname, getRoutePath, type AppRoute } from './routes';
import type { AuthSession } from './types/auth';
function App() {
const [session, setSession] = useState<AuthSession | null>(() => loadStoredSession());
const [courses, setCourses] = useState<InstructorCourse[]>([]);
const [selectedCourseId, setSelectedCourseId] = useState(() => window.localStorage.getItem('lms-admin-selected-course-id') ?? '');
const [sessionExpired, setSessionExpired] = useState(false);
const [chatThreadOpenRequest, setChatThreadOpenRequest] = useState<{ threadId: string; requestId: number } | null>(null);
const location = useLocation();
const navigate = useNavigate();
const route = getRouteFromPathname(location.pathname);
const activeRoute = route ?? defaultRoute;
const [visibleRoute, setVisibleRoute] = useState<AppRoute>(activeRoute);
const [transitionState, setTransitionState] = useState<'entering' | 'leaving'>('entering');
const setRoute = (nextRoute: AppRoute) => {
navigate(getRoutePath(nextRoute));
};
const handleLogout = async () => {
await logoutInstructor();
setSession(null);
setSessionExpired(false);
};
const expireSession = () => {
clearStoredSession();
setSession(null);
setSessionExpired(true);
};
const moveToLogin = () => {
clearStoredSession();
setSession(null);
setSessionExpired(false);
};
useEffect(() => {
if (activeRoute === visibleRoute) return;
setTransitionState('leaving');
const leaveTimer = window.setTimeout(() => {
setVisibleRoute(activeRoute);
setTransitionState('entering');
}, 120);
return () => window.clearTimeout(leaveTimer);
}, [activeRoute, visibleRoute]);
useEffect(() => {
if (!session) return;
let cancelled = false;
refreshCurrentUser(session)
.then((nextSession) => {
if (!cancelled) setSession(nextSession);
})
.catch(async () => {
if (!cancelled) {
try {
const nextSession = await refreshInstructorSession(session);
if (!cancelled) setSession(nextSession);
} catch {
if (!cancelled) expireSession();
}
}
});
return () => {
cancelled = true;
};
}, [session?.accessToken]);
useEffect(() => {
if (!session?.accessToken) return;
let cancelled = false;
listInstructorCourses(session.accessToken)
.then((payload) => {
if (cancelled) return;
setCourses(payload.courses);
setSelectedCourseId((current) => {
const next = payload.courses.some((course) => course.id === current)
? current
: payload.courses[0]?.id ?? '';
if (next) window.localStorage.setItem('lms-admin-selected-course-id', next);
return next;
});
})
.catch(() => {
if (!cancelled) setCourses([]);
});
return () => {
cancelled = true;
};
}, [session?.accessToken]);
const handleSelectCourse = (courseId: string) => {
setSelectedCourseId(courseId);
if (courseId) window.localStorage.setItem('lms-admin-selected-course-id', courseId);
};
useEffect(() => {
if (!session?.accessToken || !session.refreshToken) return;
const obtainedAt = session.obtainedAt ?? Date.now();
const expiresInMs = (session.accessTokenExpiresIn ?? 3600) * 1000;
const refreshAt = obtainedAt + expiresInMs - 60_000;
const delayMs = Math.max(5_000, refreshAt - Date.now());
const timer = window.setTimeout(() => {
refreshInstructorSession(session)
.then((nextSession) => {
setSession(nextSession);
setSessionExpired(false);
})
.catch(() => expireSession());
}, delayMs);
return () => window.clearTimeout(timer);
}, [session?.accessToken, session?.refreshToken, session?.obtainedAt, session?.accessTokenExpiresIn]);
useEffect(() => {
if (!session) return;
const handleExpired = () => expireSession();
window.addEventListener('lms-admin-session-expired', handleExpired);
return () => {
window.removeEventListener('lms-admin-session-expired', handleExpired);
};
}, [session]);
useEffect(() => {
if (!session) return;
const originalFetch = window.fetch.bind(window);
const wrappedFetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const response = await originalFetch(input, init);
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
const isAuthMutation = url.includes('/auth/login') || url.includes('/auth/refresh') || url.includes('/auth/logout');
if (response.status === 401 && !isAuthMutation) {
window.dispatchEvent(new Event('lms-admin-session-expired'));
}
return response;
};
window.fetch = wrappedFetch as typeof window.fetch;
return () => {
window.fetch = originalFetch as typeof window.fetch;
};
}, [session?.accessToken]);
if (sessionExpired) {
return <SessionExpiredPage onLogin={moveToLogin} />;
}
if (!session) {
return <LoginPage onLogin={setSession} />;
}
if (!route) {
return <Navigate to={getRoutePath(defaultRoute)} replace />;
}
return (
<ToastProvider>
<InstructorLayout
session={session}
route={activeRoute}
onNavigate={setRoute}
onLogout={handleLogout}
courses={courses}
selectedCourseId={selectedCourseId}
onSelectCourse={handleSelectCourse}
chatThreadId={chatThreadOpenRequest?.threadId ?? null}
chatThreadRequestId={chatThreadOpenRequest?.requestId ?? 0}
>
<div className={`page-transition ${transitionState === 'leaving' ? 'page-transition-out' : 'page-transition-in'}`}>
<RoutePage
route={visibleRoute}
session={session}
selectedCourseId={selectedCourseId}
onNavigate={setRoute}
onOpenChatThread={(threadId) => setChatThreadOpenRequest({ threadId, requestId: Date.now() })}
/>
</div>
</InstructorLayout>
</ToastProvider>
);
}
function SessionExpiredPage({ onLogin }: { onLogin: () => void }) {
return (
<main className="grid min-h-screen place-items-center bg-[#f6f8fb] px-6">
<section className="w-full max-w-[420px] rounded-xl border border-[#c7d2e0] bg-white px-7 py-8 text-center shadow-[0_24px_70px_rgba(15,23,42,0.12)]">
<div className="mx-auto grid h-12 w-12 place-items-center rounded-full bg-[#fff1f2] text-[#e11d48]">
<span className="text-[24px] font-black">!</span>
</div>
<h1 className="mt-5 text-[22px] font-black text-[#111827]">세션이 만료되었습니다</h1>
<p className="mt-3 text-[14px] font-semibold leading-relaxed text-[#64748b]">
보안을 위해 현재 로그인 세션을 사용할 수 없습니다. 새로고침 후 다시 로그인해 주세요.
</p>
<button
type="button"
className="mt-6 h-10 w-full rounded-md bg-[#2563eb] text-[14px] font-black text-white hover:bg-[#1d4ed8]"
onClick={onLogin}
>
로그인으로 이동
</button>
</section>
</main>
);
}
function RoutePage({ route, session, selectedCourseId, onNavigate, onOpenChatThread }: { route: AppRoute; session: AuthSession; selectedCourseId: string; onNavigate: (route: AppRoute) => void; onOpenChatThread: (threadId: string) => void }) {
switch (route) {
case 'dashboard':
return <DashboardPage session={session} selectedCourseId={selectedCourseId} onNavigate={onNavigate} onOpenChatThread={onOpenChatThread} />;
case 'curriculum':
return <CurriculumPage session={session} selectedCourseId={selectedCourseId} />;
case 'groups':
return <GroupsPage session={session} selectedCourseId={selectedCourseId} />;
case 'platforms':
return <PlatformsPage session={session} />;
case 'students':
return <StudentsPage session={session} selectedCourseId={selectedCourseId} />;
case 'submissions':
return <SubmissionsPage session={session} />;
case 'analytics':
return <AnalyticsPage />;
case 'settings':
return <SettingsPage />;
case 'profile':
return <ProfilePage session={session} />;
}
}
export default App;
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

+779
View File
@@ -0,0 +1,779 @@
@import "tailwindcss";
/* @font-face {
font-family: 'NanumGothic';
src: url('./fonts/NanumGothicLight.woff2') format('woff2');
font-weight: 300;
font-style: normal;
}
@font-face {
font-family: 'NanumGothic';
src: url('./fonts/NanumGothic.woff2') format('woff2');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'NanumGothic';
src: url('./fonts/NanumGothicBold.woff2') format('woff2');
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: 'NanumGothic';
src: url('./fonts/NanumGothicExtraBold.woff2') format('woff2');
font-weight: 800;
font-style: normal;
} */
::-webkit-scrollbar {
width: 4px;
height: 4px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 99px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.28);
}
:root {
--bg-deep: #0a0b0e;
--bg-sidebar: #0d0f14;
--bg-main: #0f1117;
--border: rgba(255, 255, 255, 0.06);
--accent: #3b7ffa;
--accent-glow: rgba(59, 127, 250, 0.35);
--text-primary: #e8eaf0;
--text-secondary: rgba(200, 205, 220, 0.65);
--text-muted: rgba(157, 163, 175, 0.911);
--text-soft: #d6dae3;
--sidebar-width: 280px;
}
html,
body,
#root {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
background: #000;
font-family: 'NanumSquare', sans-serif;
}
/* ── Sidebar ── */
.sidebar {
background: var(--bg-sidebar);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
padding: 8px 0;
flex-shrink: 0;
position: relative;
z-index: 20;
overflow: visible;
}
.sidebar::after {
content: '';
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 1px;
background: linear-gradient(
to bottom,
transparent,
rgba(59, 127, 250, 0.15) 40%,
rgba(59, 127, 250, 0.15) 60%,
transparent
);
pointer-events: none;
}
.nav-item {
display: flex;
align-items: center;
padding: 13px 24px;
font-size: 13.5px;
font-weight: 400;
color: var(--text-secondary);
cursor: pointer;
position: relative;
letter-spacing: 0.01em;
transition: color 0.2s, background 0.2s;
}
.nav-item:hover {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.03);
}
.nav-item.active {
color: var(--accent);
font-weight: 500;
background: rgba(59, 127, 250, 0.07);
}
.nav-item.active::before {
content: '';
position: absolute;
left: 0;
top: 8px;
bottom: 8px;
width: 3px;
background: var(--accent);
border-radius: 0 3px 3px 0;
box-shadow: 0 0 12px var(--accent-glow);
}
.nav-item-icon {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
margin-right: 10px;
opacity: 0.55;
flex-shrink: 0;
transition: opacity 0.2s;
}
.nav-item:hover .nav-item-icon {
opacity: 0.8;
}
.nav-item.active .nav-item-icon {
opacity: 1;
}
.nav-item-collapsed {
justify-content: center;
padding-left: 0;
padding-right: 0;
}
/* ── Sidebar Collapse Toggle Button ── */
.sidebar-collapse-btn {
position: absolute;
right: -10px;
top: 50%;
transform: translateY(-50%);
z-index: 50;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--bg-sidebar);
border: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--text-muted);
transition: color 0.2s, background 0.2s, border-color 0.2s;
}
.sidebar-collapse-btn:hover {
color: var(--text-primary);
background: rgba(255,255,255,0.06);
border-color: rgba(255,255,255,0.2);
}
/* ── Main Content ── */
.main-content {
flex: 1;
background: var(--bg-main);
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
}
.main-content::before {
content: '';
position: absolute;
inset: 0;
background:
radial-gradient(ellipse 80% 60% at 65% 50%, rgba(20, 25, 40, 0.9) 0%, transparent 70%),
radial-gradient(ellipse 50% 50% at 20% 50%, rgba(15, 30, 60, 0.3) 0%, transparent 60%);
pointer-events: none;
}
/* ── Update Button ── */
.update-btn {
display: flex;
align-items: center;
gap: 7px;
padding: 7px 16px;
background: var(--accent);
border: 1px solid var(--accent);
border-radius: 6px;
color: #fff;
font-family: 'NanumGothic', sans-serif;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.03em;
cursor: pointer;
transition: background 0.2s, box-shadow 0.2s, transform 0.15s;
box-shadow: 0 4px 20px rgba(59, 127, 250, 0.35);
}
.update-btn:hover {
background: #5592ff;
box-shadow: 0 6px 28px rgba(59, 127, 250, 0.55);
transform: translateY(-1px);
}
.update-btn:active {
transform: translateY(0);
}
/* ── Updating Button (in-progress state) ── */
.updating-btn {
display: flex;
align-items: center;
gap: 7px;
padding: 7px 16px;
background: rgba(59, 127, 250, 0.15);
border: 1px solid rgba(59, 127, 250, 0.3);
border-radius: 6px;
color: rgba(255, 255, 255, 0.5);
font-family: 'NanumGothic', sans-serif;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.03em;
cursor: default;
}
/* ── Update Progress Bar ── */
.update-progress-wrap {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.update-progress-track {
flex: 1;
height: 3px;
background: rgba(255,255,255,0.08);
border-radius: 99px;
overflow: hidden;
}
.update-progress-fill {
height: 100%;
background: var(--accent);
border-radius: 99px;
transition: width 0.2s ease;
box-shadow: 0 0 6px rgba(59,127,250,0.6);
}
.update-progress-pct {
font-size: 11px;
color: var(--text-muted);
min-width: 30px;
text-align: right;
font-variant-numeric: tabular-nums;
}
/* ── Spinner ── */
@keyframes spin {
to { transform: rotate(360deg); }
}
.spin {
animation: spin 0.8s linear infinite;
}
/* ── Launch Button ── */
.launch-btn {
display: flex;
align-items: center;
gap: 7px;
padding: 7px 16px;
background: var(--accent);
border: 1px solid var(--accent);
border-radius: 6px;
color: #fff;
font-family: 'NanumGothic', sans-serif;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.03em;
cursor: pointer;
transition: background 0.2s, box-shadow 0.2s, transform 0.15s;
box-shadow: 0 4px 20px rgba(59, 127, 250, 0.35);
}
.launch-btn:hover {
background: #5592ff;
box-shadow: 0 6px 28px rgba(59, 127, 250, 0.55);
transform: translateY(-1px);
}
.launch-btn:active {
transform: translateY(0);
}
/* ── Car Video ── */
.car-video {
position: absolute;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center bottom;
opacity: 0;
animation: carFadeIn 1.4s 0.5s ease forwards;
z-index: 2;
}
.car-video-shadow {
position: absolute;
inset: 0;
background:
linear-gradient(to right, var(--bg-main) 0%, var(--bg-main) 14%, rgba(15, 17, 23, 0.45) 32%, transparent 52%),
linear-gradient(to left, var(--bg-main) 0%, rgba(15, 17, 23, 0.6) 8%, transparent 35%),
linear-gradient(to top, rgba(5, 7, 12, 0.9) 0%, transparent 45%),
radial-gradient(ellipse 60% 60% at 100% 100%, rgba(5, 7, 12, 0.8) 0%, transparent 70%),
radial-gradient(ellipse 100% 100% at 80% 40%, transparent 40%, rgba(5, 7, 12, 0.5) 100%);
pointer-events: none;
z-index: 3;
}
.floor-reflection {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 120px;
background: linear-gradient(to top, rgba(8, 10, 18, 0.8) 0%, transparent 100%);
pointer-events: none;
z-index: 1;
}
/* ── Text Content ── */
.text-content {
position: relative;
z-index: 10;
max-width: 480px;
animation: textReveal 1s 0.2s ease both;
}
.brand-title {
font-family: 'Rajdhani', sans-serif;
font-size: 52px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: 0.02em;
line-height: 1.0;
margin-bottom: 20px;
text-shadow: 0 0 60px rgba(59, 127, 250, 0.1);
}
.brand-title span {
color: var(--accent);
}
.subtitle {
font-size: 17px;
font-weight: 400;
color: var(--text-primary);
line-height: 1.7;
margin-bottom: 28px;
letter-spacing: 0.01em;
}
.divider {
width: 140px;
height: 2px;
background: linear-gradient(to right, var(--accent), transparent);
margin-bottom: 22px;
border-radius: 2px;
box-shadow: 0 0 10px var(--accent-glow);
}
.description {
font-size: 13.5px;
color: rgba(200, 210, 230, 0.85);
line-height: 2.0;
letter-spacing: 0.005em;
}
/* ── Verify Code Button ── */
.verify-code-btn {
flex-shrink: 0;
padding: 0 12px;
font-size: 12px;
font-weight: 500;
border-radius: 6px;
border: 1px solid rgba(59, 127, 250, 0.35);
background: rgba(59, 127, 250, 0.10);
color: var(--accent);
white-space: nowrap;
cursor: pointer;
transition: background 0.2s, border-color 0.2s, box-shadow 0.2s, transform 0.15s;
font-family: 'NanumGothic', sans-serif;
}
.verify-code-btn:hover:not(:disabled) {
background: rgba(59, 127, 250, 0.2);
border-color: rgba(59, 127, 250, 0.6);
box-shadow: 0 0 12px rgba(59, 127, 250, 0.25);
}
.verify-code-btn:disabled {
background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.07);
color: var(--text-muted);
cursor: not-allowed;
}
/* ── Notice Icon Button ── */
.notice-icon-btn {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 8px;
color: var(--text-secondary);
cursor: pointer;
transition: background 0.2s, border-color 0.2s, color 0.2s;
}
.notice-icon-btn:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.12);
color: var(--text-primary);
}
.notice-badge {
position: absolute;
top: 7px;
right: 7px;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 6px var(--accent-glow);
}
.notice-dropdown {
position: absolute;
top: calc(100% + 10px);
right: 0;
width: 320px;
background: rgba(13, 15, 22, 0.96);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
overflow: hidden;
}
.notice-item {
padding: 12px 16px;
}
.notice-item-border {
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
/* ── Notice Panel ── */
.notice-panel {
padding: 14px 16px;
background: rgba(13, 15, 20, 0.75);
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 10px;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
}
.notice-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 6px var(--accent-glow);
flex-shrink: 0;
}
.notice-label {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent);
}
.notice-date {
font-size: 10px;
color: var(--text-muted);
letter-spacing: 0.04em;
}
.notice-title {
font-size: 12.5px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 5px;
line-height: 1.4;
}
.notice-body {
font-size: 11.5px;
color: var(--text-secondary);
line-height: 1.7;
letter-spacing: 0.01em;
}
/* ── Dashboard ── */
.car-bg-radial {
position: absolute;
inset: 0;
background: radial-gradient(ellipse 90% 70% at 60% 55%, rgba(25, 35, 70, 0.15) 0%, transparent 60%);
}
.version-label {
position: absolute;
bottom: 16px;
right: 24px;
font-size: 11px;
font-weight: 500;
letter-spacing: 0.12em;
color: var(--text-soft);
z-index: 5;
}
/* ── Login ── */
.login-video {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center bottom;
opacity: 0;
animation: carFadeIn 1.4s 0.3s ease forwards;
z-index: 0;
}
.login-video-overlay {
position: absolute;
inset: 0;
background:
linear-gradient(to bottom, var(--bg-deep) 0%, rgba(10, 11, 14, 0.5) 35%, transparent 60%),
linear-gradient(to top, var(--bg-deep) 0%, transparent 40%),
linear-gradient(to right, rgba(10, 11, 14, 0.3) 0%, transparent 60%),
linear-gradient(to left, rgba(10, 11, 14, 0.5) 0%, transparent 55%),
radial-gradient(ellipse 60% 50% at 100% 100%, rgba(10, 11, 14, 0.7) 0%, transparent 70%);
z-index: 1;
pointer-events: none;
}
.login-panel {
border-left: 1px solid rgba(255, 255, 255, 0.07);
background: rgba(13, 15, 20, 0.6);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
}
.login-panel-edge {
position: absolute;
left: 0; top: 0; bottom: 0;
width: 1px;
background: linear-gradient(to bottom, transparent, rgba(59, 127, 250, 0.2) 30%, rgba(59, 127, 250, 0.2) 70%, transparent);
pointer-events: none;
}
.login-panel-glow {
position: absolute;
top: 0; left: 0; right: 0;
height: 200px;
background: radial-gradient(ellipse 80% 60% at 50% 0%, rgba(59, 127, 250, 0.06) 0%, transparent 100%);
pointer-events: none;
}
.animate-reveal {
animation: textReveal 0.8s 0.2s ease both;
}
.animate-reveal-late {
animation: textReveal 0.8s 0.3s ease both;
}
/* ── Login inputs ── */
.login-input-wrap {
position: relative;
display: flex;
align-items: center;
}
.login-input-icon {
position: absolute;
left: 14px;
width: 15px;
height: 15px;
color: var(--text-muted);
pointer-events: none;
flex-shrink: 0;
}
.login-input-field {
width: 100%;
padding: 11px 16px 11px 40px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
font-family: 'NanumGothic', sans-serif;
font-size: 13px;
outline: none;
transition: border-color 0.2s, background 0.2s;
box-sizing: border-box;
}
.login-input-field::placeholder { color: var(--text-muted); }
.login-input-field:focus {
border-color: rgba(59, 127, 250, 0.5);
background: rgba(59, 127, 250, 0.04);
}
.login-input-eye {
position: absolute;
right: 12px;
width: 16px;
height: 16px;
color: var(--text-muted);
background: none;
border: none;
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s;
}
.login-input-eye:hover { color: var(--text-secondary); }
.login-input-eye svg { width: 16px; height: 16px; }
.login-checkbox {
width: 14px;
height: 14px;
accent-color: var(--accent);
cursor: pointer;
}
.login-google-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 10px 0;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-secondary);
font-family: 'NanumGothic', sans-serif;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
}
.login-google-btn:hover {
background: rgba(255, 255, 255, 0.07);
border-color: rgba(255, 255, 255, 0.12);
}
/* ── Login ── */
.login-input {
width: 100%;
padding: 11px 16px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
font-family: 'NanumGothic', sans-serif;
font-size: 13px;
outline: none;
transition: border-color 0.2s, background 0.2s;
box-sizing: border-box;
}
.login-input::placeholder {
color: var(--text-muted);
}
.login-input:focus {
border-color: rgba(59, 127, 250, 0.5);
background: rgba(59, 127, 250, 0.04);
}
.login-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 11px 0;
background: var(--accent);
border: 1px solid var(--accent);
border-radius: 6px;
color: #fff;
font-family: 'NanumGothic', sans-serif;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.03em;
cursor: pointer;
transition: background 0.2s, box-shadow 0.2s, transform 0.15s;
box-shadow: 0 4px 20px rgba(59, 127, 250, 0.3);
}
.login-btn:hover:not(:disabled) {
background: #5592ff;
box-shadow: 0 6px 28px rgba(59, 127, 250, 0.5);
transform: translateY(-1px);
}
.login-btn:active:not(:disabled) {
transform: translateY(0);
}
/* ── Typing Cursor ── */
.typing-cursor {
display: inline-block;
width: 2px;
height: 1em;
background: var(--text-muted);
border-radius: 1px;
animation: cursorBlink 0.9s step-end infinite;
transform: translateY(2px);
}
@keyframes cursorBlink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
/* ── Running State ── */
.running-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 14px 6px 10px;
border-radius: 20px;
background: rgba(34, 197, 94, 0.07);
border: 1px solid rgba(34, 197, 94, 0.2);
margin-bottom: 24px;
}
.running-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #22c55e;
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.5);
animation: running-pulse 1.8s ease infinite;
flex-shrink: 0;
}
@keyframes running-pulse {
0% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.5); }
55% { box-shadow: 0 0 0 5px rgba(34, 197, 94, 0); }
100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0); }
}
/* ── Animations ── */
@keyframes textReveal {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes carFadeIn {
from { opacity: 0; transform: translateX(30px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
+14
View File
@@ -0,0 +1,14 @@
import autonomyLogo from '../assets/icon.png';
export function BrandMark({ dark = false }: { dark?: boolean }) {
return (
<div className={`flex items-center gap-3 ${dark ? 'text-[#111827]' : 'text-white'}`}>
<span className="grid h-8 w-8 place-items-center overflow-hidden rounded-lg bg-white ring-1 ring-white/25">
<img className="h-7 w-7 object-contain" src={autonomyLogo} alt="" />
</span>
<span className="text-[24px] font-black tracking-[0] sm:text-[24px]">
Autonomy <span className={dark ? 'text-[#4f46e5]' : 'text-[#67e8f9]'}>Studio</span>
</span>
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
import { createPortal } from 'react-dom';
import { Loader2, type LucideIcon } from 'lucide-react';
import type { ReactNode } from 'react';
type ConfirmDialogProps = {
open: boolean;
title: string;
message: string;
detail?: ReactNode;
confirmLabel?: string;
cancelLabel?: string;
loading?: boolean;
danger?: boolean;
icon?: LucideIcon;
onCancel: () => void;
onConfirm: () => void;
};
export function ConfirmDialog({
open,
title,
message,
detail,
confirmLabel = '확인',
cancelLabel = '취소',
loading = false,
danger = false,
icon: Icon,
onCancel,
onConfirm,
}: ConfirmDialogProps) {
if (!open) return null;
const confirmClass = danger
? 'bg-[#e11d48] text-white hover:bg-[#be123c]'
: 'bg-[#2563eb] text-white hover:bg-[#1d4ed8]';
return createPortal(
<div className="fixed inset-0 z-[1000] grid place-items-center bg-[#0f172a]/45 px-4 backdrop-blur-[2px]">
<section className="w-full max-w-[420px] overflow-hidden rounded-xl border border-[#cbd5e1] bg-white shadow-[0_28px_80px_rgba(15,23,42,0.30)]">
<header className="border-b border-[#cbd5e1] px-5 py-4">
<div className="min-w-0">
<h2 className="text-[18px] font-black leading-snug text-[#111827]">{title}</h2>
</div>
</header>
<div className="px-5 py-4">
<p className="text-[13px] font-semibold leading-relaxed text-[#64748b]">{message}</p>
{detail ? <div className="mt-4">{detail}</div> : null}
</div>
<footer className="flex justify-end gap-2 border-t border-[#cbd5e1] bg-[#f8fafc] px-5 py-4">
<button
type="button"
onClick={onCancel}
disabled={loading}
className="h-9 cursor-pointer rounded-md border border-[#cbd5e1] bg-white px-4 text-[13px] font-black text-[#475569] transition hover:bg-[#f1f5f9] disabled:cursor-not-allowed disabled:opacity-60"
>
{cancelLabel}
</button>
<button
type="button"
onClick={onConfirm}
disabled={loading}
className={`inline-flex h-9 cursor-pointer items-center gap-2 rounded-md px-4 text-[13px] font-black transition disabled:cursor-not-allowed disabled:opacity-60 ${confirmClass}`}
>
{loading ? <Loader2 className="animate-spin" size={14} aria-hidden="true" /> : Icon ? <Icon size={14} aria-hidden="true" /> : null}
{loading ? `${confirmLabel} 중` : confirmLabel}
</button>
</footer>
</section>
</div>,
document.body,
);
}
+468
View File
@@ -0,0 +1,468 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { ArrowLeft, ChevronRight, Loader2, MessageCircle, Send, UserRound, X } from 'lucide-react';
import { io } from 'socket.io-client';
import type { Socket } from 'socket.io-client';
import {
CHAT_SOCKET_BASE_URL,
createInstructorChatMessage,
getInstructorChatThread,
listInstructorChatThreads,
type InstructorChatMessage,
type InstructorChatThread,
} from '../lib/chat';
import type { AuthSession } from '../types/auth';
type InstructorChatPanelProps = {
session: AuthSession;
open: boolean;
initialThreadId?: string | null;
initialThreadRequestId?: number;
onClose: () => void;
};
type ChatNotificationPayload = {
type: 'chat.message.created';
threadId: string;
messageId: string;
senderType: 'STUDENT' | 'AI' | 'INSTRUCTOR' | 'SYSTEM';
preview: string;
createdAt: string;
};
export function InstructorChatPanel({ session, open, initialThreadId = null, initialThreadRequestId = 0, onClose }: InstructorChatPanelProps) {
const [threads, setThreads] = useState<InstructorChatThread[]>([]);
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [roomLoading, setRoomLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [mounted, setMounted] = useState(open);
const selectedThread = useMemo(() => threads.find((thread) => thread.id === selectedThreadId) ?? null, [selectedThreadId, threads]);
const refreshThreadList = (showLoading = false) => {
let cancelled = false;
if (showLoading) setLoading(true);
setError(null);
const request = listInstructorChatThreads(session.accessToken)
.then((items) => {
if (!cancelled) setThreads(items);
})
.catch((reason: Error) => {
if (!cancelled) {
setThreads([]);
setError(reason.message);
}
})
.finally(() => {
if (!cancelled && showLoading) setLoading(false);
});
return {
cancel: () => {
cancelled = true;
},
request,
};
};
const refreshSelectedThread = (threadId: string, showLoading = false) => {
if (showLoading) setRoomLoading(true);
setError(null);
return getInstructorChatThread(session.accessToken, threadId)
.then((thread) => {
setThreads((current) => replaceThread(current, thread));
})
.catch((reason: Error) => {
setError(reason.message);
})
.finally(() => {
if (showLoading) setRoomLoading(false);
});
};
useEffect(() => {
if (!open) return;
const refresh = refreshThreadList(true);
return () => {
refresh.cancel();
};
}, [open, session.accessToken]);
useEffect(() => {
if (!open || !initialThreadId) return;
setSelectedThreadId(initialThreadId);
void refreshSelectedThread(initialThreadId, true);
}, [open, initialThreadId, initialThreadRequestId, session.accessToken]);
useEffect(() => {
if (!open) return;
const socket: Socket = io(CHAT_SOCKET_BASE_URL, {
auth: { token: session.accessToken },
transports: ['websocket', 'polling'],
});
const handleMessageCreated = (payload: ChatNotificationPayload) => {
void refreshThreadList(false).request;
if (selectedThreadId && payload.threadId === selectedThreadId) {
void refreshSelectedThread(selectedThreadId, false);
}
};
socket.on('connect', () => {
void refreshThreadList(false).request;
if (selectedThreadId) void refreshSelectedThread(selectedThreadId, false);
});
socket.on('chat.message.created', handleMessageCreated);
return () => {
socket.off('chat.message.created', handleMessageCreated);
socket.disconnect();
};
}, [open, selectedThreadId, session.accessToken]);
useEffect(() => {
if (!open) setSelectedThreadId(null);
}, [open]);
useEffect(() => {
if (open) {
setMounted(true);
return;
}
const timer = window.setTimeout(() => setMounted(false), 220);
return () => window.clearTimeout(timer);
}, [open]);
const handleOpenThread = (threadId: string) => {
setSelectedThreadId(threadId);
void refreshSelectedThread(threadId, true);
};
const handleSendMessage = async (threadId: string, content: string) => {
const updatedThread = await createInstructorChatMessage(session.accessToken, threadId, content);
setThreads((current) => replaceThread(current, updatedThread));
};
if (!mounted) return null;
return (
<aside className={`fixed bottom-0 right-0 top-[60px] z-30 flex w-full max-w-[420px] flex-col border-l border-[#cbd5e1] bg-white/92 shadow-[0_22px_70px_rgba(15,23,42,0.18)] backdrop-blur-md transition-transform duration-200 ease-out ${open ? 'translate-x-0' : 'translate-x-full'}`}>
{selectedThread ? (
<ChatRoom thread={selectedThread} loading={roomLoading} onBack={() => setSelectedThreadId(null)} onClose={onClose} onSend={handleSendMessage} />
) : (
<>
<div className="flex h-[68px] items-center justify-between border-b border-[#cbd5e1] px-5">
<div>
<p className="text-[15px] font-black text-[#0f172a]">채팅</p>
<p className="mt-1 text-[12px] font-bold text-[#64748b]">수강생 문의 목록</p>
</div>
<button className="grid h-9 w-9 place-items-center rounded-md text-[#64748b] hover:bg-[#f8fafc] hover:text-[#111827]" type="button" aria-label="채팅 닫기" title="채팅 닫기" onClick={onClose}>
<X size={17} aria-hidden="true" />
</button>
</div>
<div className="flex-1 overflow-y-auto overscroll-contain px-4 py-4">
{loading ? (
<div className="flex h-full items-center justify-center text-[#64748b]">
<Loader2 className="mr-2 animate-spin" size={18} aria-hidden="true" />
<span className="text-[13px] font-bold">문의 목록을 불러오는 중입니다.</span>
</div>
) : error ? (
<EmptyState title="채팅 서버 연결이 필요합니다." description={error} />
) : threads.length > 0 ? (
<div className="space-y-2.5">
{threads.map((thread) => (
<ThreadCard key={thread.id} accessToken={session.accessToken} thread={thread} onOpen={() => handleOpenThread(thread.id)} />
))}
</div>
) : (
<EmptyState title="대기 중인 문의가 없습니다." description="수강생이 강사 문의를 남기면 이곳에 표시됩니다." />
)}
</div>
</>
)}
</aside>
);
}
function ThreadCard({ accessToken, thread, onOpen }: { accessToken: string; thread: InstructorChatThread; onOpen: () => void }) {
return (
<button
type="button"
className="group w-full cursor-pointer rounded-lg border border-[#c7d2e0] bg-white px-4 py-3 text-left shadow-[0_10px_28px_rgba(15,23,42,0.05)] outline-none transition hover:border-[#818cf8] hover:bg-[#f8faff] hover:shadow-[0_16px_36px_rgba(79,70,229,0.14)] focus-visible:border-[#4f46e5] focus-visible:ring-2 focus-visible:ring-[#c7d2fe] active:bg-[#eef2ff]"
role="button"
onClick={onOpen}
>
<div className="flex items-start gap-3">
<StudentAvatar accessToken={accessToken} thread={thread} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="truncate text-[14px] font-black text-[#111827]">{thread.studentName}</p>
{thread.status === 'WAITING_INSTRUCTOR' ? <StatusBadge status={thread.status} /> : null}
</div>
<p className="mt-1 line-clamp-2 text-[12px] font-bold leading-5 text-[#64748b]">{thread.lastMessage}</p>
<div className="mt-3 flex items-center gap-2 text-[11px] font-bold text-[#94a3b8]">
{thread.groupName ? <span>{thread.groupName}</span> : null}
{thread.week ? <span>{thread.week}주차</span> : null}
{thread.lastMessageAt ? <span>{formatDate(thread.lastMessageAt)}</span> : null}
</div>
</div>
<span className="mt-1 grid h-8 w-8 shrink-0 place-items-center rounded-md text-[#94a3b8] transition group-hover:bg-[#eef2ff] group-hover:text-[#4f46e5]">
<ChevronRight size={17} aria-hidden="true" />
</span>
</div>
</button>
);
}
function StudentAvatar({ accessToken, thread }: { accessToken: string; thread: InstructorChatThread }) {
const [avatarSource, setAvatarSource] = useState<string | null>(null);
useEffect(() => {
if (!thread.studentAvatarUrl) {
setAvatarSource(null);
return;
}
let revoked = false;
let objectUrl: string | null = null;
fetch(thread.studentAvatarUrl, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
.then((response) => (response.ok ? response.blob() : null))
.then((blob) => {
if (!blob || revoked) return;
objectUrl = URL.createObjectURL(blob);
setAvatarSource(objectUrl);
})
.catch(() => {
if (!revoked) setAvatarSource(null);
});
return () => {
revoked = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [accessToken, thread.studentAvatarUrl]);
return (
<span className="grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-full bg-[#eef2ff] text-[#4f46e5] transition group-hover:bg-[#4f46e5] group-hover:text-white">
{avatarSource ? (
<img className="h-full w-full object-cover" src={avatarSource} alt="" />
) : (
<UserRound size={18} aria-hidden="true" />
)}
</span>
);
}
function ChatRoom({
thread,
loading,
onBack,
onClose,
onSend,
}: {
thread: InstructorChatThread;
loading: boolean;
onBack: () => void;
onClose: () => void;
onSend: (threadId: string, content: string) => Promise<void>;
}) {
const messages = thread.messages.length > 0 ? thread.messages : createFallbackMessages(thread);
const [draft, setDraft] = useState('');
const [sending, setSending] = useState(false);
const [sendError, setSendError] = useState<string | null>(null);
const messageScrollRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const target = messageScrollRef.current;
if (!target || loading) return;
target.scrollTo({
top: target.scrollHeight,
behavior: 'smooth',
});
}, [loading, messages.length, thread.id]);
const handleSubmit = async () => {
const content = draft.trim();
if (!content || sending) return;
setSending(true);
setSendError(null);
try {
await onSend(thread.id, content);
setDraft('');
} catch (reason) {
setSendError(reason instanceof Error ? reason.message : '강사 답변을 저장하지 못했습니다.');
} finally {
setSending(false);
}
};
return (
<>
<div className="flex h-[68px] items-center gap-2 border-b border-[#cbd5e1] px-4">
<button className="grid h-9 w-9 place-items-center rounded-md text-[#64748b] hover:bg-[#f8fafc] hover:text-[#111827]" type="button" aria-label="목록으로 돌아가기" title="목록으로 돌아가기" onClick={onBack}>
<ArrowLeft size={17} aria-hidden="true" />
</button>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="truncate text-[15px] font-black text-[#0f172a]">{thread.studentName}</p>
{thread.status === 'WAITING_INSTRUCTOR' ? <StatusBadge status={thread.status} /> : null}
</div>
<p className="mt-1 truncate text-[12px] font-bold text-[#64748b]">
{[thread.groupName, thread.week ? `${thread.week}주차` : null].filter(Boolean).join(' · ') || '강사 문의'}
</p>
</div>
<button className="grid h-9 w-9 place-items-center rounded-md text-[#64748b] hover:bg-[#f8fafc] hover:text-[#111827]" type="button" aria-label="채팅 닫기" title="채팅 닫기" onClick={onClose}>
<X size={17} aria-hidden="true" />
</button>
</div>
<div ref={messageScrollRef} className="flex-1 overflow-y-auto overscroll-contain px-5 py-5">
{loading ? (
<div className="flex h-full items-center justify-center text-[#64748b]">
<Loader2 className="mr-2 animate-spin" size={18} aria-hidden="true" />
<span className="text-[13px] font-bold">채팅방을 불러오는 중입니다.</span>
</div>
) : (
<div className="space-y-3">
{messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
</div>
)}
</div>
<div className="border-t border-[#cbd5e1] bg-white/86 p-4">
<div>
<div className="flex overflow-hidden rounded-lg border border-[#dbe3ef] bg-[#f8fafc]">
<textarea
className="h-[82px] min-w-0 flex-1 resize-none bg-transparent px-3 py-2 text-[13px] font-bold leading-5 text-[#111827] outline-none placeholder:text-[#94a3b8]"
value={draft}
placeholder="수강생에게 답변을 입력하세요."
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
void handleSubmit();
}
}}
/>
<button
className="grid w-12 shrink-0 place-items-center bg-[#4f46e5] text-white hover:bg-[#4338ca] disabled:bg-[#cbd5e1]"
type="button"
disabled={!draft.trim() || sending}
onClick={handleSubmit}
aria-label={sending ? '전송 중' : '전송'}
title={sending ? '전송 중' : '전송'}
>
{sending ? <Loader2 className="animate-spin" size={16} aria-hidden="true" /> : <Send size={16} aria-hidden="true" />}
</button>
</div>
{sendError ? <p className="mt-1 text-[11px] font-bold text-[#e11d48]">{sendError}</p> : null}
</div>
</div>
</>
);
}
function MessageBubble({ message }: { message: InstructorChatMessage }) {
const isInstructor = message.senderType === 'INSTRUCTOR';
const isStudent = message.senderType === 'STUDENT';
const senderLabel = message.senderName?.trim() || getSenderLabel(message.senderType);
return (
<div className={`flex ${isInstructor ? 'justify-end' : 'justify-start'}`}>
<div
className={`max-w-[82%] rounded-lg px-3.5 py-2.5 ${
isInstructor
? 'bg-[#4f46e5] text-white'
: isStudent
? 'border border-[#c7d2e0] bg-white text-[#111827]'
: 'border border-[#e0e7ff] bg-[#eef2ff] text-[#3730a3]'
}`}
>
<div className="mb-1 flex items-center gap-2">
<span className={`text-[10px] font-black ${isInstructor ? 'text-white/78' : 'text-[#64748b]'}`}>{senderLabel}</span>
{message.createdAt ? <span className={`text-[10px] font-bold ${isInstructor ? 'text-white/58' : 'text-[#94a3b8]'}`}>{formatDate(message.createdAt)}</span> : null}
</div>
<p className="whitespace-pre-wrap text-[13px] font-bold leading-5">{message.content}</p>
</div>
</div>
);
}
function EmptyState({ title, description }: { title: string; description: string }) {
return (
<div className="flex h-full flex-col items-center justify-center rounded-lg border border-dashed border-[#cbd5e1] bg-white/58 px-6 text-center">
<span className="mb-3 grid h-11 w-11 place-items-center rounded-full bg-[#eef2ff] text-[#4f46e5]">
<MessageCircle size={20} aria-hidden="true" />
</span>
<p className="text-[14px] font-black text-[#111827]">{title}</p>
<p className="mt-2 text-[12px] font-bold leading-5 text-[#64748b]">{description}</p>
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const label = getStatusLabel(status);
const tone = status === 'WAITING_INSTRUCTOR' ? 'bg-[#fff1f2] text-[#be123c] border-[#fecdd3]' : 'bg-[#eef2ff] text-[#4f46e5] border-[#c7d2fe]';
return <span className={`shrink-0 rounded-full border px-2 py-0.5 text-[10px] font-black ${tone}`}>{label}</span>;
}
function getStatusLabel(status: string): string {
if (status === 'WAITING_INSTRUCTOR') return '답변 대기';
if (status === 'OPEN') return '진행 중';
if (status === 'RESOLVED') return '완료';
if (status === 'ARCHIVED') return '보관';
return status;
}
function getSenderLabel(senderType: string): string {
if (senderType === 'STUDENT') return '수강생';
if (senderType === 'INSTRUCTOR') return '강사';
if (senderType === 'AI') return 'AI';
return '시스템';
}
function createFallbackMessages(thread: InstructorChatThread): InstructorChatMessage[] {
return [
{
id: `${thread.id}-last`,
senderType: 'STUDENT',
content: thread.lastMessage,
createdAt: thread.lastMessageAt,
},
];
}
function replaceThread(threads: InstructorChatThread[], thread: InstructorChatThread): InstructorChatThread[] {
const exists = threads.some((item) => item.id === thread.id);
if (!exists) return [thread, ...threads];
return threads.map((item) => (item.id === thread.id ? thread : item));
}
function formatDate(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat('ko-KR', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(date);
}
+21
View File
@@ -0,0 +1,21 @@
import type { AppRoute } from '../routes';
import { pageMeta } from '../pageMeta';
type PageHeaderProps = {
route: AppRoute;
action?: React.ReactNode;
};
export function PageHeader({ route, action }: PageHeaderProps) {
const meta = pageMeta[route];
return (
<div className={`mb-7 ${action ? 'flex flex-wrap items-end justify-between gap-4' : ''}`}>
<div>
<h1 className="text-[24px] font-black tracking-[0] text-[#0f172a]">{meta.title}</h1>
<p className="mt-2 text-[13px] font-bold text-[#64748b]">{meta.description}</p>
</div>
{action}
</div>
);
}
+72
View File
@@ -0,0 +1,72 @@
import { ChevronLeft, ChevronRight } from 'lucide-react';
type PaginationBarProps = {
totalLabel: string;
page: number;
pageSize: number;
totalItems: number;
onPageChange: (page: number) => void;
onPageSizeChange: (pageSize: number) => void;
};
const pageSizeOptions = [10, 20, 50];
export function PaginationBar({ totalLabel, page, pageSize, totalItems, onPageChange, onPageSizeChange }: PaginationBarProps) {
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
const pages = Array.from({ length: Math.min(totalPages, 5) }, (_, index) => {
if (totalPages <= 5) return index + 1;
const start = Math.min(Math.max(page - 2, 1), totalPages - 4);
return start + index;
});
return (
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-[#cbd5e1] px-4 pb-3 pt-6">
<p className="text-[12px] font-bold text-[#64748b]">{totalLabel}</p>
<div className="flex items-center gap-1.5">
<button
className="grid h-8 w-8 place-items-center rounded-md border border-[#d1d9e6] bg-white text-[#94a3b8] hover:bg-[#f8fafc] disabled:cursor-default disabled:opacity-35"
type="button"
aria-label="이전 페이지"
disabled={page <= 1}
onClick={() => onPageChange(Math.max(1, page - 1))}
>
<ChevronLeft size={15} aria-hidden="true" />
</button>
{pages.map((item) => (
<button
key={item}
className={`grid h-8 w-8 place-items-center rounded-md text-[12px] font-bold ${
item === page ? 'bg-[#4f46e5] text-white' : 'border border-[#d1d9e6] bg-white text-[#64748b] hover:bg-[#f8fafc]'
}`}
type="button"
aria-current={item === page ? 'page' : undefined}
onClick={() => onPageChange(item)}
>
{item}
</button>
))}
<button
className="grid h-8 w-8 place-items-center rounded-md border border-[#d1d9e6] bg-white text-[#94a3b8] hover:bg-[#f8fafc] disabled:cursor-default disabled:opacity-35"
type="button"
aria-label="다음 페이지"
disabled={page >= totalPages}
onClick={() => onPageChange(Math.min(totalPages, page + 1))}
>
<ChevronRight size={15} aria-hidden="true" />
</button>
</div>
<select
aria-label="페이지당 표시 개수"
className="h-8 rounded-md border border-[#d1d9e6] bg-white px-2 text-[12px] font-bold text-[#64748b] outline-none hover:bg-[#f8fafc] focus:border-[#4f46e5] focus:ring-2 focus:ring-[#4f46e5]/10"
value={pageSize}
onChange={(event) => onPageSizeChange(Number(event.target.value))}
>
{pageSizeOptions.map((option) => (
<option key={option} value={option}>{option}개씩 보기</option>
))}
</select>
</div>
);
}
+88
View File
@@ -0,0 +1,88 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { CheckCircle2, CircleAlert, X } from 'lucide-react';
type Toast = {
id: number;
title: string;
message?: string;
variant?: 'success' | 'error';
entering?: boolean;
closing?: boolean;
};
type ToastContextValue = {
showToast: (toast: Omit<Toast, 'id'>) => void;
};
const ToastContext = createContext<ToastContextValue | null>(null);
const TOAST_DURATION_MS = 2800;
const TOAST_EXIT_MS = 180;
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const removeToast = useCallback((id: number) => {
setToasts((current) => current.map((toast) => (toast.id === id ? { ...toast, closing: true } : toast)));
window.setTimeout(() => {
setToasts((current) => current.filter((toast) => toast.id !== id));
}, TOAST_EXIT_MS);
}, []);
const showToast = useCallback((toast: Omit<Toast, 'id'>) => {
const id = Date.now() + Math.floor(Math.random() * 1000);
setToasts((current) => [...current, { ...toast, id, entering: true }]);
window.setTimeout(() => {
setToasts((current) => current.map((item) => (item.id === id ? { ...item, entering: false } : item)));
}, 20);
}, []);
const value = useMemo(() => ({ showToast }), [showToast]);
return (
<ToastContext.Provider value={value}>
{children}
{createPortal(
<div className="fixed bottom-5 right-5 z-[80] flex w-[calc(100vw-40px)] max-w-[360px] flex-col gap-2">
{toasts.map((toast) => (
<ToastItem key={toast.id} toast={toast} onClose={() => removeToast(toast.id)} />
))}
</div>,
document.body,
)}
</ToastContext.Provider>
);
}
export function useToast() {
const context = useContext(ToastContext);
if (!context) throw new Error('useToast must be used within ToastProvider');
return context;
}
function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
useEffect(() => {
const timer = window.setTimeout(onClose, TOAST_DURATION_MS);
return () => window.clearTimeout(timer);
}, [onClose]);
const Icon = toast.variant === 'error' ? CircleAlert : CheckCircle2;
const iconTone = toast.variant === 'error' ? 'bg-[#e11d48] text-white' : 'bg-[#4f46e5] text-white';
return (
<div className={`rounded-lg border border-[#1e293b] bg-[#111827] px-4 py-3 shadow-[0_22px_54px_rgba(15,23,42,0.32)] transition duration-180 ease-out ${toast.closing || toast.entering ? 'translate-y-2 opacity-0' : 'translate-y-0 opacity-100'}`}>
<div className="flex items-start gap-3">
<span className={`mt-0.5 grid h-8 w-8 shrink-0 place-items-center rounded-md ${iconTone}`}>
<Icon size={17} aria-hidden="true" />
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-[13px] font-black text-white">{toast.title}</p>
{toast.message ? <p className="mt-1 truncate text-[12px] font-bold text-[#cbd5e1]">{toast.message}</p> : null}
</div>
<button className="grid h-7 w-7 shrink-0 place-items-center rounded-md text-[#cbd5e1] hover:bg-white/10 hover:text-white" type="button" onClick={onClose} aria-label="토스트 닫기" title="토스트 닫기">
<X size={14} aria-hidden="true" />
</button>
</div>
</div>
);
}
+115
View File
@@ -0,0 +1,115 @@
import { useCallback, useEffect, useState } from 'react';
import { fetchProfileAvatar } from '../lib/profileAvatar';
type ProfileAvatarState = {
avatarObjectUrl: string | null;
avatarMissing: boolean;
avatarLoading: boolean;
refreshAvatar: () => void;
setAvatarMissing: (missing: boolean) => void;
};
export function useProfileAvatar(accessToken: string): ProfileAvatarState {
const [avatarVersion, setAvatarVersion] = useState(() => Date.now());
const [avatarObjectUrl, setAvatarObjectUrl] = useState<string | null>(null);
const [avatarMissing, setAvatarMissing] = useState(false);
const [avatarLoading, setAvatarLoading] = useState(false);
const refreshAvatar = useCallback(() => {
setAvatarMissing(false);
setAvatarVersion(Date.now());
}, []);
useEffect(() => {
const handleAvatarUpdated = () => refreshAvatar();
const handleAvatarDeleted = () => {
setAvatarObjectUrl((previous) => {
if (previous) URL.revokeObjectURL(previous);
return null;
});
setAvatarMissing(true);
setAvatarLoading(false);
};
window.addEventListener('profile-avatar-updated', handleAvatarUpdated);
window.addEventListener('profile-avatar-deleted', handleAvatarDeleted);
return () => {
window.removeEventListener('profile-avatar-updated', handleAvatarUpdated);
window.removeEventListener('profile-avatar-deleted', handleAvatarDeleted);
};
}, [refreshAvatar]);
useEffect(() => {
let cancelled = false;
let nextObjectUrl: string | null = null;
const loadAvatar = async () => {
if (!accessToken) {
setAvatarObjectUrl((previous) => {
if (previous) URL.revokeObjectURL(previous);
return null;
});
setAvatarMissing(true);
return;
}
setAvatarLoading(true);
try {
const blob = await fetchProfileAvatar(accessToken, avatarVersion);
if (!blob) {
if (!cancelled) {
setAvatarObjectUrl((previous) => {
if (previous) URL.revokeObjectURL(previous);
return null;
});
setAvatarMissing(true);
}
return;
}
nextObjectUrl = URL.createObjectURL(blob);
if (!cancelled) {
setAvatarObjectUrl((previous) => {
if (previous) URL.revokeObjectURL(previous);
return nextObjectUrl;
});
setAvatarMissing(false);
}
} catch {
if (!cancelled) {
setAvatarObjectUrl((previous) => {
if (previous) URL.revokeObjectURL(previous);
return null;
});
setAvatarMissing(true);
}
} finally {
if (!cancelled) setAvatarLoading(false);
}
};
void loadAvatar();
return () => {
cancelled = true;
if (nextObjectUrl) URL.revokeObjectURL(nextObjectUrl);
};
}, [accessToken, avatarVersion]);
useEffect(() => {
return () => {
setAvatarObjectUrl((previous) => {
if (previous) URL.revokeObjectURL(previous);
return null;
});
};
}, []);
return {
avatarObjectUrl,
avatarMissing,
avatarLoading,
refreshAvatar,
setAvatarMissing,
};
}
+189
View File
@@ -0,0 +1,189 @@
@import "tailwindcss";
@font-face {
font-family: "NanumSquare";
src: url("/assets/fonts/NanumSquareL.woff2") format("woff2");
font-weight: 300;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "NanumSquare";
src: url("/assets/fonts/NanumSquare.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "NanumSquare";
src: url("/assets/fonts/NanumSquareB.woff2") format("woff2");
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "NanumSquare";
src: url("/assets/fonts/NanumSquareEB.woff2") format("woff2");
font-weight: 800 900;
font-style: normal;
font-display: swap;
}
@theme {
--font-sans: "NanumSquare", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
@layer base {
:root {
color: #17171f;
background: #fafafa;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
scrollbar-gutter: stable;
}
* {
scrollbar-width: thin;
scrollbar-color: #cbd5e1 transparent;
}
*::-webkit-scrollbar {
width: 6px;
height: 6px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 999px;
}
*::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
body {
min-width: 320px;
min-height: 100vh;
margin: 0;
font-family: var(--font-sans);
}
button {
cursor: pointer;
}
input[type="password"] {
color: #111827;
caret-color: #111827;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
-webkit-text-fill-color: #111827;
}
input::placeholder {
color: #a0a7b7;
font-family: var(--font-sans);
font-weight: 700;
}
input[type="password"]::placeholder {
color: #a0a7b7;
font-family: var(--font-sans);
font-weight: 700;
-webkit-text-fill-color: #a0a7b7;
}
}
@layer components {
.icon-ripple-button {
transition: color 150ms ease;
}
.icon-ripple-button::after {
position: absolute;
inset: 5px;
content: "";
pointer-events: none;
border: 1px solid rgba(79, 70, 229, 0.42);
border-radius: 9999px;
opacity: 0;
transform: scale(0.72);
}
.icon-ripple-button:active::after {
animation: icon-ripple 340ms ease-out;
}
.icon-chat-blink {
animation: icon-chat-blink 1.05s ease-in-out infinite;
}
@keyframes icon-ripple {
0% {
opacity: 0.42;
transform: scale(0.72);
}
100% {
opacity: 0;
transform: scale(1.75);
}
}
@keyframes icon-chat-blink {
0% {
opacity: 1;
filter: drop-shadow(0 0 0 rgba(37, 99, 235, 0));
}
45% {
opacity: 0.34;
filter: drop-shadow(0 0 7px rgba(37, 99, 235, 0.82));
}
100% {
opacity: 1;
filter: drop-shadow(0 0 0 rgba(37, 99, 235, 0));
}
}
.page-transition {
will-change: opacity, transform;
transition:
opacity 120ms ease,
transform 120ms ease;
}
.page-transition-in {
opacity: 1;
transform: translateY(0);
}
.page-transition-out {
opacity: 0;
transform: translateY(4px);
}
}
@media (prefers-reduced-motion: reduce) {
.icon-ripple-button:active::after {
animation: none;
}
.icon-chat-blink {
animation: none;
}
.page-transition {
transition: none;
transform: none;
}
}
+797
View File
@@ -0,0 +1,797 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
Bell,
BookOpenText,
Building2,
Camera,
CheckCheck,
ChevronDown,
ChevronLeft,
ChevronRight,
ExternalLink,
Loader2,
LogOut,
MessageCircle,
Search,
Settings,
ShieldCheck,
Trash2,
X,
type LucideIcon,
} from 'lucide-react';
import { io } from 'socket.io-client';
import type { Socket } from 'socket.io-client';
import { ConfirmDialog } from '../components/ConfirmDialog';
import { InstructorChatPanel } from '../components/InstructorChatPanel';
import { useToast } from '../components/ToastProvider';
import autonomyLogo from '../assets/icon.png';
import { useProfileAvatar } from '../hooks/useProfileAvatar';
import { CHAT_SOCKET_BASE_URL } from '../lib/chat';
import type { InstructorCourse } from '../lib/courses';
import { deleteProfileAvatar, notifyProfileAvatarDeleted, notifyProfileAvatarUpdated, resizeImageToWebp, uploadProfileAvatar } from '../lib/profileAvatar';
import { navItems, type AppRoute } from '../routes';
import type { AuthSession } from '../types/auth';
type InstructorLayoutProps = {
session: AuthSession;
route: AppRoute;
onNavigate: (route: AppRoute) => void;
onLogout: () => void;
courses: InstructorCourse[];
selectedCourseId: string;
onSelectCourse: (courseId: string) => void;
chatThreadId?: string | null;
chatThreadRequestId?: number;
children: React.ReactNode;
};
type NotificationMessage = {
id: string;
title: string;
body: string;
category: string;
categoryLabel?: string;
audience?: string;
createdAt: string;
date: string;
read: boolean;
};
type ChatNotificationPayload = {
type: 'chat.message.created';
threadId: string;
messageId: string;
senderType: 'STUDENT' | 'AI' | 'INSTRUCTOR' | 'SYSTEM';
preview: string;
createdAt: string;
};
const NOTIFICATION_API_BASE_URL = (import.meta.env.VITE_LAUNCHER_API_BASE_URL ?? 'http://localhost:4200/api').replace(/\/+$/, '');
const NOTIFICATION_DROPDOWN_LIMIT = 10;
const NOTIFICATION_DRAWER_PAGE_SIZE = 8;
export function InstructorLayout({ session, route, onNavigate, onLogout, courses, selectedCourseId, onSelectCourse, chatThreadId = null, chatThreadRequestId = 0, children }: InstructorLayoutProps) {
const [profileOpen, setProfileOpen] = useState(false);
const [notificationOpen, setNotificationOpen] = useState(false);
const [notificationDrawerOpen, setNotificationDrawerOpen] = useState(false);
const [notificationQuery, setNotificationQuery] = useState('');
const [notificationTab, setNotificationTab] = useState<'unread' | 'read'>('unread');
const [notificationPage, setNotificationPage] = useState(1);
const [chatOpen, setChatOpen] = useState(false);
const [hasUnreadChat, setHasUnreadChat] = useState(false);
const [notifications, setNotifications] = useState<NotificationMessage[]>([]);
const profileRef = useRef<HTMLDivElement | null>(null);
const avatarInputRef = useRef<HTMLInputElement | null>(null);
const { showToast } = useToast();
const [avatarUploading, setAvatarUploading] = useState(false);
const [avatarDeleting, setAvatarDeleting] = useState(false);
const [avatarDeleteConfirmOpen, setAvatarDeleteConfirmOpen] = useState(false);
const displayName = getDisplayName(session);
const affiliation = getAffiliation(session);
const roleLabel = getRoleLabel(session.user.role);
const { avatarObjectUrl, avatarMissing, avatarLoading, refreshAvatar, setAvatarMissing } = useProfileAvatar(session.accessToken);
const hasAvatar = !avatarMissing && Boolean(avatarObjectUrl);
const avatarBusy = avatarLoading || avatarUploading || avatarDeleting;
const unreadCount = notifications.filter((notification) => !notification.read).length;
const hasUnreadNotifications = unreadCount > 0;
const dropdownNotifications = notifications.slice(0, NOTIFICATION_DROPDOWN_LIMIT);
const filteredDrawerNotifications = useMemo(() => {
const query = notificationQuery.trim().toLowerCase();
return notifications.filter((notification) => {
if (notificationTab === 'unread' && notification.read) return false;
if (notificationTab === 'read' && !notification.read) return false;
if (!query) return true;
return [notification.title, notification.body, notification.categoryLabel ?? notification.category]
.join(' ')
.toLowerCase()
.includes(query);
});
}, [notificationQuery, notificationTab, notifications]);
const notificationTotalPages = Math.max(1, Math.ceil(filteredDrawerNotifications.length / NOTIFICATION_DRAWER_PAGE_SIZE));
const drawerNotifications = filteredDrawerNotifications.slice(
(notificationPage - 1) * NOTIFICATION_DRAWER_PAGE_SIZE,
notificationPage * NOTIFICATION_DRAWER_PAGE_SIZE,
);
useEffect(() => {
const handlePointerDown = (event: PointerEvent) => {
if (!profileRef.current?.contains(event.target as Node)) {
setProfileOpen(false);
setNotificationOpen(false);
}
};
window.addEventListener('pointerdown', handlePointerDown);
return () => window.removeEventListener('pointerdown', handlePointerDown);
}, []);
useEffect(() => {
let cancelled = false;
fetch(`${NOTIFICATION_API_BASE_URL}/notifications?appKey=LMS&audience=instructor&page=1&pageSize=100`, {
headers: {
Authorization: `Bearer ${session.accessToken}`,
},
})
.then((response) => (response.ok ? response.json() : null))
.then((payload: { notifications?: NotificationMessage[] } | null) => {
if (!cancelled) {
setNotifications((payload?.notifications ?? []).map((notification) => ({ ...notification, read: Boolean(notification.read) })));
}
})
.catch(() => {
if (!cancelled) setNotifications([]);
});
return () => {
cancelled = true;
};
}, [session.accessToken]);
useEffect(() => {
setNotificationPage(1);
}, [notificationQuery, notificationTab]);
useEffect(() => {
if (notificationPage > notificationTotalPages) setNotificationPage(notificationTotalPages);
}, [notificationPage, notificationTotalPages]);
useEffect(() => {
const socket: Socket = io(CHAT_SOCKET_BASE_URL, {
auth: { token: session.accessToken },
transports: ['websocket', 'polling'],
});
const handleMessageCreated = (payload: ChatNotificationPayload) => {
if (payload.senderType === 'STUDENT' && !chatOpen) setHasUnreadChat(true);
};
socket.on('chat.message.created', handleMessageCreated);
return () => {
socket.off('chat.message.created', handleMessageCreated);
socket.disconnect();
};
}, [chatOpen, session.accessToken]);
useEffect(() => {
if (chatOpen) setHasUnreadChat(false);
}, [chatOpen]);
const handleNotificationClick = () => {
setNotificationOpen((current) => !current);
setProfileOpen(false);
setChatOpen(false);
setNotificationDrawerOpen(false);
};
const handleChatClick = () => {
setChatOpen((current) => !current);
setHasUnreadChat(false);
setNotificationOpen(false);
setNotificationDrawerOpen(false);
setProfileOpen(false);
};
useEffect(() => {
if (!chatThreadId) return;
setChatOpen(true);
setHasUnreadChat(false);
setNotificationOpen(false);
setNotificationDrawerOpen(false);
setProfileOpen(false);
}, [chatThreadId, chatThreadRequestId]);
const handleOpenNotificationDrawer = () => {
setNotificationDrawerOpen(true);
setNotificationOpen(false);
setChatOpen(false);
setProfileOpen(false);
};
const handleMarkAllNotificationsRead = async () => {
if (!session.accessToken || unreadCount === 0) return;
try {
const response = await fetch(`${NOTIFICATION_API_BASE_URL}/notifications/read-all`, {
method: 'POST',
headers: {
Authorization: `Bearer ${session.accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
appKey: 'LMS',
audience: 'instructor',
}),
});
if (!response.ok) throw new Error('mark_read_failed');
setNotifications((current) => current.map((notification) => ({ ...notification, read: true })));
} catch {
// 다음 조회에서 서버 상태와 다시 동기화된다.
}
};
const handleMarkNotificationRead = async (notificationId: string) => {
const target = notifications.find((notification) => notification.id === notificationId);
if (!session.accessToken || !target || target.read) return;
setNotifications((current) =>
current.map((notification) => (notification.id === notificationId ? { ...notification, read: true } : notification)),
);
try {
const response = await fetch(`${NOTIFICATION_API_BASE_URL}/notifications/${notificationId}/read`, {
method: 'POST',
headers: {
Authorization: `Bearer ${session.accessToken}`,
},
});
if (!response.ok) throw new Error('mark_read_failed');
} catch {
setNotifications((current) =>
current.map((notification) => (notification.id === notificationId ? { ...notification, read: false } : notification)),
);
}
};
const handleAvatarSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
setAvatarUploading(true);
try {
const imageBlob = await resizeImageToWebp(file, 128);
await uploadProfileAvatar(session.accessToken, imageBlob);
refreshAvatar();
notifyProfileAvatarUpdated();
showToast({ title: '프로필 이미지가 변경되었습니다.', variant: 'success' });
} catch (error) {
showToast({
title: '프로필 이미지 변경 실패',
message: error instanceof Error ? error.message : '이미지를 다시 확인해주세요.',
variant: 'error',
});
} finally {
setAvatarUploading(false);
}
};
const handleAvatarDelete = async () => {
setAvatarDeleting(true);
try {
await deleteProfileAvatar(session.accessToken);
setAvatarMissing(true);
notifyProfileAvatarDeleted();
setAvatarDeleteConfirmOpen(false);
showToast({ title: '프로필 이미지가 삭제되었습니다.', variant: 'success' });
} catch {
showToast({ title: '프로필 이미지 삭제 실패', message: '잠시 후 다시 시도해주세요.', variant: 'error' });
} finally {
setAvatarDeleting(false);
}
};
return (
<div className="relative min-h-screen overflow-hidden bg-[#f7f9ff] text-[#111827]">
<div className="pointer-events-none fixed inset-x-0 top-0 h-[260px] bg-[radial-gradient(circle_at_42%_36%,rgba(124,58,237,0.13),transparent_22%),radial-gradient(circle_at_80%_28%,rgba(37,99,235,0.12),transparent_24%),linear-gradient(180deg,rgba(239,246,255,0.94)_0%,rgba(247,249,255,0)_100%)] lg:left-[252px]" />
<div className="pointer-events-none fixed inset-x-0 bottom-0 h-[420px] bg-[linear-gradient(0deg,rgba(124,58,237,0.14)_0%,rgba(167,139,250,0.07)_44%,rgba(247,249,255,0)_100%)] lg:left-[252px]" />
<div className="pointer-events-none fixed right-[8%] top-[78px] h-2 w-2 rounded-full bg-[#93c5fd]/70" />
<div className="pointer-events-none fixed left-[53%] top-[92px] h-3 w-3 rounded-full bg-[#c4b5fd]/70" />
<header className="fixed right-0 top-0 z-40 flex h-[60px] items-center border-b border-[#94a3b8]/55 bg-white/[0.04] shadow-[inset_0_1px_0_rgba(255,255,255,0.28)] backdrop-blur-[4px] lg:left-[252px]">
<div className="min-w-0 flex-1" />
<div className="relative flex h-full items-center gap-2 px-6" ref={profileRef}>
<button
className={`icon-ripple-button relative grid h-9 w-9 cursor-pointer place-items-center rounded-md ${
notificationOpen ? 'text-[#4f46e5]' : 'text-[#64748b] hover:text-[#111827]'
}`}
type="button"
aria-label="알림"
aria-expanded={notificationOpen}
onClick={handleNotificationClick}
>
<Bell
size={17}
aria-hidden="true"
className={notificationOpen ? 'fill-[#4f46e5] stroke-[#4f46e5]' : ''}
/>
{hasUnreadNotifications ? <span className="absolute right-2 top-2 h-1.5 w-1.5 rounded-full bg-[#e11d48]" /> : null}
</button>
{notificationOpen ? (
<div className="absolute right-[176px] top-[48px] w-[360px] overflow-hidden rounded-lg border border-[#dfe5ee] bg-white shadow-[0_18px_50px_rgba(15,23,42,0.14)]">
<div className="border-b border-[#cbd5e1] px-4 py-3">
<div className="flex items-center justify-between">
<p className="text-[13px] font-black text-[#111827]">알림</p>
<span className="text-[11px] font-bold text-[#64748b]">안읽음 {unreadCount}건</span>
</div>
<div className="mt-3 flex items-center justify-end gap-2">
<button
className="inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-md border border-[#dbe3ef] px-2.5 text-[11px] font-bold text-[#64748b] hover:bg-[#f8fafc] hover:text-[#111827] disabled:cursor-default disabled:opacity-40"
type="button"
disabled={unreadCount === 0}
onClick={() => void handleMarkAllNotificationsRead()}
>
<CheckCheck size={13} aria-hidden="true" />
모두 읽음
</button>
<button
className="h-7 cursor-pointer rounded-md border border-[#c7d2fe] bg-[#eef2ff] px-2.5 text-[11px] font-black text-[#4f46e5] hover:bg-[#e0e7ff]"
type="button"
onClick={handleOpenNotificationDrawer}
>
전체 보기
</button>
</div>
</div>
<div className="max-h-[320px] overflow-y-auto pb-3">
{dropdownNotifications.length > 0 ? (
dropdownNotifications.map((notification, index) => (
<NotificationRow
key={notification.id}
notification={notification}
bordered={index < dropdownNotifications.length - 1}
onClick={() => void handleMarkNotificationRead(notification.id)}
/>
))
) : (
<div className="px-4 py-8 text-center">
<p className="text-[13px] font-bold text-[#64748b]">표시할 알림이 없습니다.</p>
</div>
)}
</div>
</div>
) : null}
<button
className={`icon-ripple-button relative grid h-9 w-9 cursor-pointer place-items-center rounded-md ${
chatOpen || hasUnreadChat ? 'text-[#2563eb]' : 'text-[#64748b] hover:text-[#111827]'
}`}
type="button"
aria-label="강사 채팅"
aria-expanded={chatOpen}
onClick={handleChatClick}
>
<MessageCircle
size={17}
aria-hidden="true"
className={[
chatOpen || hasUnreadChat ? 'fill-[#2563eb] stroke-[#2563eb] text-white' : '',
hasUnreadChat && !chatOpen ? 'icon-chat-blink' : ''
].join(' ')}
/>
</button>
<button
className="flex h-10 items-center gap-1.5 rounded-md px-2 text-left hover:bg-[#f8fafc] cursor-pointer"
type="button"
aria-expanded={profileOpen}
onClick={() => setProfileOpen((current) => !current)}
>
<div className="hidden min-w-0 text-right sm:block">
<p className="truncate text-[13px] font-extrabold text-[#1f2937]">
{displayName} <span className="font-semibold text-[#64748b]">님</span>
</p>
</div>
<ChevronDown className={`text-[#94a3b8] transition-transform ${profileOpen ? 'rotate-180' : ''}`} size={14} aria-hidden="true" />
</button>
{profileOpen ? (
<div className="absolute right-6 top-[48px] w-[292px] overflow-hidden rounded-lg border border-[#dfe5ee] bg-white shadow-[0_18px_50px_rgba(15,23,42,0.14)]">
<div className="border-b border-[#cbd5e1] px-4 py-4">
<div className="flex items-center gap-3">
<div className="relative h-12 w-12 shrink-0">
<span className="grid h-full w-full place-items-center overflow-hidden rounded-full border border-[#dbe3ef] bg-[#2563eb] text-[15px] font-extrabold text-white">
{hasAvatar ? (
<img
src={avatarObjectUrl ?? undefined}
alt=""
className="h-full w-full object-cover"
onError={() => setAvatarMissing(true)}
/>
) : (
displayName.slice(0, 1)
)}
</span>
{avatarBusy ? (
<span className="absolute inset-0 grid place-items-center rounded-full bg-white/65 text-[#2563eb] backdrop-blur-[2px]">
<Loader2 size={16} className="animate-spin" aria-hidden="true" />
</span>
) : null}
<input ref={avatarInputRef} type="file" accept="image/*" className="hidden" onChange={handleAvatarSelect} />
<button
type="button"
className="absolute bottom-0 right-0 z-10 grid h-6 w-6 translate-x-1 translate-y-1 cursor-pointer place-items-center rounded-full border border-white bg-[#2563eb] text-white shadow-[0_8px_18px_rgba(37,99,235,0.28)] transition hover:bg-[#1d4ed8] disabled:cursor-not-allowed disabled:opacity-60"
disabled={avatarBusy}
onClick={() => avatarInputRef.current?.click()}
aria-label="프로필 이미지 등록"
>
<Camera size={12} aria-hidden="true" />
</button>
{hasAvatar ? (
<button
type="button"
className="absolute right-0 top-0 z-10 grid h-6 w-6 translate-x-1 -translate-y-1 cursor-pointer place-items-center rounded-full border border-white bg-[#fff1f2] text-[#e11d48] shadow-[0_8px_18px_rgba(225,29,72,0.18)] transition hover:bg-[#ffe4e6] disabled:cursor-not-allowed disabled:opacity-60"
disabled={avatarBusy}
onClick={() => setAvatarDeleteConfirmOpen(true)}
aria-label="프로필 이미지 삭제"
>
<Trash2 size={12} aria-hidden="true" />
</button>
) : null}
</div>
<div className="min-w-0">
<p className="truncate text-[14px] font-black text-[#111827]">{displayName}</p>
</div>
</div>
<div className="mt-4 grid gap-2 text-[12px] font-bold text-[#64748b]">
<ProfileMeta icon={Building2} label={affiliation} />
<ProfileMeta icon={ShieldCheck} label={roleLabel} />
</div>
<label className="mt-4 block">
<span className="text-[11px] font-black text-[#64748b]">강좌 선택</span>
<select
className="mt-1 h-9 w-full rounded-md border border-[#d1d9e6] bg-white px-3 text-[12px] font-bold text-[#111827] outline-none transition focus:border-[#2563eb]"
value={selectedCourseId}
onChange={(event) => onSelectCourse(event.target.value)}
>
{courses.length ? courses.map((course) => (
<option key={course.id} value={course.id}>
{course.title}
</option>
)) : (
<option value="">등록된 강좌 없음</option>
)}
</select>
</label>
</div>
<div className="p-2">
<ProfileAction
icon={Settings}
label="설정"
onClick={() => {
setProfileOpen(false);
onNavigate('settings');
}}
/>
<ProfileAction icon={LogOut} label="로그아웃" danger onClick={onLogout} />
</div>
</div>
) : null}
</div>
</header>
<ConfirmDialog
open={avatarDeleteConfirmOpen}
title="프로필 이미지 삭제"
message="등록된 썸네일 이미지를 삭제하시겠습니까?"
confirmLabel="삭제"
loading={avatarDeleting}
danger
icon={Trash2}
onCancel={() => setAvatarDeleteConfirmOpen(false)}
onConfirm={() => void handleAvatarDelete()}
/>
<InstructorChatPanel
session={session}
open={chatOpen}
initialThreadId={chatThreadId}
initialThreadRequestId={chatThreadRequestId}
onClose={() => setChatOpen(false)}
/>
<NotificationDrawer
notifications={drawerNotifications}
filteredCount={filteredDrawerNotifications.length}
notificationPage={notificationPage}
notificationTab={notificationTab}
notificationTotalPages={notificationTotalPages}
notificationQuery={notificationQuery}
open={notificationDrawerOpen}
unreadCount={unreadCount}
readCount={notifications.length - unreadCount}
onClose={() => setNotificationDrawerOpen(false)}
onMarkAllRead={() => void handleMarkAllNotificationsRead()}
onMarkRead={(notificationId) => void handleMarkNotificationRead(notificationId)}
onPageChange={setNotificationPage}
onQueryChange={setNotificationQuery}
onTabChange={setNotificationTab}
/>
<aside className="fixed bottom-0 left-0 top-0 z-50 hidden w-[252px] overflow-hidden bg-[linear-gradient(180deg,#071126_0%,#0b1630_56%,#101a3a_100%)] text-white lg:flex lg:flex-col">
<div className="flex h-[82px] items-center gap-3 px-5">
<img className="h-8 w-8 rounded-md object-contain" src={autonomyLogo} alt="" />
<div className="min-w-0">
<p className="truncate text-[16px] font-black tracking-[0] text-white">Autonomy <span className="text-[#67e8f9]">Studio</span></p>
<p className="text-[11px] font-semibold leading-tight text-white/68">Instructor Portal</p>
</div>
</div>
<nav className="flex-1 space-y-2 px-5 py-3">
{navItems.map((item) => {
const Icon = item.icon;
const active = item.id === route;
return (
<div key={item.id}>
<button
className={`flex h-11 w-full items-center rounded-lg px-4 text-left text-[13px] transition ${
active
? 'bg-[linear-gradient(135deg,#6366f1_0%,#7c3aed_100%)] font-black text-white shadow-[0_12px_28px_rgba(79,70,229,0.32)]'
: 'font-bold text-white/72 hover:bg-white/8 hover:text-white'
}`}
type="button"
onClick={() => onNavigate(item.id)}
>
<span className="flex min-w-0 items-center gap-3">
<Icon className={active ? 'text-white' : 'text-white/72'} size={16} aria-hidden="true" />
{item.label}
</span>
</button>
</div>
);
})}
</nav>
<div className="px-5 pb-5">
<div className="rounded-lg border border-white/10 bg-white/8 p-3 shadow-[0_18px_46px_rgba(0,0,0,0.16)] backdrop-blur">
<div className="mb-3 flex items-start gap-2.5">
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-md bg-white/12 text-[#8b5cf6]">
<BookOpenText size={15} aria-hidden="true" />
</span>
<div className="min-w-0">
<p className="text-[12px] font-black text-white">도움이 필요하신가요?</p>
<p className="mt-1 text-[11px] font-bold leading-5 text-white/62">가이드를 확인하거나 문의해 보세요.</p>
</div>
</div>
<button className="flex h-8 w-full items-center justify-center gap-2 rounded-md bg-white/10 text-[12px] font-bold text-[#a78bfa] hover:bg-white/14" type="button">
가이드 보기
<ExternalLink size={13} aria-hidden="true" />
</button>
</div>
</div>
</aside>
<main className="relative min-w-0 lg:pl-[252px]">
<div className="relative px-6 pb-24 pt-[100px] lg:px-12 lg:pb-32">{children}</div>
</main>
</div>
);
}
function getDisplayName(session: AuthSession): string {
const name = session.user.name?.trim();
if (name && name !== 'ATNMY Studio Admin') return name;
return session.user.email.split('@')[0] || '강사';
}
function getAffiliation(session: AuthSession): string {
const affiliation = session.user.primaryAffiliation;
if (!affiliation) return '소속 미지정';
return [affiliation.organizationName, affiliation.department, affiliation.position].filter(Boolean).join(' · ');
}
function getRoleLabel(role: string): string {
const normalized = role.toUpperCase();
if (normalized === 'LMS_ADMIN') return '관리자';
if (normalized === 'INSTRUCTOR') return '강사';
return role;
}
function NotificationDrawer({
filteredCount,
notificationPage,
notificationQuery,
notificationTab,
notificationTotalPages,
notifications,
open,
readCount,
unreadCount,
onClose,
onMarkAllRead,
onMarkRead,
onPageChange,
onQueryChange,
onTabChange,
}: {
filteredCount: number;
notificationPage: number;
notificationQuery: string;
notificationTab: 'unread' | 'read';
notificationTotalPages: number;
notifications: NotificationMessage[];
open: boolean;
readCount: number;
unreadCount: number;
onClose: () => void;
onMarkAllRead: () => void;
onMarkRead: (notificationId: string) => void;
onPageChange: React.Dispatch<React.SetStateAction<number>>;
onQueryChange: (query: string) => void;
onTabChange: (tab: 'unread' | 'read') => void;
}) {
if (!open) return null;
return (
<aside className="fixed bottom-0 right-0 top-[60px] z-30 flex w-full max-w-[420px] flex-col border-l border-[#cbd5e1] bg-white/94 shadow-[0_22px_70px_rgba(15,23,42,0.18)] backdrop-blur-md">
<header className="flex h-[72px] shrink-0 items-center justify-between border-b border-[#cbd5e1] px-5">
<div>
<h2 className="text-[16px] font-black text-[#0f172a]">전체 알림</h2>
<p className="mt-1 text-[12px] font-bold text-[#64748b]">최근 알림을 확인하고 읽음 처리합니다.</p>
</div>
<button className="grid h-9 w-9 place-items-center rounded-md text-[#64748b] hover:bg-[#f8fafc] hover:text-[#111827]" type="button" aria-label="알림 닫기" title="알림 닫기" onClick={onClose}>
<X size={17} aria-hidden="true" />
</button>
</header>
<div className="shrink-0 border-b border-[#cbd5e1] px-5 py-4">
<div className="flex items-center gap-2">
<div className="relative min-w-0 flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[#94a3b8]" size={15} aria-hidden="true" />
<input
className="h-9 w-full rounded-md border border-[#dbe3ef] bg-white pl-9 pr-3 text-[13px] font-bold text-[#111827] outline-none placeholder:text-[#94a3b8] focus:border-[#a5b4fc]"
value={notificationQuery}
placeholder="알림 검색"
onChange={(event) => onQueryChange(event.target.value)}
/>
</div>
<button
className="inline-flex h-9 shrink-0 cursor-pointer items-center gap-1.5 rounded-md border border-[#dbe3ef] px-3 text-[12px] font-bold text-[#64748b] hover:bg-[#f8fafc] hover:text-[#111827] disabled:cursor-default disabled:opacity-40"
type="button"
disabled={unreadCount === 0}
onClick={onMarkAllRead}
>
<CheckCheck size={14} aria-hidden="true" />
모두 읽음
</button>
</div>
<div className="mt-3 flex items-center justify-between">
<div className="inline-flex rounded-md border border-[#dbe3ef] bg-[#f8fafc] p-1">
{(['unread', 'read'] as const).map((tab) => (
<button
key={tab}
className={`h-8 cursor-pointer rounded px-3 text-[12px] font-black transition ${
notificationTab === tab ? 'bg-white text-[#4f46e5] shadow-[0_6px_16px_rgba(15,23,42,0.08)]' : 'text-[#64748b] hover:text-[#111827]'
}`}
type="button"
onClick={() => onTabChange(tab)}
>
{tab === 'unread' ? `안 읽음 ${unreadCount}` : `읽음 ${readCount}`}
</button>
))}
</div>
<span className="text-[11px] font-bold text-[#94a3b8]">{filteredCount}건</span>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-6 pt-4">
{notifications.length > 0 ? (
<div className="overflow-hidden rounded-lg border border-[#c7d2e0] bg-white shadow-[0_10px_28px_rgba(15,23,42,0.05)]">
{notifications.map((notification, index) => (
<NotificationRow
key={notification.id}
notification={notification}
bordered={index < notifications.length - 1}
onClick={() => onMarkRead(notification.id)}
/>
))}
</div>
) : (
<div className="flex h-full items-center justify-center rounded-lg border border-dashed border-[#cbd5e1] bg-white/58 px-6 text-center">
<p className="text-[13px] font-bold text-[#64748b]">표시할 알림이 없습니다.</p>
</div>
)}
</div>
<footer className="flex h-[54px] shrink-0 items-center justify-between border-t border-[#cbd5e1] px-5">
<span className="text-[12px] font-bold text-[#94a3b8]">
{notificationPage}/{notificationTotalPages}
</span>
<div className="flex items-center gap-2">
<button
className="grid h-8 w-8 cursor-pointer place-items-center rounded-md border border-[#dbe3ef] text-[#64748b] hover:bg-[#f8fafc] hover:text-[#111827] disabled:cursor-default disabled:opacity-35 disabled:hover:bg-transparent"
type="button"
aria-label="이전 페이지"
disabled={notificationPage <= 1}
onClick={() => onPageChange((page) => Math.max(1, page - 1))}
>
<ChevronLeft size={16} aria-hidden="true" />
</button>
<button
className="grid h-8 w-8 cursor-pointer place-items-center rounded-md border border-[#dbe3ef] text-[#64748b] hover:bg-[#f8fafc] hover:text-[#111827] disabled:cursor-default disabled:opacity-35 disabled:hover:bg-transparent"
type="button"
aria-label="다음 페이지"
disabled={notificationPage >= notificationTotalPages}
onClick={() => onPageChange((page) => Math.min(notificationTotalPages, page + 1))}
>
<ChevronRight size={16} aria-hidden="true" />
</button>
</div>
</footer>
</aside>
);
}
function NotificationRow({ notification, bordered, onClick }: { notification: NotificationMessage; bordered: boolean; onClick: () => void }) {
return (
<button
className={`block w-full cursor-pointer px-4 py-3 text-left transition hover:bg-[#f8fafc] ${bordered ? 'border-b border-[#cbd5e1]' : ''} ${
notification.read ? 'opacity-70' : ''
}`}
type="button"
onClick={onClick}
>
<div className="mb-1.5 flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
{!notification.read ? <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-[#4f46e5]" /> : null}
<span
className={`rounded-full px-2 py-0.5 text-[10px] font-black uppercase tracking-[0.02em] ${
notification.read ? 'bg-[#f1f5f9] text-[#94a3b8]' : 'bg-[#eef2ff] text-[#4f46e5]'
}`}
>
{notification.categoryLabel ?? notification.category}
</span>
</div>
<span className="shrink-0 text-[11px] font-bold text-[#94a3b8]">{notification.date}</span>
</div>
<p className={`text-[13px] leading-5 ${notification.read ? 'font-bold text-[#64748b]' : 'font-black text-[#111827]'}`}>{notification.title}</p>
<p className={`mt-1 text-[12px] font-semibold leading-5 ${notification.read ? 'text-[#94a3b8]' : 'text-[#64748b]'}`}>{notification.body}</p>
</button>
);
}
function ProfileMeta({ icon: Icon, label }: { icon: LucideIcon; label: string }) {
return (
<div className="flex min-w-0 items-center gap-2">
<Icon className="shrink-0 text-[#94a3b8]" size={14} aria-hidden="true" />
<span className="truncate">{label}</span>
</div>
);
}
function ProfileAction({ icon: Icon, label, danger = false, onClick }: { icon: LucideIcon; label: string; danger?: boolean; onClick: () => void }) {
return (
<button
className={`flex h-9 w-full items-center gap-3 rounded-md px-3 text-left text-[13px] font-bold ${
danger ? 'text-[#e11d48] hover:bg-[#fff1f2]' : 'text-[#111827] hover:bg-[#f8fafc]'
}`}
type="button"
onClick={onClick}
>
<Icon size={15} aria-hidden="true" />
{label}
</button>
);
}
+167
View File
@@ -0,0 +1,167 @@
import type { AuthAppRole, AuthSession } from '../types/auth';
const AUTH_BASE_URL = (import.meta.env.VITE_ATNMY_AUTH_URL ?? import.meta.env.VITE_ADS_IDE_AUTH_URL ?? 'http://localhost:4100').replace(/\/+$/, '');
const ADMIN_SESSION_STORAGE_KEY = 'atnmy.lms.admin.auth.session';
const INSTRUCTOR_ROLES = new Set(['INSTRUCTOR', 'LMS_ADMIN']);
export const isInstructorSession = (session: AuthSession): boolean => {
const roles = new Set(getSessionRoles(session));
return [...INSTRUCTOR_ROLES].some((role) => roles.has(role));
};
const getSessionRoles = (session: AuthSession): string[] => {
return [
session.user.role,
...(session.user.roles ?? []),
...(session.roles ?? []),
...getAppRoleCodes(session.user.appRoles),
...getAppRoleCodes(session.user.app_roles),
...getAppRoleCodes(session.appRoles),
...getAppRoleCodes(session.app_roles),
]
.filter((role): role is string => Boolean(role))
.map((role) => role.toUpperCase());
};
const getAppRoleCodes = (roles: AuthAppRole[] | undefined): string[] => {
if (!Array.isArray(roles)) return [];
return roles
.filter((role) => {
const appKey = role.appKey ?? role.app_key;
return !appKey || appKey.toUpperCase() === 'LMS';
})
.map((role) => role.roleCode ?? role.role_code ?? role.role ?? role.code)
.filter((role): role is string => Boolean(role));
};
export const loadStoredSession = (): AuthSession | null => {
try {
const raw = window.localStorage.getItem(ADMIN_SESSION_STORAGE_KEY);
if (!raw) return null;
const session = JSON.parse(raw) as AuthSession;
if (!isInstructorSession(session) || isStoredSessionExpired(session)) {
clearStoredSession();
return null;
}
return session;
} catch {
return null;
}
};
export const storeSession = (session: AuthSession): void => {
window.localStorage.setItem(ADMIN_SESSION_STORAGE_KEY, JSON.stringify({
...session,
obtainedAt: session.obtainedAt ?? Date.now(),
}));
};
export const clearStoredSession = (): void => {
window.localStorage.removeItem(ADMIN_SESSION_STORAGE_KEY);
};
export const isStoredSessionExpired = (session: AuthSession): boolean => {
const obtainedAt = session.obtainedAt ?? 0;
if (!obtainedAt) return false;
if (session.refreshToken && session.refreshTokenExpiresIn) {
return Date.now() >= obtainedAt + session.refreshTokenExpiresIn * 1000;
}
return Date.now() >= obtainedAt + (session.accessTokenExpiresIn ?? 0) * 1000;
};
export const loginInstructor = async (email: string, password: string): Promise<AuthSession> => {
const response = await fetch(`${AUTH_BASE_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ email, password })
});
if (!response.ok) {
throw new Error(response.status === 429 ? '로그인 시도가 너무 많습니다. 잠시 후 다시 시도해주세요.' : '이메일 또는 비밀번호를 확인해주세요.');
}
const session = {
...(await response.json()) as AuthSession,
obtainedAt: Date.now(),
};
if (!isInstructorSession(session)) {
clearStoredSession();
throw new Error('강사 계정만 로그인할 수 있습니다.');
}
storeSession(session);
return session;
};
export const refreshInstructorSession = async (session: AuthSession): Promise<AuthSession> => {
if (!session.refreshToken) {
throw new Error('리프레시 토큰이 없습니다.');
}
const response = await fetch(`${AUTH_BASE_URL}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ refreshToken: session.refreshToken })
});
if (!response.ok) {
throw new Error('세션을 갱신하지 못했습니다.');
}
const nextSession = {
...(await response.json()) as AuthSession,
obtainedAt: Date.now(),
};
if (!isInstructorSession(nextSession)) {
clearStoredSession();
throw new Error('강사 계정만 로그인할 수 있습니다.');
}
storeSession(nextSession);
return nextSession;
};
export const refreshCurrentUser = async (session: AuthSession): Promise<AuthSession> => {
const response = await fetch(`${AUTH_BASE_URL}/auth/me`, {
headers: {
Authorization: `Bearer ${session.accessToken}`
}
});
if (!response.ok) {
throw new Error('사용자 정보를 다시 가져오지 못했습니다.');
}
const payload = (await response.json()) as { user?: AuthSession['user'] };
const nextSession = {
...session,
user: payload.user ?? session.user
};
if (!isInstructorSession(nextSession)) {
clearStoredSession();
throw new Error('강사 계정만 로그인할 수 있습니다.');
}
storeSession(nextSession);
return nextSession;
};
export const logoutInstructor = async (): Promise<void> => {
try {
await fetch(`${AUTH_BASE_URL}/auth/logout`, {
method: 'POST',
credentials: 'include'
});
} finally {
clearStoredSession();
}
};
+129
View File
@@ -0,0 +1,129 @@
export type ChatSenderType = 'STUDENT' | 'AI' | 'INSTRUCTOR' | 'SYSTEM' | string;
export type InstructorChatMessage = {
id: string;
senderType: ChatSenderType;
senderName?: string;
senderAvatarUrl?: string | null;
content: string;
createdAt?: string;
};
export type InstructorChatThread = {
id: string;
title: string;
studentName: string;
studentAvatarUrl?: string | null;
groupName?: string;
week?: number | string;
status: string;
lastMessage: string;
lastMessageAt?: string;
messages: InstructorChatMessage[];
};
const CHAT_API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:4400/api').replace(/\/+$/, '');
export const CHAT_SOCKET_BASE_URL = CHAT_API_BASE_URL.replace(/\/api$/, '');
type ChatThreadsPayload = {
threads?: unknown[];
data?: unknown[];
};
export async function listInstructorChatThreads(accessToken: string, week?: number): Promise<InstructorChatThread[]> {
const params = new URLSearchParams();
if (week) params.set('week', String(week));
const queryString = params.toString();
const response = await fetch(`${CHAT_API_BASE_URL}/chat/instructor/threads${queryString ? `?${queryString}` : ''}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
throw new Error(`강사 채팅 목록을 불러오지 못했습니다. (${response.status})`);
}
const payload = (await response.json()) as ChatThreadsPayload | unknown[];
const items = Array.isArray(payload) ? payload : Array.isArray(payload?.threads) ? payload.threads : Array.isArray(payload?.data) ? payload.data : [];
return items.map(normalizeThread).filter((thread): thread is InstructorChatThread => Boolean(thread));
}
export async function getInstructorChatThread(accessToken: string, threadId: string): Promise<InstructorChatThread> {
const response = await fetch(`${CHAT_API_BASE_URL}/chat/instructor/threads/${threadId}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
throw new Error(`강사 채팅방을 불러오지 못했습니다. (${response.status})`);
}
const payload = await response.json();
const thread = normalizeThread(payload?.thread ?? payload);
if (!thread) throw new Error('강사 채팅방 응답이 올바르지 않습니다.');
return thread;
}
export async function createInstructorChatMessage(accessToken: string, threadId: string, content: string): Promise<InstructorChatThread> {
const response = await fetch(`${CHAT_API_BASE_URL}/chat/instructor/threads/${threadId}/messages`, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ content }),
});
if (!response.ok) {
throw new Error(`강사 답변을 저장하지 못했습니다. (${response.status})`);
}
const payload = await response.json();
const thread = normalizeThread(payload?.thread ?? payload);
if (!thread) throw new Error('강사 답변 응답이 올바르지 않습니다.');
return thread;
}
function normalizeThread(raw: unknown): InstructorChatThread | null {
if (!raw || typeof raw !== 'object') return null;
const item = raw as Record<string, any>;
const messages = Array.isArray(item.messages) ? item.messages.map(normalizeMessage).filter((message): message is InstructorChatMessage => Boolean(message)) : [];
const lastMessageFromList = messages.length > 0 ? messages[messages.length - 1]?.content : undefined;
const rawLastMessage = item.lastMessage?.content ?? item.lastMessageContent ?? item.lastMessage;
const studentName = item.student?.name ?? item.studentName ?? item.student?.email ?? item.studentEmail ?? item.studentId ?? '수강생';
const title = item.title ?? item.threadTitle ?? studentName;
const weekValue = typeof item.week === 'object' && item.week ? item.week.week : item.week;
return {
id: String(item.id ?? item.threadId ?? title),
title: String(title),
studentName: String(studentName),
studentAvatarUrl: typeof item.studentAvatarUrl === 'string' ? item.studentAvatarUrl : null,
groupName: item.group?.name ?? item.groupName ?? item.metadata?.groupName,
week: weekValue ?? item.weekId ?? item.metadata?.week,
status: String(item.status ?? 'WAITING_INSTRUCTOR'),
lastMessage: String(rawLastMessage ?? lastMessageFromList ?? '저장된 문의 내용을 확인해 주세요.'),
lastMessageAt: item.lastMessageAt ?? item.updatedAt ?? item.createdAt,
messages,
};
}
function normalizeMessage(raw: unknown): InstructorChatMessage | null {
if (!raw || typeof raw !== 'object') return null;
const item = raw as Record<string, any>;
return {
id: String(item.id ?? `${item.senderType ?? 'MESSAGE'}-${item.createdAt ?? item.content ?? 'unknown'}`),
senderType: String(item.senderType ?? 'STUDENT'),
senderName: item.senderName ?? item.sender?.name,
senderAvatarUrl: typeof item.senderAvatarUrl === 'string' ? item.senderAvatarUrl : null,
content: String(item.content ?? ''),
createdAt: item.createdAt,
};
}
+26
View File
@@ -0,0 +1,26 @@
export type InstructorCourse = {
id: string;
slug: string;
title: string;
subtitle?: string | null;
description?: string | null;
organizationName: string;
teacher?: {
id: string;
name?: string | null;
email: string;
};
};
const LMS_API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:4400/api').replace(/\/+$/, '');
export async function listInstructorCourses(accessToken: string): Promise<{ courses: InstructorCourse[] }> {
const response = await fetch(`${LMS_API_BASE_URL}/instructor/courses`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) throw new Error(`강좌 목록을 불러오지 못했습니다. (${response.status})`);
return response.json() as Promise<{ courses: InstructorCourse[] }>;
}
+275
View File
@@ -0,0 +1,275 @@
export type CurriculumWeek = {
week: number;
title: string;
tags: string[];
progress: number;
state: string;
topics: string[];
duration: string;
github: string;
detail: {
overview: string;
learningSteps?: Array<{
title: string;
description: string;
videoEnabled?: boolean;
videoUrl?: string;
concepts?: string[];
}>;
topicDetails: string[];
topicVideoUrls?: string[];
practiceContents?: Array<{
title: string;
description: string;
}>;
references?: CurriculumReference[];
exampleCodes?: CurriculumReference[];
deliverable: string;
requiresSourceSubmission: boolean;
};
modules?: CurriculumModule[];
visualizationPlatforms?: CurriculumVisualizationPlatform[];
};
export type CurriculumModule = {
id: string;
sortOrder: number;
category: string;
name: string;
folderName: string;
templateRepositoryUrl?: string | null;
dockerImage?: string | null;
enabled?: boolean;
};
export type CurriculumReference = {
id: string;
kind?: string;
title: string;
description?: string | null;
fileName: string;
contentType?: string | null;
fileSizeBytes?: number | string | null;
week?: number | string | null;
weekNumber?: number | string | null;
downloadUrl: string;
createdAt?: string;
};
export type CurriculumVisualizationPlatform = {
key: string;
type: string;
displayName: string;
description?: string | null;
config?: unknown;
};
export type CurriculumTagOption = {
key: string;
label: string;
};
export type InstructorCurriculumResponse = {
course: {
id: string;
title: string;
subtitle?: string | null;
description?: string | null;
organizationName: string;
} | null;
weeks: CurriculumWeek[];
setting: {
groupId?: string | null;
activeWeek: number;
activeLearningStep?: string | null;
} | null;
tagOptions?: CurriculumTagOption[];
visualizationPlatforms?: CurriculumVisualizationPlatform[];
};
const LMS_API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:4400/api').replace(/\/+$/, '');
export async function getInstructorCurriculum(accessToken: string, groupId?: string | null, courseId?: string | null): Promise<InstructorCurriculumResponse> {
const params = new URLSearchParams();
if (groupId) params.set('groupId', groupId);
if (courseId) params.set('courseId', courseId);
const query = params.toString() ? `?${params.toString()}` : '';
const response = await fetch(`${LMS_API_BASE_URL}/instructor/curriculum${query}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) throw new Error(`커리큘럼을 불러오지 못했습니다. (${response.status})`);
return response.json() as Promise<InstructorCurriculumResponse>;
}
export async function updateInstructorCurriculum(
accessToken: string,
input: {
courseId?: string | null;
groupId?: string | null;
activeWeek: number;
activeLearningStep?: string | null;
applyToStudents?: boolean;
completed?: boolean;
tagLabel?: string;
title?: string;
duration?: string;
overview?: string;
deliverable?: string;
requiresSourceSubmission?: boolean;
topics?: string[];
learningSteps?: Array<{
title?: string;
description?: string;
videoEnabled?: boolean;
videoUrl?: string;
concepts?: string[];
}>;
topicDetails?: string[];
topicVideoUrls?: string[];
practiceContents?: Array<{
title?: string;
description?: string;
}>;
disabledModuleIds?: string[];
visualizationPlatformKeys?: string[];
},
): Promise<InstructorCurriculumResponse> {
const response = await fetch(`${LMS_API_BASE_URL}/instructor/curriculum`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${accessToken}`,
'content-type': 'application/json',
},
body: JSON.stringify(input),
});
if (!response.ok) {
const payload = await response.json().catch((): null => null);
const message =
payload && typeof payload === 'object' && 'message' in payload
? String(payload.message)
: `커리큘럼을 적용하지 못했습니다. (${response.status})`;
throw new Error(message);
}
return response.json() as Promise<InstructorCurriculumResponse>;
}
export async function deleteInstructorCurriculumWeek(
accessToken: string,
week: number,
): Promise<InstructorCurriculumResponse> {
const response = await fetch(`${LMS_API_BASE_URL}/instructor/curriculum/weeks/${week}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
const payload = await response.json().catch((): null => null);
const message =
payload && typeof payload === 'object' && 'message' in payload
? String(payload.message)
: `주차를 삭제하지 못했습니다. (${response.status})`;
throw new Error(message);
}
return response.json() as Promise<InstructorCurriculumResponse>;
}
export async function uploadCurriculumReference(
accessToken: string,
input: {
courseId?: string | null;
week: number;
title?: string;
file: File;
},
): Promise<InstructorCurriculumResponse> {
const formData = new FormData();
formData.set('file', input.file);
formData.set('courseId', input.courseId ?? '');
formData.set('title', input.title?.trim() || input.file.name);
const response = await fetch(`${LMS_API_BASE_URL}/instructor/curriculum/weeks/${input.week}/references`, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
},
body: formData,
});
if (!response.ok) {
const payload = await response.json().catch((): null => null);
const message =
payload && typeof payload === 'object' && 'message' in payload
? String(payload.message)
: `참고자료를 업로드하지 못했습니다. (${response.status})`;
throw new Error(message);
}
return response.json() as Promise<InstructorCurriculumResponse>;
}
export async function uploadCurriculumExampleCode(
accessToken: string,
input: {
courseId?: string | null;
week: number;
title?: string;
file: File;
},
): Promise<InstructorCurriculumResponse> {
const formData = new FormData();
formData.set('file', input.file);
formData.set('courseId', input.courseId ?? '');
formData.set('title', input.title?.trim() || input.file.name);
const response = await fetch(`${LMS_API_BASE_URL}/instructor/curriculum/weeks/${input.week}/example-codes`, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
},
body: formData,
});
if (!response.ok) {
const payload = await response.json().catch((): null => null);
const message =
payload && typeof payload === 'object' && 'message' in payload
? String(payload.message)
: `예제 코드를 업로드하지 못했습니다. (${response.status})`;
throw new Error(message);
}
return response.json() as Promise<InstructorCurriculumResponse>;
}
export async function deleteCurriculumReference(
accessToken: string,
referenceId: string,
courseId?: string | null,
): Promise<InstructorCurriculumResponse> {
const query = courseId ? `?courseId=${encodeURIComponent(courseId)}` : '';
const response = await fetch(`${LMS_API_BASE_URL}/instructor/curriculum/references/${referenceId}${query}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
const payload = await response.json().catch((): null => null);
const message =
payload && typeof payload === 'object' && 'message' in payload
? String(payload.message)
: `참고자료를 삭제하지 못했습니다. (${response.status})`;
throw new Error(message);
}
return response.json() as Promise<InstructorCurriculumResponse>;
}
+127
View File
@@ -0,0 +1,127 @@
export type GroupStudentOption = {
id: string;
name: string;
email: string;
};
export type InstructorGroup = {
id: string;
name: string;
week: string;
status: string;
members: string[];
membersDetailed?: GroupStudentOption[];
instructorPlatformId?: string | null;
platform: string;
progress: number;
completedMissions: number;
activeUsers: number;
};
export type InstructorGroupsSummary = {
totalGroups: number;
assignedPlatforms: number;
totalStudents: number;
averageProgress: number;
};
export type InstructorGroupsResponse = {
summary: InstructorGroupsSummary;
groups: InstructorGroup[];
unassignedStudents?: GroupStudentOption[];
platformOptions?: GroupPlatformOption[];
};
export type GroupPlatformOption = {
id: string;
name: string;
};
export type SaveInstructorGroupInput = {
courseId?: string | null;
name: string;
instructorPlatformId?: string | null;
platformName?: string | null;
memberIds: string[];
};
const LMS_API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:4400/api').replace(/\/+$/, '');
export async function listInstructorGroups(accessToken: string, courseId?: string | null): Promise<InstructorGroupsResponse> {
const params = new URLSearchParams();
if (courseId) params.set('courseId', courseId);
const query = params.toString() ? `?${params.toString()}` : '';
const response = await fetch(`${LMS_API_BASE_URL}/instructor/groups${query}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
throw new Error(`그룹 현황을 불러오지 못했습니다. (${response.status})`);
}
return response.json() as Promise<InstructorGroupsResponse>;
}
export async function createInstructorGroup(
accessToken: string,
input: SaveInstructorGroupInput,
): Promise<InstructorGroupsResponse> {
const response = await fetch(`${LMS_API_BASE_URL}/instructor/groups`, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'content-type': 'application/json',
},
body: JSON.stringify(input),
});
if (!response.ok) throw new Error(await groupErrorMessage(response, '그룹을 추가하지 못했습니다.'));
return response.json() as Promise<InstructorGroupsResponse>;
}
export async function updateInstructorGroup(
accessToken: string,
groupId: string,
input: SaveInstructorGroupInput,
): Promise<InstructorGroupsResponse> {
const response = await fetch(`${LMS_API_BASE_URL}/instructor/groups/${groupId}`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${accessToken}`,
'content-type': 'application/json',
},
body: JSON.stringify(input),
});
if (!response.ok) throw new Error(await groupErrorMessage(response, '그룹을 수정하지 못했습니다.'));
return response.json() as Promise<InstructorGroupsResponse>;
}
export async function deleteInstructorGroup(
accessToken: string,
groupId: string,
courseId?: string | null,
): Promise<InstructorGroupsResponse> {
const params = new URLSearchParams();
if (courseId) params.set('courseId', courseId);
const query = params.toString() ? `?${params.toString()}` : '';
const response = await fetch(`${LMS_API_BASE_URL}/instructor/groups/${groupId}${query}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) throw new Error(await groupErrorMessage(response, '그룹을 삭제하지 못했습니다.'));
return response.json() as Promise<InstructorGroupsResponse>;
}
async function groupErrorMessage(response: Response, fallback: string) {
const payload = await response.json().catch((): null => null);
if (payload && typeof payload === 'object' && 'message' in payload) {
return String(payload.message);
}
return `${fallback} (${response.status})`;
}
+262
View File
@@ -0,0 +1,262 @@
export type InstructorPlatform = {
id: string;
masterPlatformId: string;
name: string;
type: string;
status: string;
battery: number;
uptime: string;
speed: string;
errors: number;
group: string | null;
user: string | null;
emergencyStop: boolean;
vehicleType: string;
wheelbaseMeters: number;
maxSpeedMetersPerSecond: number;
maxSteeringAngleDegrees: number;
sensors: PlatformSettingItem[];
drivers: PlatformSettingItem[];
algorithms: PlatformAlgorithms;
controlDurationMinutes: number;
controlWarningMinutes: number;
agent: InstructorPlatformAgentBinding | null;
};
export type InstructorPlatformAgentBinding = {
nodeId: string | null;
hostname: string | null;
ownerEmail: string | null;
ip: string | null;
connected: boolean;
lastSeenAt: string | null;
};
export type PlatformInUseSession = {
id: string;
holderUserId: string;
holderName: string | null;
holderEmail: string | null;
startedAt: string;
endsAt: string;
};
export class PlatformInUseError extends Error {
activeSession: PlatformInUseSession;
constructor(message: string, activeSession: PlatformInUseSession) {
super(message);
this.name = 'PlatformInUseError';
this.activeSession = activeSession;
}
}
export type TeachingAgent = {
id: string;
hostname: string;
name: string;
user: string;
tags: string[];
ip: string;
sshUser?: string | null;
workspaceRoot?: string | null;
connected: boolean;
expired: boolean;
lastSeenAt: string | null;
};
export type PlatformSettingItem = {
title: string;
description: string;
enabled?: boolean;
installed?: boolean;
};
export type PlatformAlgorithms = {
slam: string;
planning: string;
control: string;
};
export type MasterPlatform = {
id: string;
name: string;
description: string;
iconKey: string;
};
export type InstructorPlatformsSummary = {
totalPlatforms: number;
activePlatforms: number;
standbyPlatforms: number;
errorPlatforms: number;
};
export type InstructorPlatformsResponse = {
summary: InstructorPlatformsSummary;
platforms: InstructorPlatform[];
masterPlatforms: MasterPlatform[];
agents: TeachingAgent[];
};
const LMS_API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:4400/api').replace(/\/+$/, '');
export async function listInstructorPlatforms(accessToken: string): Promise<InstructorPlatformsResponse> {
const response = await fetch(`${LMS_API_BASE_URL}/instructor/platforms`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
throw new Error(`플랫폼 현황을 불러오지 못했습니다. (${response.status})`);
}
return response.json() as Promise<InstructorPlatformsResponse>;
}
export async function listInstructorPlatformAgents(accessToken: string): Promise<{ agents: TeachingAgent[] }> {
const response = await fetch(`${LMS_API_BASE_URL}/instructor/platforms/agents`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
throw new Error(`에이전트 목록을 불러오지 못했습니다. (${response.status})`);
}
return response.json() as Promise<{ agents: TeachingAgent[] }>;
}
export async function createInstructorPlatform(
accessToken: string,
platformId: string,
displayName?: string,
agent?: TeachingAgent | null,
ssh?: { user?: string; port?: string },
): Promise<InstructorPlatformsResponse> {
const response = await fetch(`${LMS_API_BASE_URL}/instructor/platforms`, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'content-type': 'application/json',
},
body: JSON.stringify({
platformId,
displayName,
agentNodeId: agent?.id,
agentHostname: agent?.hostname || agent?.name,
agentOwnerEmail: agent?.user,
sshUser: ssh?.user ?? agent?.sshUser ?? undefined,
sshPort: ssh?.port,
workspaceRoot: agent?.workspaceRoot ?? undefined,
}),
});
if (!response.ok) throw new Error(await platformErrorMessage(response, '플랫폼을 등록하지 못했습니다.'));
return response.json() as Promise<InstructorPlatformsResponse>;
}
export async function updateInstructorPlatform(
accessToken: string,
platformId: string,
displayName: string,
vehicleType: string,
wheelbaseMeters: number,
maxSpeedMetersPerSecond: number,
maxSteeringAngleDegrees: number,
sensors: PlatformSettingItem[],
algorithms: PlatformAlgorithms,
controlDurationMinutes: number,
controlWarningMinutes: number,
): Promise<InstructorPlatformsResponse> {
const response = await fetch(`${LMS_API_BASE_URL}/instructor/platforms/${platformId}`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${accessToken}`,
'content-type': 'application/json',
},
body: JSON.stringify({
displayName,
vehicleType,
wheelbaseMeters,
maxSpeedMetersPerSecond,
maxSteeringAngleDegrees,
sensors,
algorithms,
controlDurationMinutes,
controlWarningMinutes,
}),
});
if (!response.ok) throw new Error(await platformErrorMessage(response, '플랫폼 설정을 저장하지 못했습니다.'));
return response.json() as Promise<InstructorPlatformsResponse>;
}
export async function deleteInstructorPlatform(
accessToken: string,
platformId: string,
options?: { force?: boolean },
): Promise<InstructorPlatformsResponse> {
const query = options?.force ? '?force=true' : '';
const response = await fetch(`${LMS_API_BASE_URL}/instructor/platforms/${platformId}${query}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
const payload = await response.json().catch((): null => null);
if (response.status === 409 && payload && typeof payload === 'object' && (payload as { code?: unknown }).code === 'PLATFORM_IN_USE') {
const activeSession = (payload as { activeSession?: PlatformInUseSession }).activeSession;
if (activeSession) {
throw new PlatformInUseError(String((payload as { message?: unknown }).message ?? '현재 학생이 플랫폼을 사용 중입니다.'), activeSession);
}
}
throw new Error(platformErrorMessageFromPayload(response, payload, '플랫폼을 삭제하지 못했습니다.'));
}
return response.json() as Promise<InstructorPlatformsResponse>;
}
export async function assignInstructorPlatform(
accessToken: string,
platformId: string,
groupId: string,
options?: { force?: boolean },
): Promise<InstructorPlatformsResponse> {
const query = options?.force ? '?force=true' : '';
const response = await fetch(`${LMS_API_BASE_URL}/instructor/platforms/${platformId}/assignment${query}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'content-type': 'application/json',
},
body: JSON.stringify({ groupId }),
});
if (!response.ok) {
const payload = await response.json().catch((): null => null);
if (response.status === 409 && payload && typeof payload === 'object' && (payload as { code?: unknown }).code === 'PLATFORM_IN_USE') {
const activeSession = (payload as { activeSession?: PlatformInUseSession }).activeSession;
if (activeSession) {
throw new PlatformInUseError(String((payload as { message?: unknown }).message ?? '현재 학생이 플랫폼을 사용 중입니다.'), activeSession);
}
}
throw new Error(platformErrorMessageFromPayload(response, payload, '플랫폼을 배정하지 못했습니다.'));
}
return response.json() as Promise<InstructorPlatformsResponse>;
}
async function platformErrorMessage(response: Response, fallback: string) {
const payload = await response.json().catch((): null => null);
return platformErrorMessageFromPayload(response, payload, fallback);
}
function platformErrorMessageFromPayload(response: Response, payload: unknown, fallback: string) {
if (payload && typeof payload === 'object' && 'message' in payload) {
return String((payload as { message?: unknown }).message);
}
return `${fallback} (${response.status})`;
}
+86
View File
@@ -0,0 +1,86 @@
const PROFILE_API_BASE_URL = (import.meta.env.VITE_LAUNCHER_API_BASE_URL ?? 'http://localhost:4200/api').replace(/\/+$/, '');
const AVATAR_MAX_BYTES = 80 * 1024;
export async function fetchProfileAvatar(accessToken: string, version: number): Promise<Blob | null> {
const response = await fetch(`${PROFILE_API_BASE_URL}/auth/profile/avatar?v=${version}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
credentials: 'include',
});
if (response.status === 404) return null;
if (!response.ok) throw new Error('avatar_load_failed');
return response.blob();
}
export async function uploadProfileAvatar(accessToken: string, imageBlob: Blob): Promise<void> {
const response = await fetch(`${PROFILE_API_BASE_URL}/auth/profile/avatar`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': imageBlob.type,
},
credentials: 'include',
body: imageBlob,
});
if (!response.ok) throw new Error('avatar_upload_failed');
}
export async function deleteProfileAvatar(accessToken: string): Promise<void> {
const response = await fetch(`${PROFILE_API_BASE_URL}/auth/profile/avatar`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${accessToken}`,
},
credentials: 'include',
});
if (!response.ok) throw new Error('avatar_delete_failed');
}
export async function resizeImageToWebp(file: File, size = 128): Promise<Blob> {
if (!file.type.startsWith('image/')) throw new Error('이미지 파일만 등록할 수 있습니다.');
const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(new Error('이미지를 읽을 수 없습니다.'));
reader.readAsDataURL(file);
});
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('이미지를 불러올 수 없습니다.'));
img.src = dataUrl;
});
const sourceSize = Math.min(image.naturalWidth, image.naturalHeight);
const sourceX = Math.max(0, (image.naturalWidth - sourceSize) / 2);
const sourceY = Math.max(0, (image.naturalHeight - sourceSize) / 2);
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const context = canvas.getContext('2d');
if (!context) throw new Error('이미지를 처리할 수 없습니다.');
context.drawImage(image, sourceX, sourceY, sourceSize, sourceSize, 0, 0, size, size);
const blob = await new Promise<Blob | null>((resolve) => {
canvas.toBlob(resolve, 'image/webp', 0.82);
});
if (!blob) throw new Error('이미지를 압축할 수 없습니다.');
if (blob.size > AVATAR_MAX_BYTES) throw new Error('이미지 용량이 너무 큽니다.');
return blob;
}
export function notifyProfileAvatarUpdated(): void {
window.dispatchEvent(new Event('profile-avatar-updated'));
}
export function notifyProfileAvatarDeleted(): void {
window.dispatchEvent(new Event('profile-avatar-deleted'));
}
+109
View File
@@ -0,0 +1,109 @@
export type InstructorStudentIssue = {
type: 'MISSING_SUBMISSION' | 'COMPLETED_WITHOUT_SUBMISSION' | 'LOW_AVERAGE_SCORE';
title: string;
detail: string;
week?: number;
};
export type InstructorStudent = {
id: string;
name: string;
email: string;
group: string;
week: string;
progress: number;
commits: number;
issues: number;
issueItems: InstructorStudentIssue[];
lastActive: string;
online: boolean;
courseTitle?: string;
};
export type InstructorStudentsSummary = {
totalStudents: number;
onlineStudents: number;
averageProgress: number;
unresolvedIssues: number;
};
export type InstructorStudentsResponse = {
organization: {
id?: string;
name: string;
} | null;
summary: InstructorStudentsSummary;
students: InstructorStudent[];
};
export type CreateInstructorStudentInput = {
courseId?: string | null;
email: string;
name: string;
groupId?: string | null;
};
const LMS_API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:4400/api').replace(/\/+$/, '');
export async function listInstructorStudents(accessToken: string, courseId?: string | null): Promise<InstructorStudentsResponse> {
const query = courseId ? `?courseId=${encodeURIComponent(courseId)}` : '';
const response = await fetch(`${LMS_API_BASE_URL}/instructor/students${query}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
throw new Error(`수강생 현황을 불러오지 못했습니다. (${response.status})`);
}
return response.json() as Promise<InstructorStudentsResponse>;
}
export async function createInstructorStudent(
accessToken: string,
input: CreateInstructorStudentInput,
): Promise<InstructorStudentsResponse> {
const response = await fetch(`${LMS_API_BASE_URL}/instructor/students`, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'content-type': 'application/json',
},
body: JSON.stringify(input),
});
if (!response.ok) {
const payload = await response.json().catch((): null => null);
const message =
payload && typeof payload === 'object' && 'message' in payload
? String(payload.message)
: `수강생을 등록하지 못했습니다. (${response.status})`;
throw new Error(message);
}
return response.json() as Promise<InstructorStudentsResponse>;
}
export async function removeInstructorStudent(
accessToken: string,
studentId: string,
): Promise<InstructorStudentsResponse> {
const response = await fetch(`${LMS_API_BASE_URL}/instructor/students/${studentId}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
const payload = await response.json().catch((): null => null);
const message =
payload && typeof payload === 'object' && 'message' in payload
? String(payload.message)
: `수강생을 제외하지 못했습니다. (${response.status})`;
throw new Error(message);
}
return response.json() as Promise<InstructorStudentsResponse>;
}
+127
View File
@@ -0,0 +1,127 @@
export type InstructorSubmissionCommitFile = {
path: string;
status: string;
patch: string;
};
export type InstructorSubmissionCommit = {
hash: string;
shortHash: string;
message: string;
author: string;
authorEmail: string;
date: string | null;
files: InstructorSubmissionCommitFile[];
};
export type InstructorSubmissionItem = {
id: string;
status: 'NOT_INSTALLED' | 'PENDING' | 'SUBMITTED';
submitted: boolean;
submittedAt: string | null;
commitCount: number;
score: number;
course: {
id: string;
title: string;
slug: string;
};
week: {
id: string;
week: number;
title: string;
};
module: {
id: string;
name: string;
folderName: string;
sortOrder: number;
};
student: {
id: string;
name: string;
email: string;
group: string;
};
repository: {
id: string;
owner: string;
name: string;
url: string;
} | null;
commits: InstructorSubmissionCommit[];
finalFiles: InstructorSubmissionCommitFile[];
};
export type InstructorSubmissionsResponse = {
organization: {
id?: string;
name: string;
} | null;
summary: {
totalItems: number;
submittedItems: number;
pendingItems: number;
notInstalledItems: number;
totalCommits: number;
};
courses: Array<{
id: string;
title: string;
slug: string;
weeks: Array<{
id: string;
week: number;
title: string;
modules: Array<{
id: string;
name: string;
folderName: string;
sortOrder: number;
}>;
}>;
}>;
students: Array<{
id: string;
name: string;
email: string;
group: string;
}>;
items: InstructorSubmissionItem[];
};
const LMS_API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:4400/api').replace(/\/+$/, '');
export async function listInstructorSubmissions(accessToken: string): Promise<InstructorSubmissionsResponse> {
const response = await fetch(`${LMS_API_BASE_URL}/instructor/submissions`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
throw new Error(`제출 현황을 불러오지 못했습니다. (${response.status})`);
}
return response.json() as Promise<InstructorSubmissionsResponse>;
}
export async function saveInstructorSubmissionScore(
accessToken: string,
input: { studentId: string; weekId: string; score: number },
): Promise<{ score: number }> {
const response = await fetch(`${LMS_API_BASE_URL}/instructor/submissions/score`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(input),
});
if (!response.ok) {
throw new Error(`점수를 저장하지 못했습니다. (${response.status})`);
}
return response.json() as Promise<{ score: number }>;
}
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>
);
+45
View File
@@ -0,0 +1,45 @@
import type { AppRoute } from './routes';
type PageMeta = {
title: string;
description: string;
};
export const pageMeta: Record<AppRoute, PageMeta> = {
dashboard: {
title: '대시보드',
description: '실시간 모니터링 및 통합 관리 시스템'
},
curriculum: {
title: '커리큘럼 관리',
description: '강사별 진행 주차 및 학습단계 적용'
},
groups: {
title: '그룹 관리',
description: '수강생 그룹 및 플랫폼 할당'
},
platforms: {
title: '플랫폼 관리',
description: '자율주행 하드웨어 및 시뮬레이션 상태 조회'
},
students: {
title: '수강생 현황',
description: '개별 학습 진행 상황'
},
submissions: {
title: '제출 현황',
description: '강좌별 수강생 제출 여부와 변경 파일 조회'
},
analytics: {
title: '통계 분석',
description: '그룹별 진도와 플랫폼 사용 데이터를 분석합니다.'
},
settings: {
title: '설정',
description: '강사 포털의 운영 정책과 계정 설정을 관리합니다.'
},
profile: {
title: '프로필',
description: '강사 계정과 소속 정보를 확인합니다.'
}
};
+6
View File
@@ -0,0 +1,6 @@
import { BarChart3 } from 'lucide-react';
import { PlaceholderPage } from '../common/PlaceholderPage';
export function AnalyticsPage() {
return <PlaceholderPage icon={BarChart3} route="analytics" items={['평균 진도', '플랫폼 사용률', '에스컬레이션 추이']} />;
}
+41
View File
@@ -0,0 +1,41 @@
import type { LucideIcon } from 'lucide-react';
import { PageHeader } from '../../components/PageHeader';
import { pageMeta } from '../../pageMeta';
import type { AppRoute } from '../../routes';
type PlaceholderPageProps = {
icon: LucideIcon;
route: AppRoute;
items: string[];
};
export function PlaceholderPage({ icon: Icon, route, items }: PlaceholderPageProps) {
const title = pageMeta[route].title;
return (
<div className="mx-auto max-w-[1220px]">
<PageHeader route={route} />
<section className="rounded-lg border border-[#e5e7eb] bg-white p-5">
<div className="mb-5 flex items-center gap-3">
<div className="grid h-10 w-10 place-items-center rounded-lg bg-[#eef2ff] text-[#4f46e5]">
<Icon size={18} aria-hidden="true" />
</div>
<div>
<h2 className="text-[15px] font-black">{title} 준비 영역</h2>
<p className="mt-1 text-[12px] font-bold text-[#64748b]">메뉴별 라우팅과 페이지 경계가 분리되어 있습니다.</p>
</div>
</div>
<div className="grid gap-3 md:grid-cols-3">
{items.map((item) => (
<article className="rounded-lg border border-[#e5e7eb] bg-[#fbfcfe] p-4" key={item}>
<p className="text-[13px] font-black text-[#111827]">{item}</p>
<p className="mt-2 text-[12px] font-bold leading-5 text-[#64748b]">후속 API와 상세 UI를 이 페이지 폴더 안에서 확장합니다.</p>
</article>
))}
</div>
</section>
</div>
);
}
File diff suppressed because it is too large Load Diff
+348
View File
@@ -0,0 +1,348 @@
import { useEffect, useMemo, useState } from 'react';
import { Activity, BarChart3, Car, ChevronRight, CircleAlert, Loader2, Users, Wifi, type LucideIcon } from 'lucide-react';
import { PageHeader } from '../../components/PageHeader';
import { useToast } from '../../components/ToastProvider';
import { listInstructorChatThreads, type InstructorChatThread } from '../../lib/chat';
import { listInstructorGroups, type InstructorGroup, type InstructorGroupsSummary } from '../../lib/groups';
import { listInstructorPlatforms, type InstructorPlatform, type InstructorPlatformsSummary } from '../../lib/platforms';
import type { AppRoute } from '../../routes';
import type { AuthSession } from '../../types/auth';
const emptyPlatformSummary: InstructorPlatformsSummary = {
totalPlatforms: 0,
activePlatforms: 0,
standbyPlatforms: 0,
errorPlatforms: 0,
};
const emptyGroupSummary: InstructorGroupsSummary = {
totalGroups: 0,
assignedPlatforms: 0,
totalStudents: 0,
averageProgress: 0,
};
export function DashboardPage({ session, selectedCourseId, onNavigate, onOpenChatThread }: { session: AuthSession; selectedCourseId: string; onNavigate?: (route: AppRoute) => void; onOpenChatThread?: (threadId: string) => void }) {
const { showToast } = useToast();
const [platforms, setPlatforms] = useState<InstructorPlatform[]>([]);
const [platformSummary, setPlatformSummary] = useState<InstructorPlatformsSummary>(emptyPlatformSummary);
const [groups, setGroups] = useState<InstructorGroup[]>([]);
const [groupSummary, setGroupSummary] = useState<InstructorGroupsSummary>(emptyGroupSummary);
const [threads, setThreads] = useState<InstructorChatThread[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
Promise.allSettled([
listInstructorPlatforms(session.accessToken),
listInstructorGroups(session.accessToken, selectedCourseId || null),
listInstructorChatThreads(session.accessToken),
])
.then(([platformResult, groupResult, chatResult]) => {
if (cancelled) return;
if (platformResult.status === 'fulfilled') {
setPlatforms(platformResult.value.platforms);
setPlatformSummary(platformResult.value.summary);
} else {
setPlatforms([]);
setPlatformSummary(emptyPlatformSummary);
}
if (groupResult.status === 'fulfilled') {
setGroups(groupResult.value.groups);
setGroupSummary(groupResult.value.summary);
} else {
setGroups([]);
setGroupSummary(emptyGroupSummary);
}
if (chatResult.status === 'fulfilled') {
setThreads(chatResult.value);
} else {
setThreads([]);
}
const failed = [platformResult, groupResult].filter((result) => result.status === 'rejected');
setError(failed.length > 0 ? '대시보드 일부 데이터를 불러오지 못했습니다.' : null);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [session.accessToken, selectedCourseId]);
const waitingThreads = useMemo(() => threads.filter((thread) => thread.status === 'WAITING_INSTRUCTOR'), [threads]);
const activeUsers = useMemo(() => {
const groupActiveUsers = groups.reduce((sum, group) => sum + group.activeUsers, 0);
if (groupActiveUsers > 0) return groupActiveUsers;
return platforms.filter((platform) => platform.user).length;
}, [groups, platforms]);
const metrics = [
{ label: '활성 플랫폼', value: `${platformSummary.activePlatforms}/${platformSummary.totalPlatforms}`, icon: Activity, tone: 'bg-[#f0efff] text-[#4f46e5]' },
{ label: '총 수강생', value: `${groupSummary.totalStudents}명`, icon: Users, tone: 'bg-[#eff6ff] text-[#2563eb]' },
{ label: '현재 접속', value: `${activeUsers}명`, icon: Wifi, tone: 'bg-[#f8fafc] text-[#64748b]' },
{ label: '평균 진도', value: `${groupSummary.averageProgress}%`, icon: BarChart3, tone: 'bg-[#fff7ed] text-[#f97316]' },
];
return (
<div className="mx-auto max-w-[1220px]">
<PageHeader route="dashboard" />
{error ? <p className="mb-4 rounded-md border border-[#fecdd3] bg-[#fff1f2] px-4 py-3 text-[13px] font-bold text-[#e11d48]">{error}</p> : null}
<section className="mb-4 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{metrics.map((metric) => (
<MetricCard key={metric.label} {...metric} loading={loading} />
))}
</section>
<section className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<div className="rounded-lg border border-[#c7d2e0] bg-white">
<div className="flex items-start justify-between gap-3 border-b border-[#cbd5e1] px-4 py-4">
<div>
<h2 className="text-[15px] font-black text-[#111827]">플랫폼 실시간 상태</h2>
<p className="mt-1 text-[12px] font-bold text-[#64748b]">하드웨어 모니터링</p>
</div>
<button
type="button"
className="grid h-8 w-8 shrink-0 place-items-center rounded-md border border-[#dbe3ef] bg-white text-[#64748b] transition hover:border-[#bfdbfe] hover:bg-[#eff6ff] hover:text-[#2563eb]"
onClick={() => onNavigate?.('platforms')}
aria-label="플랫폼 관리로 이동"
title="플랫폼 관리로 이동"
>
<ChevronRight size={17} aria-hidden="true" />
</button>
</div>
<div className="max-h-[490px] space-y-3 overflow-y-auto p-4">
{loading ? (
<LoadingState message="플랫폼 상태를 불러오는 중입니다." />
) : platforms.length > 0 ? (
platforms.map((platform) => (
<PlatformStatusCard
key={platform.id}
platform={platform}
onEmergencyStop={() => {
showToast({
title: '긴급 정지 요청이 전송되었습니다.',
message: platform.name,
variant: 'error',
});
}}
/>
))
) : (
<EmptyState message="등록된 플랫폼이 없습니다." />
)}
</div>
</div>
<div className="rounded-lg border border-[#c7d2e0] bg-white">
<div className="flex items-start justify-between gap-3 border-b border-[#cbd5e1] px-4 py-4">
<div>
<h2 className="text-[15px] font-black text-[#111827]">그룹 진행 요약</h2>
<p className="mt-1 text-[12px] font-bold text-[#64748b]">그룹 구성, 플랫폼 배정, 진행률 조회</p>
</div>
<button
type="button"
className="grid h-8 w-8 shrink-0 place-items-center rounded-md border border-[#dbe3ef] bg-white text-[#64748b] transition hover:border-[#bfdbfe] hover:bg-[#eff6ff] hover:text-[#2563eb]"
onClick={() => onNavigate?.('groups')}
aria-label="그룹 관리로 이동"
title="그룹 관리로 이동"
>
<ChevronRight size={17} aria-hidden="true" />
</button>
</div>
<div className="max-h-[490px] space-y-3 overflow-y-auto p-4">
{loading ? (
<LoadingState message="그룹 진행 정보를 불러오는 중입니다." />
) : groups.length > 0 ? (
groups.map((group) => (
<GroupSummaryCard key={group.id} group={group} />
))
) : (
<EmptyState message="등록된 그룹이 없습니다." />
)}
</div>
</div>
</section>
<section className="mt-4 rounded-lg border border-[#c7d2e0] bg-white">
<div className="flex items-start justify-between gap-4 border-b border-[#cbd5e1] px-4 py-4">
<div>
<h2 className="text-[15px] font-black text-[#111827]">AI 가이드 에스컬레이션</h2>
<p className="mt-1 text-[12px] font-bold text-[#64748b]">강사 지원이 필요한 질문</p>
</div>
<span className="rounded-full bg-[#fff1f2] px-2.5 py-1 text-[13px] font-bold text-[#e11d48]">{waitingThreads.length} 건</span>
</div>
<div className="divide-y divide-[#e2e8f0]">
{loading ? (
<LoadingState message="문의 목록을 불러오는 중입니다." />
) : waitingThreads.length > 0 ? (
waitingThreads.slice(0, 5).map((thread) => (
<EscalationRow key={thread.id} thread={thread} onRespond={() => onOpenChatThread?.(thread.id)} />
))
) : (
<EmptyState message="강사 지원이 필요한 질문이 없습니다." />
)}
</div>
</section>
</div>
);
}
function PlatformStatusCard({ platform, onEmergencyStop }: { platform: InstructorPlatform; onEmergencyStop: () => void }) {
return (
<article className="flex min-h-[72px] items-center gap-3 rounded-lg border border-[#c7d2e0] bg-white px-3 py-3">
<div className={`grid h-10 w-10 shrink-0 place-items-center rounded-md ${platform.status === '오류' ? 'bg-[#fff1f2] text-[#e11d48]' : 'bg-[#f0efff] text-[#4f46e5]'}`}>
<Car size={18} aria-hidden="true" />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="truncate text-[14px] font-bold text-[#111827]">{platform.name}</h3>
<span className={`inline-flex h-6 items-center rounded-full border border-current/20 px-2 text-[11px] font-bold ${statusTone(platform.status)}`}>{platform.status}</span>
</div>
<p className="mt-1 truncate text-[12px] font-bold text-[#64748b]">
{platform.user ? `사용자: ${platform.user} 배터리: ${platform.battery}% 속도: ${platform.speed}` : `배터리: ${platform.battery}%`}
</p>
</div>
{platform.emergencyStop ? (
<button className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md bg-[#e11d48] px-3 text-[12px] font-bold text-white hover:bg-[#be123c]" type="button" onClick={onEmergencyStop}>
<CircleAlert size={14} aria-hidden="true" />
긴급 정지
</button>
) : null}
</article>
);
}
function GroupSummaryCard({ group }: { group: InstructorGroup }) {
return (
<article className="rounded-lg border border-[#c7d2e0] bg-white px-4 py-3">
<div className="mb-3 flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-[14px] font-black text-[#111827]">{group.name}</h3>
<span className="inline-flex h-6 items-center rounded-full border border-[#c7d2e0] bg-[#f8fafc] px-2 text-[11px] font-bold text-[#64748b]">{group.status}</span>
</div>
<p className="mt-1 text-[12px] font-bold text-[#64748b]">{group.members.length}명 | {group.week}</p>
</div>
<span className={`inline-flex h-7 shrink-0 items-center rounded-full border px-2.5 text-[11px] font-black ${group.platform === '미배정' ? 'border-[#d1d9e6] bg-[#f8fafc] text-[#94a3b8]' : 'border-[#c7d2fe] bg-[#eef2ff] text-[#4f46e5]'}`}>{group.platform}</span>
</div>
<div className="mb-2 flex items-center justify-between text-[12px] font-bold text-[#475569]">
<span>진행률</span>
<span className="text-[#4f46e5]">{group.progress}%</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-[#dbe3ef]">
<div className="h-full rounded-full bg-[#4f46e5]" style={{ width: `${group.progress}%` }} />
</div>
<div className="mt-3 flex items-center gap-4 text-[11px] font-bold text-[#94a3b8]">
<span>
완료 <strong className="text-[#64748b]">{group.completedMissions}</strong>
</span>
<span>
활동 <strong className={group.activeUsers > 0 ? 'text-[#4f46e5]' : 'text-[#94a3b8]'}>{group.activeUsers}</strong>
</span>
</div>
</article>
);
}
function EscalationRow({ thread, onRespond }: { thread: InstructorChatThread; onRespond?: () => void }) {
return (
<article className="grid gap-3 px-4 py-4 lg:grid-cols-[minmax(150px,180px)_minmax(0,1fr)_auto] lg:items-center">
<div className="flex min-w-0 items-center gap-2">
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-md bg-[#fff1f2] text-[#e11d48]">
<CircleAlert size={16} aria-hidden="true" />
</span>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="truncate text-[14px] font-bold text-[#111827]">{thread.studentName}</h3>
<span className="rounded border border-[#d1d9e6] px-1.5 py-0.5 text-[11px] font-bold text-[#475569]">{thread.groupName ?? (thread.week ? `${thread.week}주차` : '미배정')}</span>
</div>
<p className="mt-1 text-[12px] font-medium text-[#94a3b8]">{formatRelativeTime(thread.lastMessageAt)}</p>
</div>
</div>
<p className="min-w-0 text-[13px] font-semibold leading-6 text-[#111827]">{thread.lastMessage}</p>
<button
className="h-8 cursor-pointer justify-self-start rounded-md border border-[#c7d2fe] bg-[#eef2ff] px-3 text-[12px] font-bold text-[#4f46e5] hover:bg-[#e0e7ff] lg:justify-self-end"
type="button"
onClick={onRespond}
>
응답하기
</button>
</article>
);
}
function MetricCard({ label, value, icon: Icon, tone, loading = false }: { label: string; value: string; icon: LucideIcon; tone: string; loading?: boolean }) {
return (
<article className="rounded-lg border border-[#c7d2e0] bg-white px-5 py-4">
<div className="flex items-center justify-between">
<div>
<p className="text-[12px] font-semibold text-[#64748b]">{label}</p>
<p className="mt-2 text-[20px] font-black leading-none text-[#111827]">{loading ? '-' : value}</p>
</div>
<span className={`grid h-11 w-11 shrink-0 place-items-center rounded-md ${tone}`}>
<Icon size={28} aria-hidden="true" />
</span>
</div>
</article>
);
}
function LoadingState({ message }: { message: string }) {
return (
<div className="flex h-[180px] items-center justify-center text-[#64748b]">
<Loader2 className="mr-2 animate-spin" size={18} aria-hidden="true" />
<span className="text-[13px] font-bold">{message}</span>
</div>
);
}
function EmptyState({ message }: { message: string }) {
return (
<div className="flex h-[180px] items-center justify-center px-4 text-center">
<p className="text-[13px] font-bold text-[#64748b]">{message}</p>
</div>
);
}
function statusTone(status: string) {
if (status === '활성') return 'bg-[#f0efff] text-[#4f46e5]';
if (status === '오류') return 'bg-[#fff1f2] text-[#e11d48]';
return 'bg-[#f1f5f9] text-[#64748b]';
}
function formatRelativeTime(value: string | undefined) {
if (!value) return '-';
const time = new Date(value).getTime();
if (Number.isNaN(time)) return '-';
const diffMinutes = Math.max(0, Math.floor((Date.now() - time) / 60000));
if (diffMinutes < 1) return '방금 전';
if (diffMinutes < 60) return `${diffMinutes}분 전`;
const diffHours = Math.floor(diffMinutes / 60);
if (diffHours < 24) return `${diffHours}시간 전`;
const diffDays = Math.floor(diffHours / 24);
return `${diffDays}일 전`;
}
+471
View File
@@ -0,0 +1,471 @@
import { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { Activity, BarChart3, Car, Edit3, Loader2, Plus, Search, Trash2, Users, X, type LucideIcon } from 'lucide-react';
import { PageHeader } from '../../components/PageHeader';
import { PaginationBar } from '../../components/PaginationBar';
import { useToast } from '../../components/ToastProvider';
import {
createInstructorGroup,
deleteInstructorGroup,
listInstructorGroups,
updateInstructorGroup,
type GroupStudentOption,
type GroupPlatformOption,
type InstructorGroup,
type InstructorGroupsResponse,
type InstructorGroupsSummary,
} from '../../lib/groups';
import type { AuthSession } from '../../types/auth';
const emptySummary: InstructorGroupsSummary = {
totalGroups: 0,
assignedPlatforms: 0,
totalStudents: 0,
averageProgress: 0,
};
export function GroupsPage({ session, selectedCourseId }: { session: AuthSession; selectedCourseId: string }) {
const { showToast } = useToast();
const [groups, setGroups] = useState<InstructorGroup[]>([]);
const [summary, setSummary] = useState<InstructorGroupsSummary>(emptySummary);
const [unassignedStudents, setUnassignedStudents] = useState<GroupStudentOption[]>([]);
const [platformOptions, setPlatformOptions] = useState<GroupPlatformOption[]>([]);
const [error, setError] = useState<string | null>(null);
const [query, setQuery] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [modalMode, setModalMode] = useState<'create' | 'edit' | null>(null);
const [editingGroup, setEditingGroup] = useState<InstructorGroup | null>(null);
const [groupName, setGroupName] = useState('');
const [selectedPlatform, setSelectedPlatform] = useState('unassigned');
const [selectedMemberIds, setSelectedMemberIds] = useState<string[]>([]);
const [formError, setFormError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<InstructorGroup | null>(null);
const [deletingGroupId, setDeletingGroupId] = useState<string | null>(null);
const filteredGroups = useMemo(() => {
const keyword = query.trim().toLowerCase();
if (!keyword) return groups;
return groups.filter((group) =>
[group.name, group.platform, ...group.members]
.join(' ')
.toLowerCase()
.includes(keyword),
);
}, [groups, query]);
const pagedGroups = useMemo(() => filteredGroups.slice((page - 1) * pageSize, page * pageSize), [filteredGroups, page, pageSize]);
const summaryItems = [
{ label: '전체 그룹', value: `${summary.totalGroups}개`, icon: Users, tone: 'bg-[#f0efff] text-[#4f46e5]' },
{ label: '배정 플랫폼', value: `${summary.assignedPlatforms}대`, icon: Car, tone: 'bg-[#eff6ff] text-[#2563eb]' },
{ label: '수강생', value: `${summary.totalStudents}명`, icon: Activity, tone: 'bg-[#fff7ed] text-[#f97316]' },
{ label: '평균 진도', value: `${summary.averageProgress}%`, icon: BarChart3, tone: 'bg-[#f8fafc] text-[#64748b]' }
];
const applyGroupsPayload = (payload: InstructorGroupsResponse) => {
setError(null);
setGroups(payload.groups);
setSummary(payload.summary);
setUnassignedStudents(payload.unassignedStudents ?? []);
setPlatformOptions(payload.platformOptions ?? []);
};
useEffect(() => {
let cancelled = false;
listInstructorGroups(session.accessToken, selectedCourseId || null)
.then((payload) => {
if (cancelled) return;
applyGroupsPayload(payload);
})
.catch((reason: Error) => {
if (cancelled) return;
setGroups([]);
setSummary(emptySummary);
setError(reason.message);
});
return () => {
cancelled = true;
};
}, [session.accessToken, selectedCourseId]);
useEffect(() => {
setPage(1);
}, [query]);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(filteredGroups.length / pageSize));
if (page > totalPages) setPage(totalPages);
}, [filteredGroups.length, page, pageSize]);
useEffect(() => {
if (!modalMode && !deleteTarget) return;
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = originalOverflow;
};
}, [deleteTarget, modalMode]);
const handlePageSizeChange = (nextPageSize: number) => {
setPageSize(nextPageSize);
setPage(1);
};
const openCreateModal = () => {
setModalMode('create');
setEditingGroup(null);
setGroupName('');
setSelectedPlatform('unassigned');
setSelectedMemberIds([]);
setFormError(null);
};
const openEditModal = (group: InstructorGroup) => {
setModalMode('edit');
setEditingGroup(group);
setGroupName(group.name);
setSelectedPlatform(group.instructorPlatformId ?? 'unassigned');
setSelectedMemberIds((group.membersDetailed ?? []).map((member) => member.id));
setFormError(null);
};
const closeGroupModal = () => {
if (saving) return;
setModalMode(null);
setEditingGroup(null);
};
const availableStudents = useMemo(() => {
const currentMembers = editingGroup?.membersDetailed ?? [];
const byId = new Map<string, GroupStudentOption>();
[...currentMembers, ...unassignedStudents].forEach((student) => byId.set(student.id, student));
return [...byId.values()].sort((left, right) => left.name.localeCompare(right.name, 'ko'));
}, [editingGroup, unassignedStudents]);
const availablePlatforms = useMemo(() => {
const byId = new Map<string, GroupPlatformOption>();
if (editingGroup?.instructorPlatformId && editingGroup.platform !== '미배정') {
byId.set(editingGroup.instructorPlatformId, {
id: editingGroup.instructorPlatformId,
name: editingGroup.platform,
});
}
platformOptions.forEach((platform) => byId.set(platform.id, platform));
return [...byId.values()].sort((left, right) => left.name.localeCompare(right.name, 'ko'));
}, [editingGroup, platformOptions]);
const toggleMember = (studentId: string) => {
setSelectedMemberIds((current) =>
current.includes(studentId)
? current.filter((id) => id !== studentId)
: [...current, studentId],
);
};
const handleSaveGroup = async () => {
const name = groupName.trim();
if (!name) {
setFormError('그룹명을 입력해 주세요.');
return;
}
setSaving(true);
setFormError(null);
try {
const input = {
courseId: selectedCourseId || null,
name,
instructorPlatformId: selectedPlatform === 'unassigned' ? null : selectedPlatform,
platformName: availablePlatforms.find((platform) => platform.id === selectedPlatform)?.name ?? null,
memberIds: selectedMemberIds,
};
const payload = modalMode === 'edit' && editingGroup
? await updateInstructorGroup(session.accessToken, editingGroup.id, input)
: await createInstructorGroup(session.accessToken, input);
applyGroupsPayload(payload);
showToast({
title: modalMode === 'edit' ? '그룹이 수정되었습니다.' : '그룹이 추가되었습니다.',
message: name,
});
setModalMode(null);
setEditingGroup(null);
setPage(1);
} catch (reason) {
setFormError(reason instanceof Error ? reason.message : '그룹을 저장하지 못했습니다.');
} finally {
setSaving(false);
}
};
const handleDeleteGroup = async (group: InstructorGroup) => {
setDeletingGroupId(group.id);
setError(null);
try {
const payload = await deleteInstructorGroup(session.accessToken, group.id, selectedCourseId || null);
applyGroupsPayload(payload);
showToast({
title: '그룹이 삭제되었습니다.',
message: group.name,
});
setDeleteTarget(null);
setPage(1);
} catch (reason) {
setError(reason instanceof Error ? reason.message : '그룹을 삭제하지 못했습니다.');
} finally {
setDeletingGroupId(null);
}
};
return (
<div className="mx-auto max-w-[1220px]">
<PageHeader
route="groups"
action={
<button className="inline-flex h-9 items-center gap-2 rounded-md bg-[#2563eb] px-3 text-[13px] font-bold text-white hover:bg-[#1d4ed8]" type="button" onClick={openCreateModal}>
<Plus size={15} aria-hidden="true" />
그룹 추가
</button>
}
/>
<section className="mb-4 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{summaryItems.map((item) => (
<SummaryCard key={item.label} {...item} />
))}
</section>
<section className="rounded-lg border border-[#c7d2e0] bg-white">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[#cbd5e1] px-4 py-3">
<div>
<h2 className="text-[15px] font-black text-[#111827]">그룹 리스트</h2>
<p className="mt-1 text-[12px] font-bold text-[#64748b]">그룹 구성, 플랫폼 배정, 진행률 조회</p>
</div>
<label className="flex h-9 w-full max-w-[260px] items-center gap-2 rounded-md border border-[#d1d9e6] bg-white px-3 text-[#94a3b8]">
<Search size={15} aria-hidden="true" />
<input className="min-w-0 flex-1 bg-transparent text-[13px] font-bold text-[#111827] outline-none placeholder:text-[#94a3b8]" placeholder="그룹 검색" value={query} onChange={(event) => setQuery(event.target.value)} />
</label>
</div>
<div>
<div className="hidden gap-4 grid-cols-[44px_190px_minmax(220px,1fr)_170px_190px_74px] items-center border-b border-[#cbd5e1] bg-[#f8fafc] px-4 py-3 text-center text-[11px] font-bold text-[#64748b] xl:grid">
<span>No.</span>
<span>그룹</span>
<span>그룹원</span>
<span>플랫폼</span>
<span>진행률</span>
<span>관리</span>
</div>
<div className="divide-y divide-[#e2e8f0]">
{error ? (
<div className="flex h-[220px] items-center justify-center px-4 text-center">
<p className="text-[13px] font-bold text-[#64748b]">{error}</p>
</div>
) : pagedGroups.length > 0 ? (
pagedGroups.map((group, index) => (
<article className="grid gap-4 px-4 py-4 xl:grid-cols-[44px_190px_minmax(220px,1fr)_170px_190px_74px] xl:items-center" key={group.name}>
<div className="text-center">
<p className="mb-2 text-[11px] font-bold text-[#94a3b8] xl:hidden">번호</p>
<span className="text-[13px] font-bold text-[#64748b]">{filteredGroups.length - ((page - 1) * pageSize + index)}</span>
</div>
<div className="flex min-w-0 items-center gap-3">
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-md bg-[#f0efff] text-[#4f46e5]">
<Users size={17} aria-hidden="true" />
</span>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-[14px] font-black text-[#111827]">{group.name}</h3>
<span className="rounded bg-[#f1f5f9] px-1.5 py-0.5 text-[11px] font-bold text-[#64748b]">{group.status}</span>
</div>
<p className="mt-1 text-[12px] font-bold text-[#64748b]">
{group.members.length}명 | {group.week}
</p>
</div>
</div>
<div className="min-w-0">
<p className="mb-2 text-[11px] font-bold text-[#94a3b8]">그룹원</p>
<div className="flex flex-wrap gap-1.5">
{group.members.map((member) => (
<span className="rounded bg-[#f8fafc] px-2 py-1 text-[12px] font-bold text-[#334155]" key={member}>
{member}
</span>
))}
</div>
</div>
<div className="text-center">
<p className="mb-2 text-[11px] font-bold text-[#94a3b8]">플랫폼</p>
<span className="inline-flex h-7 items-center rounded-md bg-[#f1f5f9] px-2.5 text-[12px] font-bold text-[#111827]">{group.platform}</span>
</div>
<div>
<div className="mb-2 flex items-center justify-between text-[12px] font-bold text-[#475569]">
<span>진행률</span>
<span className="text-[#4f46e5]">{group.progress}%</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-[#dbe3ef]">
<div className="h-full rounded-full bg-[#4f46e5]" style={{ width: `${group.progress}%` }} />
</div>
<p className="mt-2 text-center text-[11px] font-bold text-[#94a3b8]">
완료 {group.completedMissions} · 활동 {group.activeUsers}
</p>
</div>
<div className="flex items-center justify-center gap-2">
<button className="grid h-8 w-8 place-items-center rounded-md border border-[#c7d2fe] bg-[#eef2ff] text-[#4f46e5] hover:bg-[#e0e7ff]" type="button" aria-label={`${group.name} 수정`} title="수정" onClick={() => openEditModal(group)}>
<Edit3 size={15} aria-hidden="true" />
</button>
<button className="grid h-8 w-8 place-items-center rounded-md border border-[#fecdd3] bg-[#fff1f2] text-[#e11d48] hover:bg-[#ffe4e6] disabled:cursor-not-allowed disabled:opacity-60" type="button" aria-label={`${group.name} 삭제`} title="삭제" onClick={() => setDeleteTarget(group)} disabled={deletingGroupId === group.id}>
{deletingGroupId === group.id ? <Loader2 className="animate-spin" size={15} aria-hidden="true" /> : <Trash2 size={15} aria-hidden="true" />}
</button>
</div>
</article>
))
) : (
<div className="flex h-[220px] items-center justify-center px-4 text-center">
<p className="text-[13px] font-bold text-[#64748b]">조회된 그룹이 없습니다.</p>
</div>
)}
</div>
</div>
<PaginationBar
page={page}
pageSize={pageSize}
totalItems={filteredGroups.length}
totalLabel={`전체 ${filteredGroups.length}개`}
onPageChange={setPage}
onPageSizeChange={handlePageSizeChange}
/>
</section>
{modalMode ? createPortal((
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#0f172a]/25 px-4">
<div className="flex max-h-[86vh] w-full max-w-[620px] flex-col rounded-lg border border-[#c7d2e0] bg-white shadow-[0_24px_70px_rgba(15,23,42,0.18)]">
<div className="flex items-center justify-between border-b border-[#cbd5e1] px-5 py-4">
<div>
<h2 className="text-[16px] font-black text-[#111827]">{modalMode === 'edit' ? '그룹 수정' : '그룹 추가'}</h2>
<p className="mt-1 text-[12px] font-bold text-[#64748b]">그룹 정보와 배정 인원을 설정해 주세요.</p>
</div>
<button className="grid h-8 w-8 place-items-center rounded-md text-[#64748b] hover:bg-[#f1f5f9] hover:text-[#111827]" type="button" onClick={closeGroupModal} disabled={saving} aria-label="닫기" title="닫기">
<X size={17} aria-hidden="true" />
</button>
</div>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto px-5 py-5">
<label className="block">
<span className="mb-2 block text-[12px] font-black text-[#334155]">그룹명</span>
<input
className="h-10 w-full rounded-md border border-[#d1d9e6] bg-white px-3 text-[13px] font-semibold text-[#111827] outline-none placeholder:text-[#94a3b8] focus:border-[#2563eb] focus:ring-2 focus:ring-[#dbeafe]"
placeholder="그룹명 입력"
value={groupName}
onChange={(event) => setGroupName(event.target.value)}
/>
</label>
<label className="block">
<span className="mb-2 block text-[12px] font-black text-[#334155]">플랫폼</span>
<select
className="h-10 w-full rounded-md border border-[#d1d9e6] bg-white px-3 text-[13px] font-semibold text-[#111827] outline-none focus:border-[#2563eb] focus:ring-2 focus:ring-[#dbeafe]"
value={selectedPlatform}
onChange={(event) => setSelectedPlatform(event.target.value)}
>
<option value="unassigned">미배정</option>
{availablePlatforms.map((platform) => (
<option key={platform.id} value={platform.id}>{platform.name}</option>
))}
</select>
</label>
<div>
<div className="mb-2 flex items-center justify-between">
<span className="text-[12px] font-black text-[#334155]">그룹원</span>
<span className="text-[12px] font-bold text-[#64748b]">선택 {selectedMemberIds.length}명</span>
</div>
<div className="max-h-[260px] space-y-2 overflow-y-auto rounded-lg border border-[#d1d9e6] bg-[#f8fafc] p-2">
{availableStudents.length > 0 ? availableStudents.map((student) => (
<label className="flex cursor-pointer items-center gap-3 rounded-md bg-white px-3 py-2 hover:bg-[#f8faff]" key={student.id}>
<input
className="h-4 w-4 accent-[#4f46e5]"
type="checkbox"
checked={selectedMemberIds.includes(student.id)}
onChange={() => toggleMember(student.id)}
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-black text-[#111827]">{student.name}</span>
<span className="mt-0.5 block truncate text-[12px] font-semibold text-[#64748b]">{student.email}</span>
</span>
</label>
)) : (
<div className="flex h-[120px] items-center justify-center text-[13px] font-bold text-[#64748b]">배정 가능한 수강생이 없습니다.</div>
)}
</div>
</div>
{formError ? <p className="rounded-md bg-[#fff1f2] px-3 py-2 text-[12px] font-bold text-[#e11d48]">{formError}</p> : null}
</div>
<div className="flex justify-end gap-2 border-t border-[#cbd5e1] px-5 py-4">
<button className="h-9 rounded-md border border-[#d1d9e6] bg-white px-4 text-[13px] font-bold text-[#475569] hover:bg-[#f8fafc]" type="button" onClick={closeGroupModal} disabled={saving}>취소</button>
<button className="inline-flex h-9 items-center gap-2 rounded-md bg-[#2563eb] px-4 text-[13px] font-bold text-white hover:bg-[#1d4ed8] disabled:cursor-not-allowed disabled:opacity-60" type="button" onClick={handleSaveGroup} disabled={saving}>
{saving ? <Loader2 className="animate-spin" size={15} aria-hidden="true" /> : <Plus size={15} aria-hidden="true" />}
저장
</button>
</div>
</div>
</div>
), document.body) : null}
{deleteTarget ? createPortal((
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#0f172a]/25 px-4">
<div className="w-full max-w-[420px] rounded-lg border border-[#c7d2e0] bg-white shadow-[0_24px_70px_rgba(15,23,42,0.18)]">
<div className="flex items-center justify-between border-b border-[#cbd5e1] px-5 py-4">
<div>
<h2 className="text-[16px] font-black text-[#111827]">그룹 삭제</h2>
<p className="mt-1 text-[12px] font-bold text-[#64748b]">그룹원은 모두 미배정 상태가 됩니다.</p>
</div>
<button className="grid h-8 w-8 place-items-center rounded-md text-[#64748b] hover:bg-[#f1f5f9] hover:text-[#111827]" type="button" onClick={() => setDeleteTarget(null)} disabled={deletingGroupId === deleteTarget.id} aria-label="닫기" title="닫기">
<X size={17} aria-hidden="true" />
</button>
</div>
<div className="px-5 py-5">
<div className="rounded-lg border border-[#cbd5e1] bg-[#f8fafc] px-4 py-3">
<p className="text-[14px] font-black text-[#111827]">{deleteTarget.name}</p>
<p className="mt-1 text-[12px] font-semibold text-[#64748b]">{deleteTarget.members.length}명 | {deleteTarget.platform}</p>
</div>
<p className="mt-4 text-[13px] font-bold leading-relaxed text-[#475569]">그룹만 삭제되고 수강생 계정과 수강 등록은 유지됩니다.</p>
</div>
<div className="flex justify-end gap-2 border-t border-[#cbd5e1] px-5 py-4">
<button className="h-9 rounded-md border border-[#d1d9e6] bg-white px-4 text-[13px] font-bold text-[#475569] hover:bg-[#f8fafc] disabled:opacity-60" type="button" onClick={() => setDeleteTarget(null)} disabled={deletingGroupId === deleteTarget.id}>취소</button>
<button className="inline-flex h-9 items-center gap-2 rounded-md border border-[#fecdd3] bg-[#fff1f2] px-4 text-[13px] font-bold text-[#e11d48] hover:bg-[#ffe4e6] disabled:opacity-60" type="button" onClick={() => handleDeleteGroup(deleteTarget)} disabled={deletingGroupId === deleteTarget.id}>
{deletingGroupId === deleteTarget.id ? <Loader2 className="animate-spin" size={15} aria-hidden="true" /> : <Trash2 size={15} aria-hidden="true" />}
삭제
</button>
</div>
</div>
</div>
), document.body) : null}
</div>
);
}
function SummaryCard({ label, value, icon: Icon, tone }: { label: string; value: string; icon: LucideIcon; tone: string }) {
return (
<article className="rounded-lg border border-[#c7d2e0] bg-white px-5 py-4">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-[12px] font-semibold text-[#64748b]">{label}</p>
<p className="mt-2 text-[20px] font-black leading-none text-[#111827]">{value}</p>
</div>
<span className={`grid h-11 w-11 shrink-0 place-items-center rounded-md ${tone}`}>
<Icon size={28} aria-hidden="true" />
</span>
</div>
</article>
);
}
+166
View File
@@ -0,0 +1,166 @@
import { useState, type FormEvent } from 'react';
import { ArrowRight, CheckCircle2 } from 'lucide-react';
import { BrandMark } from '../../components/BrandMark';
import { loginInstructor } from '../../lib/auth';
import type { AuthSession } from '../../types/auth';
type LoginPageProps = {
onLogin: (session: AuthSession) => void;
};
const REMEMBERED_EMAIL_STORAGE_KEY = 'atnmy.lms.admin.login.rememberedEmail';
const DEFAULT_EMAIL = '';
const DEFAULT_PASSWORD = 'qwer1234';
export function LoginPage({ onLogin }: LoginPageProps) {
const rememberedEmail = window.localStorage.getItem(REMEMBERED_EMAIL_STORAGE_KEY);
const [email, setEmail] = useState(rememberedEmail ?? DEFAULT_EMAIL);
const [password, setPassword] = useState(DEFAULT_PASSWORD);
const [rememberEmail, setRememberEmail] = useState(Boolean(rememberedEmail));
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setLoading(true);
setError('');
try {
const session = await loginInstructor(email, password);
if (rememberEmail) {
window.localStorage.setItem(REMEMBERED_EMAIL_STORAGE_KEY, email);
} else {
window.localStorage.removeItem(REMEMBERED_EMAIL_STORAGE_KEY);
}
onLogin(session);
} catch (loginError) {
setError(loginError instanceof Error ? loginError.message : '로그인에 실패했습니다.');
} finally {
setLoading(false);
}
};
return (
<main className="grid min-h-screen bg-white text-[#111827] lg:grid-cols-[minmax(420px,58%)_minmax(360px,42%)]">
<section className="relative flex min-h-[42vh] overflow-hidden bg-[linear-gradient(145deg,#3b82f6_0%,#7048e8_54%,#4f46e5_100%)] px-7 py-8 text-white sm:px-12 lg:min-h-screen lg:px-[60px]">
<div className="absolute inset-0 opacity-25">
<div className="absolute left-[14%] top-[18%] h-40 w-40 rounded-full bg-white blur-3xl" />
<div className="absolute bottom-[18%] right-[12%] h-56 w-56 rounded-full bg-[#93c5fd] blur-3xl" />
</div>
<div className="relative z-10 flex w-full max-w-[580px] flex-col">
<BrandMark />
<div className="my-auto py-12 lg:py-0">
<p className="mb-4 text-[11px] font-extrabold uppercase tracking-[0.16em] text-white/70">Learning Management System</p>
<h1 className="max-w-[520px] text-[34px] font-black leading-[1.12] tracking-[0] sm:text-[44px]">
<span className="text-[#67e8f9]">자율주행</span> 교육 플랫폼
</h1>
<p className="mt-5 max-w-[410px] text-[14px] font-bold leading-6 text-white/82">
실제 하드웨어와 시뮬레이션을 활용한 차세대 학습 관리 시스템
</p>
<div className="mt-8 grid max-w-[410px] gap-2.5 rounded-xl bg-white/12 p-4 ring-1 ring-white/18 backdrop-blur">
{['실시간 모니터링 및 통합 관리', '하드웨어 플랫폼 상태 추적', 'AI 가이드 에스컬레이션'].map((item) => (
<div className="flex items-center gap-3 text-[12px] font-bold text-white/88" key={item}>
<CheckCircle2 size={16} aria-hidden="true" />
{item}
</div>
))}
</div>
</div>
<p className="text-[12px] font-bold text-white/55">© 2026. All rights reserved.</p>
</div>
</section>
<section className="flex min-h-[58vh] items-center justify-center px-7 py-10 sm:px-12 lg:min-h-screen">
<div className="w-full max-w-[420px]">
<div className="mb-8">
<h2 className="text-[26px] font-black tracking-[0] text-[#4f46e5]">강사 로그인</h2>
<p className="mt-2.5 text-[13px] font-bold text-[#6b7280]">이메일과 비밀번호를 입력해주세요.</p>
</div>
<form className="grid gap-4" onSubmit={handleSubmit}>
<label className="grid gap-2 text-[13px] font-extrabold text-[#303747]">
이메일
<input
className="h-11 rounded-lg border border-[#d8dde8] bg-white px-4 text-[14px] font-bold text-[#111827] outline-none transition placeholder:text-[#a0a7b7] focus:border-[#4f46e5] focus:ring-4 focus:ring-[#4f46e5]/12"
name="email"
placeholder="이메일을 입력하세요"
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
/>
</label>
<label className="grid gap-2 text-[13px] font-extrabold text-[#303747]">
비밀번호
<input
className="h-11 rounded-lg border border-[#d8dde8] bg-white px-4 text-[14px] font-bold text-[#111827] outline-none transition placeholder:text-[#a0a7b7] focus:border-[#4f46e5] focus:ring-4 focus:ring-[#4f46e5]/12"
name="password"
placeholder="비밀번호를 입력하세요"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
/>
</label>
<div className="flex items-center justify-between gap-3">
<label className="flex items-center gap-2 text-[12px] font-bold text-[#64748b]">
<input
className="h-4 w-4 rounded border-[#d8dde8] accent-[#4f46e5]"
type="checkbox"
checked={rememberEmail}
onChange={(event) => setRememberEmail(event.target.checked)}
/>
아이디 기억
</label>
<button className="text-[12px] font-extrabold text-[#4f46e5]" type="button">
비밀번호를 잊으셨나요?
</button>
</div>
<button
className="h-12 rounded-lg bg-[#4f46e5] text-[14px] font-extrabold text-white shadow-[0_10px_24px_rgba(79,70,229,0.22)] transition hover:bg-[#4338ca] focus:outline-none focus:ring-4 focus:ring-[#4f46e5]/20 disabled:cursor-not-allowed disabled:opacity-70"
type="submit"
disabled={loading}
>
{loading ? '로그인 중...' : '로그인'}
</button>
{error ? <p className="text-center text-[12px] font-bold text-[#dc2626]">{error}</p> : null}
</form>
<div className="my-7 flex items-center gap-4">
<span className="h-px flex-1 bg-[#e3e7ef]" />
<span className="text-[12px] font-bold text-[#9aa2b1]">또는</span>
<span className="h-px flex-1 bg-[#e3e7ef]" />
</div>
<button
className="group flex h-12 w-full items-center justify-center gap-3 rounded-lg border border-[#d8dde8] bg-white px-4 text-[14px] font-extrabold text-[#263141] shadow-sm transition hover:border-[#b8c0d0] hover:bg-[#fafbff] focus:outline-none focus:ring-4 focus:ring-[#4f46e5]/15"
type="button"
onClick={() => setError('Google 로그인은 공통 인증 서버에 엔드포인트가 준비된 뒤 연결됩니다.')}
>
<GoogleMark />
Google로 계속
<ArrowRight className="ml-auto text-[#98a1b2] transition group-hover:translate-x-0.5 group-hover:text-[#4f46e5]" size={16} aria-hidden="true" />
</button>
<p className="mt-7 text-center text-[11px] font-bold leading-5 text-[#8b93a3]">접근 권한이 없는 계정은 관리자에게 문의하세요.</p>
</div>
</section>
</main>
);
}
function GoogleMark() {
return (
<span className="grid h-5 w-5 place-items-center rounded-full border border-[#dbe1eb] bg-[#f8fafc] text-[15px] font-black leading-none text-[#4285f4]" aria-hidden="true">
<span>G</span>
</span>
);
}
File diff suppressed because it is too large Load Diff
+199
View File
@@ -0,0 +1,199 @@
import { useRef, useState, type ChangeEvent } from 'react';
import { Building2, Camera, Loader2, Mail, ShieldCheck, Trash2, type LucideIcon } from 'lucide-react';
import { ConfirmDialog } from '../../components/ConfirmDialog';
import { PageHeader } from '../../components/PageHeader';
import { useToast } from '../../components/ToastProvider';
import { useProfileAvatar } from '../../hooks/useProfileAvatar';
import { deleteProfileAvatar, notifyProfileAvatarDeleted, notifyProfileAvatarUpdated, resizeImageToWebp, uploadProfileAvatar } from '../../lib/profileAvatar';
import type { AuthSession } from '../../types/auth';
type ProfilePageProps = {
session: AuthSession;
};
export function ProfilePage({ session }: ProfilePageProps) {
const fileInputRef = useRef<HTMLInputElement | null>(null);
const { showToast } = useToast();
const { avatarObjectUrl, avatarMissing, avatarLoading, refreshAvatar, setAvatarMissing } = useProfileAvatar(session.accessToken);
const [avatarUploading, setAvatarUploading] = useState(false);
const [avatarDeleting, setAvatarDeleting] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const displayName = getDisplayName(session);
const affiliation = getAffiliation(session);
const roleLabel = getRoleLabel(session.user.role);
const organizationName = session.user.primaryAffiliation?.organizationName ?? '소속 미지정';
const department = session.user.primaryAffiliation?.department ?? '부서 미지정';
const position = session.user.primaryAffiliation?.position ?? '직책 미지정';
const hasAvatar = !avatarMissing && Boolean(avatarObjectUrl);
const avatarBusy = avatarLoading || avatarUploading || avatarDeleting;
const handleAvatarSelect = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
setAvatarUploading(true);
try {
const imageBlob = await resizeImageToWebp(file, 128);
await uploadProfileAvatar(session.accessToken, imageBlob);
refreshAvatar();
notifyProfileAvatarUpdated();
showToast({ title: '프로필 이미지가 변경되었습니다.', variant: 'success' });
} catch (error) {
showToast({
title: '프로필 이미지 변경 실패',
message: error instanceof Error ? error.message : '이미지를 다시 확인해주세요.',
variant: 'error',
});
} finally {
setAvatarUploading(false);
}
};
const handleAvatarDelete = async () => {
setAvatarDeleting(true);
try {
await deleteProfileAvatar(session.accessToken);
setAvatarMissing(true);
notifyProfileAvatarDeleted();
setDeleteConfirmOpen(false);
showToast({ title: '프로필 이미지가 삭제되었습니다.', variant: 'success' });
} catch {
showToast({ title: '프로필 이미지 삭제 실패', message: '잠시 후 다시 시도해주세요.', variant: 'error' });
} finally {
setAvatarDeleting(false);
}
};
return (
<div className="mx-auto max-w-[1220px]">
<PageHeader route="profile" />
<section className="grid gap-4 xl:grid-cols-[360px_minmax(0,1fr)]">
<div className="rounded-lg border border-[#c7d2e0] bg-white p-5">
<div className="flex items-center gap-4">
<div className="relative h-16 w-16 shrink-0">
<div className="grid h-full w-full place-items-center overflow-hidden rounded-full border border-[#dbe3ef] bg-[#2563eb] text-[22px] font-black text-white shadow-[0_12px_24px_rgba(37,99,235,0.18)]">
{hasAvatar ? (
<img
src={avatarObjectUrl ?? undefined}
alt=""
className="h-full w-full object-cover"
onError={() => setAvatarMissing(true)}
/>
) : (
displayName.slice(0, 1)
)}
</div>
{avatarBusy ? (
<span className="absolute inset-0 grid place-items-center rounded-full bg-white/65 text-[#2563eb] backdrop-blur-[2px]">
<Loader2 size={18} className="animate-spin" aria-hidden="true" />
</span>
) : null}
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleAvatarSelect} />
<button
type="button"
className="absolute bottom-0 right-0 z-10 grid h-7 w-7 translate-x-0.5 translate-y-0.5 cursor-pointer place-items-center rounded-full border border-white bg-[#2563eb] text-white shadow-[0_8px_18px_rgba(37,99,235,0.28)] transition hover:bg-[#1d4ed8] disabled:cursor-not-allowed disabled:opacity-60"
disabled={avatarBusy}
onClick={() => fileInputRef.current?.click()}
aria-label="프로필 이미지 등록"
>
<Camera size={14} aria-hidden="true" />
</button>
{hasAvatar ? (
<button
type="button"
className="absolute right-0 top-0 z-10 grid h-7 w-7 translate-x-0.5 -translate-y-0.5 cursor-pointer place-items-center rounded-full border border-white bg-[#fff1f2] text-[#e11d48] shadow-[0_8px_18px_rgba(225,29,72,0.18)] transition hover:bg-[#ffe4e6] disabled:cursor-not-allowed disabled:opacity-60"
disabled={avatarBusy}
onClick={() => setDeleteConfirmOpen(true)}
aria-label="프로필 이미지 삭제"
>
<Trash2 size={13} aria-hidden="true" />
</button>
) : null}
</div>
<div className="min-w-0">
<h2 className="truncate text-[18px] font-black text-[#111827]">{displayName}</h2>
<p className="mt-1 truncate text-[12px] font-bold text-[#64748b]">{roleLabel}</p>
</div>
</div>
<div className="mt-6 space-y-3">
<ProfileRow icon={Building2} label="소속" value={affiliation} />
<ProfileRow icon={Mail} label="이메일" value={session.user.email} />
<ProfileRow icon={ShieldCheck} label="권한" value={roleLabel} />
</div>
</div>
<div className="rounded-lg border border-[#c7d2e0] bg-white">
<div className="border-b border-[#cbd5e1] px-5 py-4">
<h2 className="text-[15px] font-black text-[#111827]">계정 정보</h2>
<p className="mt-1 text-[12px] font-bold text-[#64748b]">공통 인증 서버에서 전달된 로그인 정보입니다.</p>
</div>
<div className="grid gap-3 p-5 md:grid-cols-2">
<InfoCard label="소속 기관" value={organizationName} />
<InfoCard label="부서" value={department} />
<InfoCard label="직책" value={position} />
<InfoCard label="계정 상태" value="활성" />
</div>
</div>
</section>
<ConfirmDialog
open={deleteConfirmOpen}
title="프로필 이미지 삭제"
message="등록된 썸네일 이미지를 삭제하시겠습니까?"
confirmLabel="삭제"
loading={avatarDeleting}
danger
icon={Trash2}
onCancel={() => setDeleteConfirmOpen(false)}
onConfirm={() => void handleAvatarDelete()}
/>
</div>
);
}
function ProfileRow({ icon: Icon, label, value }: { icon: LucideIcon; label: string; value: string }) {
return (
<div className="flex items-start gap-3 rounded-lg bg-[#f8fafc] px-3 py-3">
<Icon className="mt-0.5 shrink-0 text-[#64748b]" size={16} aria-hidden="true" />
<div className="min-w-0">
<p className="text-[11px] font-bold text-[#94a3b8]">{label}</p>
<p className="mt-1 break-words text-[13px] font-bold text-[#111827]">{value}</p>
</div>
</div>
);
}
function InfoCard({ label, value, wide = false }: { label: string; value: string; wide?: boolean }) {
return (
<article className={`rounded-lg border border-[#c7d2e0] bg-white p-4 ${wide ? 'md:col-span-2' : ''}`}>
<p className="text-[12px] font-semibold text-[#64748b]">{label}</p>
<p className="mt-2 text-[14px] font-bold text-[#111827]">{value}</p>
</article>
);
}
function getDisplayName(session: AuthSession): string {
const name = session.user.name?.trim();
if (name && name !== 'ATNMY Studio Admin') return name;
return session.user.email.split('@')[0] || '강사';
}
function getAffiliation(session: AuthSession): string {
const affiliation = session.user.primaryAffiliation;
if (!affiliation) return '소속 미지정';
return [affiliation.organizationName, affiliation.department, affiliation.position].filter(Boolean).join(' · ');
}
function getRoleLabel(role: string): string {
const normalized = role.toUpperCase();
if (normalized === 'LMS_ADMIN') return '관리자';
if (normalized === 'INSTRUCTOR') return '강사';
return role;
}
+6
View File
@@ -0,0 +1,6 @@
import { Settings } from 'lucide-react';
import { PlaceholderPage } from '../common/PlaceholderPage';
export function SettingsPage() {
return <PlaceholderPage icon={Settings} route="settings" items={['권한 정책', '알림 설정', '서비스 연결']} />;
}
+611
View File
@@ -0,0 +1,611 @@
import { useCallback, useEffect, useMemo, useState, type FormEvent } from 'react';
import { createPortal } from 'react-dom';
import { BarChart3, CircleAlert, GitBranch, Loader2, Plus, Search, Trash2, UserRound, Wifi, X, type LucideIcon } from 'lucide-react';
import { PageHeader } from '../../components/PageHeader';
import { PaginationBar } from '../../components/PaginationBar';
import { useToast } from '../../components/ToastProvider';
import { listInstructorGroups, type InstructorGroup } from '../../lib/groups';
import { createInstructorStudent, listInstructorStudents, removeInstructorStudent, type InstructorStudent, type InstructorStudentsSummary } from '../../lib/students';
import type { AuthSession } from '../../types/auth';
const emptySummary: InstructorStudentsSummary = {
totalStudents: 0,
onlineStudents: 0,
averageProgress: 0,
unresolvedIssues: 0,
};
export function StudentsPage({ session, selectedCourseId }: { session: AuthSession; selectedCourseId: string }) {
const { showToast } = useToast();
const [students, setStudents] = useState<InstructorStudent[]>([]);
const [summary, setSummary] = useState<InstructorStudentsSummary>(emptySummary);
const [query, setQuery] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [groups, setGroups] = useState<InstructorGroup[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [studentModalOpen, setStudentModalOpen] = useState(false);
const [newStudentEmail, setNewStudentEmail] = useState('');
const [newStudentName, setNewStudentName] = useState('');
const [newStudentGroupId, setNewStudentGroupId] = useState('unassigned');
const [studentSaving, setStudentSaving] = useState(false);
const [studentFormError, setStudentFormError] = useState<string | null>(null);
const [removingStudentId, setRemovingStudentId] = useState<string | null>(null);
const [removeTarget, setRemoveTarget] = useState<InstructorStudent | null>(null);
const [issueTarget, setIssueTarget] = useState<InstructorStudent | null>(null);
const loadStudents = useCallback(async () => {
setLoading(true);
setError(null);
try {
const payload = await listInstructorStudents(session.accessToken, selectedCourseId || null);
setStudents(payload.students);
setSummary(payload.summary);
} catch (reason) {
setStudents([]);
setSummary(emptySummary);
setError(reason instanceof Error ? reason.message : '수강생 현황을 불러오지 못했습니다.');
} finally {
setLoading(false);
}
}, [session.accessToken, selectedCourseId]);
useEffect(() => {
let cancelled = false;
loadStudents();
listInstructorGroups(session.accessToken, selectedCourseId || null)
.then((payload) => {
if (!cancelled) setGroups(payload.groups);
})
.catch(() => {
if (!cancelled) setGroups([]);
});
return () => {
cancelled = true;
};
}, [loadStudents, session.accessToken, selectedCourseId]);
const filteredStudents = useMemo(() => {
const keyword = query.trim().toLowerCase();
if (!keyword) return students;
return students.filter((student) =>
[student.name, student.email, student.group, student.week, student.courseTitle]
.filter(Boolean)
.join(' ')
.toLowerCase()
.includes(keyword),
);
}, [query, students]);
const pagedStudents = useMemo(() => filteredStudents.slice((page - 1) * pageSize, page * pageSize), [filteredStudents, page, pageSize]);
useEffect(() => {
setPage(1);
}, [query]);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(filteredStudents.length / pageSize));
if (page > totalPages) setPage(totalPages);
}, [filteredStudents.length, page, pageSize]);
useEffect(() => {
if (!studentModalOpen && !removeTarget && !issueTarget) return;
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = originalOverflow;
};
}, [issueTarget, removeTarget, studentModalOpen]);
const handlePageSizeChange = (nextPageSize: number) => {
setPageSize(nextPageSize);
setPage(1);
};
const openStudentModal = () => {
setNewStudentEmail('');
setNewStudentName('');
setNewStudentGroupId('unassigned');
setStudentFormError(null);
setStudentModalOpen(true);
};
const closeStudentModal = () => {
if (studentSaving) return;
setStudentModalOpen(false);
};
const handleCreateStudent = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const email = newStudentEmail.trim();
const name = newStudentName.trim();
if (!email || !name) {
setStudentFormError('이메일과 이름을 입력해 주세요.');
return;
}
setStudentSaving(true);
setStudentFormError(null);
try {
const payload = await createInstructorStudent(session.accessToken, {
courseId: selectedCourseId || null,
email,
name,
groupId: newStudentGroupId === 'unassigned' ? null : newStudentGroupId,
});
setStudents(payload.students);
setSummary(payload.summary);
setStudentModalOpen(false);
setPage(1);
showToast({
title: '수강생이 추가되었습니다.',
message: name,
});
} catch (reason) {
const message = reason instanceof Error ? reason.message : '수강생을 등록하지 못했습니다.';
setStudentFormError(message);
showToast({
title: '수강생 추가에 실패했습니다.',
message,
variant: 'error',
});
} finally {
setStudentSaving(false);
}
};
const handleRemoveStudent = async (student: InstructorStudent) => {
setRemovingStudentId(student.id);
setError(null);
try {
const payload = await removeInstructorStudent(session.accessToken, student.id);
setStudents(payload.students);
setSummary(payload.summary);
showToast({
title: '수강생이 제외되었습니다.',
message: student.name,
});
} catch (reason) {
const message = reason instanceof Error ? reason.message : '수강생을 제외하지 못했습니다.';
setError(message);
showToast({
title: '수강생 제외에 실패했습니다.',
message,
variant: 'error',
});
} finally {
setRemovingStudentId(null);
setRemoveTarget(null);
}
};
const summaryItems = [
{ label: '전체 수강생', value: `${summary.totalStudents}명`, icon: UserRound, tone: 'bg-[#f0efff] text-[#4f46e5]' },
{ label: '현재 접속', value: `${summary.onlineStudents}명`, icon: Wifi, tone: 'bg-[#eff6ff] text-[#2563eb]' },
{ label: '평균 진도', value: `${summary.averageProgress}%`, icon: BarChart3, tone: 'bg-[#fff7ed] text-[#f97316]' },
{ label: '이슈', value: `${summary.unresolvedIssues}건`, icon: CircleAlert, tone: 'bg-[#fff1f2] text-[#e11d48]' },
];
return (
<div className="mx-auto max-w-[1220px]">
<PageHeader
route="students"
action={
<button className="inline-flex h-9 items-center gap-2 rounded-md bg-[#2563eb] px-3 text-[13px] font-bold text-white hover:bg-[#1d4ed8]" type="button" onClick={openStudentModal}>
<Plus size={15} aria-hidden="true" />
수강생 추가
</button>
}
/>
<section className="mb-4 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{summaryItems.map((item) => (
<SummaryCard key={item.label} {...item} />
))}
</section>
<section className="rounded-lg border border-[#c7d2e0] bg-white">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[#cbd5e1] px-4 py-3">
<div>
<h2 className="text-[15px] font-black text-[#111827]">수강생 리스트</h2>
<p className="mt-1 text-[12px] font-bold text-[#64748b]">그룹, 주차, 진도, 커밋, 이슈, 최근 활동 조회</p>
</div>
<label className="flex h-9 w-full max-w-[260px] items-center gap-2 rounded-md border border-[#d1d9e6] bg-white px-3 text-[#94a3b8]">
<Search size={15} aria-hidden="true" />
<input
className="min-w-0 flex-1 bg-transparent text-[13px] font-bold text-[#111827] outline-none placeholder:text-[#94a3b8]"
placeholder="수강생 검색"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</label>
</div>
<div>
<div>
<div className="hidden gap-4 grid-cols-[44px_108px_minmax(160px,1fr)_minmax(116px,0.7fr)_78px_minmax(150px,1fr)_58px_58px_118px_70px_42px] items-center border-b border-[#cbd5e1] bg-[#f8fafc] px-4 py-3 text-center text-[11px] font-bold text-[#64748b] xl:grid">
<span>No.</span>
<span>이름</span>
<span>이메일</span>
<span>그룹</span>
<span>주차</span>
<span>진행률</span>
<span>커밋</span>
<span>이슈</span>
<span>최근 활동</span>
<span>상태</span>
<span>제외</span>
</div>
<div className="divide-y divide-[#e2e8f0]">
{loading ? (
<div className="flex h-[220px] items-center justify-center text-[#64748b]">
<Loader2 className="mr-2 animate-spin" size={18} aria-hidden="true" />
<span className="text-[13px] font-bold">수강생 현황을 불러오는 중입니다.</span>
</div>
) : error ? (
<EmptyState message={error} />
) : filteredStudents.length > 0 ? (
pagedStudents.map((student, index) => (
<article className="grid gap-4 px-4 py-4 xl:grid-cols-[44px_108px_minmax(160px,1fr)_minmax(116px,0.7fr)_78px_minmax(150px,1fr)_58px_58px_118px_70px_42px] xl:items-center" key={student.id}>
<Cell label="번호">
<span className="text-[13px] font-bold text-[#64748b]">{filteredStudents.length - ((page - 1) * pageSize + index)}</span>
</Cell>
<div className="min-w-0 text-center">
<p className="mb-2 text-[11px] font-bold text-[#94a3b8] xl:hidden">이름</p>
<h3 className="truncate text-[14px] font-black text-[#111827]">{student.name}</h3>
</div>
<Cell label="이메일">
<span className="block truncate text-[12px] font-semibold text-[#64748b]">{student.email}</span>
</Cell>
<Cell label="그룹">
<span className={`inline-flex h-7 items-center rounded-md px-2.5 text-[12px] font-semibold ${
student.group === '미배정' ? 'bg-[#f1f5f9] text-[#64748b]' : 'bg-[#f0efff] text-[#4f46e5]'
}`}>{student.group}</span>
</Cell>
<Cell label="주차">
<span className="text-[13px] font-semibold text-[#111827]">{student.week}</span>
</Cell>
<div>
<div className="mb-2 flex items-center justify-between text-[12px] font-bold text-[#475569]">
<span className="xl:hidden">진행률</span>
<span className="font-semibold text-[#4f46e5]">{student.progress}%</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-[#dbe3ef]">
<div className="h-full rounded-full bg-[#4f46e5]" style={{ width: `${student.progress}%` }} />
</div>
</div>
<StudentMetric icon={GitBranch} label="커밋" value={student.commits} />
<StudentIssueMetric student={student} onOpen={setIssueTarget} />
<Cell label="최근 활동">
<span className="text-[13px] font-semibold text-[#64748b]">{student.lastActive}</span>
</Cell>
<Cell label="상태">
<span className={`inline-flex h-7 items-center justify-center rounded-md px-2.5 text-[12px] font-bold ${student.online ? 'bg-[#eef2ff] text-[#4f46e5]' : 'bg-[#f1f5f9] text-[#64748b]'}`}>
{student.online ? '온라인' : '오프라인'}
</span>
</Cell>
<div className="flex justify-center">
<button
className="grid h-8 w-8 place-items-center rounded-md border border-[#fecdd3] bg-[#fff1f2] text-[#e11d48] hover:bg-[#ffe4e6]"
type="button"
aria-label={`${student.name} 수강 제외`}
title="수강 제외"
onClick={() => setRemoveTarget(student)}
disabled={removingStudentId === student.id}
>
{removingStudentId === student.id ? <Loader2 className="animate-spin" size={15} aria-hidden="true" /> : <Trash2 size={15} aria-hidden="true" />}
</button>
</div>
</article>
))
) : (
<EmptyState message="조회된 수강생이 없습니다." />
)}
</div>
</div>
</div>
<PaginationBar
page={page}
pageSize={pageSize}
totalItems={filteredStudents.length}
totalLabel={`전체 ${filteredStudents.length}명`}
onPageChange={setPage}
onPageSizeChange={handlePageSizeChange}
/>
</section>
{studentModalOpen ? createPortal((
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#0f172a]/25 px-4">
<form
className="w-full max-w-[440px] rounded-lg border border-[#c7d2e0] bg-white shadow-[0_24px_70px_rgba(15,23,42,0.18)]"
onSubmit={handleCreateStudent}
>
<div className="flex items-center justify-between border-b border-[#cbd5e1] px-5 py-4">
<div>
<h2 className="text-[16px] font-black text-[#111827]">수강생 추가</h2>
<p className="mt-1 text-[12px] font-bold text-[#64748b]">수강생 정보를 입력해 주세요.</p>
</div>
<button
className="grid h-8 w-8 place-items-center rounded-md text-[#64748b] hover:bg-[#f1f5f9] hover:text-[#111827]"
type="button"
onClick={closeStudentModal}
aria-label="닫기"
title="닫기"
>
<X size={17} aria-hidden="true" />
</button>
</div>
<div className="space-y-4 px-5 py-5">
<label className="block">
<span className="mb-2 block text-[12px] font-black text-[#334155]">이메일</span>
<input
className="h-10 w-full rounded-md border border-[#d1d9e6] bg-white px-3 text-[13px] font-semibold text-[#111827] outline-none placeholder:text-[#94a3b8] focus:border-[#2563eb] focus:ring-2 focus:ring-[#dbeafe]"
type="email"
placeholder="student@futuredrive.net"
value={newStudentEmail}
onChange={(event) => setNewStudentEmail(event.target.value)}
/>
</label>
<label className="block">
<span className="mb-2 block text-[12px] font-black text-[#334155]">이름</span>
<input
className="h-10 w-full rounded-md border border-[#d1d9e6] bg-white px-3 text-[13px] font-semibold text-[#111827] outline-none placeholder:text-[#94a3b8] focus:border-[#2563eb] focus:ring-2 focus:ring-[#dbeafe]"
placeholder="홍길동"
value={newStudentName}
onChange={(event) => setNewStudentName(event.target.value)}
/>
</label>
<label className="block">
<span className="mb-2 block text-[12px] font-black text-[#334155]">그룹</span>
<select
className="h-10 w-full rounded-md border border-[#d1d9e6] bg-white px-3 text-[13px] font-semibold text-[#111827] outline-none focus:border-[#2563eb] focus:ring-2 focus:ring-[#dbeafe]"
value={newStudentGroupId}
onChange={(event) => setNewStudentGroupId(event.target.value)}
>
<option value="unassigned">미배정</option>
{groups.map((group) => (
<option key={group.id} value={group.id}>
{group.name}
</option>
))}
</select>
</label>
{studentFormError ? (
<p className="rounded-md bg-[#fff1f2] px-3 py-2 text-[12px] font-bold text-[#e11d48]">{studentFormError}</p>
) : null}
</div>
<div className="flex justify-end gap-2 border-t border-[#cbd5e1] px-5 py-4">
<button
className="h-9 rounded-md border border-[#d1d9e6] bg-white px-4 text-[13px] font-bold text-[#475569] hover:bg-[#f8fafc]"
type="button"
onClick={closeStudentModal}
disabled={studentSaving}
>
취소
</button>
<button
className="inline-flex h-9 items-center gap-2 rounded-md bg-[#2563eb] px-4 text-[13px] font-bold text-white hover:bg-[#1d4ed8] disabled:cursor-not-allowed disabled:opacity-60"
type="submit"
disabled={studentSaving}
>
{studentSaving ? <Loader2 className="animate-spin" size={15} aria-hidden="true" /> : <Plus size={15} aria-hidden="true" />}
등록
</button>
</div>
</form>
</div>
), document.body) : null}
{removeTarget ? createPortal((
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#0f172a]/25 px-4">
<div className="w-full max-w-[420px] rounded-lg border border-[#c7d2e0] bg-white shadow-[0_24px_70px_rgba(15,23,42,0.18)]">
<div className="flex items-center justify-between border-b border-[#cbd5e1] px-5 py-4">
<div>
<h2 className="text-[16px] font-black text-[#111827]">수강생 제외</h2>
<p className="mt-1 text-[12px] font-bold text-[#64748b]">현재 소속의 수강 목록에서 제외합니다.</p>
</div>
<button
className="grid h-8 w-8 place-items-center rounded-md text-[#64748b] hover:bg-[#f1f5f9] hover:text-[#111827]"
type="button"
onClick={() => setRemoveTarget(null)}
aria-label="닫기"
title="닫기"
disabled={removingStudentId === removeTarget.id}
>
<X size={17} aria-hidden="true" />
</button>
</div>
<div className="px-5 py-5">
<div className="rounded-lg border border-[#cbd5e1] bg-[#f8fafc] px-4 py-3">
<p className="truncate text-[14px] font-black text-[#111827]">{removeTarget.name}</p>
<p className="mt-1 truncate text-[12px] font-semibold text-[#64748b]">{removeTarget.email}</p>
</div>
</div>
<div className="flex justify-end gap-2 border-t border-[#cbd5e1] px-5 py-4">
<button
className="h-9 rounded-md border border-[#d1d9e6] bg-white px-4 text-[13px] font-bold text-[#475569] hover:bg-[#f8fafc] disabled:cursor-not-allowed disabled:opacity-60"
type="button"
onClick={() => setRemoveTarget(null)}
disabled={removingStudentId === removeTarget.id}
>
취소
</button>
<button
className="inline-flex h-9 items-center gap-2 rounded-md border border-[#fecdd3] bg-[#fff1f2] px-4 text-[13px] font-bold text-[#e11d48] hover:bg-[#ffe4e6] disabled:cursor-not-allowed disabled:opacity-60"
type="button"
onClick={() => handleRemoveStudent(removeTarget)}
disabled={removingStudentId === removeTarget.id}
>
{removingStudentId === removeTarget.id ? <Loader2 className="animate-spin" size={15} aria-hidden="true" /> : <Trash2 size={15} aria-hidden="true" />}
제외
</button>
</div>
</div>
</div>
), document.body) : null}
{issueTarget ? createPortal((
<IssueModal student={issueTarget} onClose={() => setIssueTarget(null)} />
), document.body) : null}
</div>
);
}
function EmptyState({ message }: { message: string }) {
return (
<div className="flex h-[220px] items-center justify-center px-4 text-center">
<p className="text-[13px] font-bold text-[#64748b]">{message}</p>
</div>
);
}
function Cell({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="min-w-0 text-center">
<p className="mb-2 text-[11px] font-bold text-[#94a3b8] xl:hidden">{label}</p>
{children}
</div>
);
}
function StudentMetric({ icon: Icon, label, value, danger = false }: { icon: typeof GitBranch; label: string; value: number; danger?: boolean }) {
return (
<div className="min-w-0 text-center">
<div className="flex items-center justify-center gap-1.5 text-[11px] font-bold text-[#94a3b8] xl:hidden">
<Icon size={13} aria-hidden="true" />
{label}
</div>
<p className={`mt-1 text-[13px] font-semibold xl:mt-0 ${danger ? 'text-[#e11d48]' : 'text-[#111827]'}`}>{value}</p>
</div>
);
}
function StudentIssueMetric({ student, onOpen }: { student: InstructorStudent; onOpen: (student: InstructorStudent) => void }) {
const hasIssues = student.issues > 0;
return (
<div className="min-w-0 text-center">
<div className="flex items-center justify-center gap-1.5 text-[11px] font-bold text-[#94a3b8] xl:hidden">
<CircleAlert size={13} aria-hidden="true" />
이슈
</div>
<button
type="button"
onClick={() => {
if (hasIssues) onOpen(student);
}}
disabled={!hasIssues}
className={[
'mt-1 inline-flex h-7 min-w-7 items-center justify-center rounded-md px-2 text-[13px] font-black xl:mt-0',
hasIssues
? 'cursor-pointer bg-[#fff1f2] text-[#e11d48] transition-colors hover:bg-[#ffe4e6]'
: 'cursor-default bg-transparent text-[#111827]',
].join(' ')}
title={hasIssues ? '이슈 상세 보기' : undefined}
>
{student.issues}
</button>
</div>
);
}
function IssueModal({ student, onClose }: { student: InstructorStudent; onClose: () => void }) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#0f172a]/25 px-4">
<section className="w-full max-w-[520px] overflow-hidden rounded-lg border border-[#c7d2e0] bg-white shadow-[0_24px_70px_rgba(15,23,42,0.18)]">
<header className="flex items-center justify-between border-b border-[#cbd5e1] px-5 py-4">
<div className="min-w-0">
<h2 className="text-[16px] font-black text-[#111827]">이슈 상세</h2>
<p className="mt-1 truncate text-[12px] font-bold text-[#64748b]">{student.name} · {student.email}</p>
</div>
<button
className="grid h-8 w-8 place-items-center rounded-md text-[#64748b] hover:bg-[#f1f5f9] hover:text-[#111827]"
type="button"
onClick={onClose}
aria-label="닫기"
title="닫기"
>
<X size={17} aria-hidden="true" />
</button>
</header>
<div className="max-h-[520px] overflow-y-auto px-5 py-4">
{student.issueItems.length ? (
<div className="grid gap-2">
{student.issueItems.map((issue, index) => (
<article key={`${issue.type}-${issue.week ?? 'score'}-${index}`} className="rounded-lg border border-[#cbd5e1] bg-[#f8fafc] px-4 py-3">
<div className="flex items-start gap-3">
<span className="mt-0.5 grid h-7 w-7 shrink-0 place-items-center rounded-md bg-[#fff1f2] text-[#e11d48]">
<CircleAlert size={15} aria-hidden="true" />
</span>
<div className="min-w-0">
<p className="text-[13px] font-black text-[#111827]">{issue.title}</p>
<p className="mt-1 text-[12px] font-semibold leading-5 text-[#64748b]">{issue.detail}</p>
</div>
</div>
</article>
))}
</div>
) : (
<div className="grid h-[180px] place-items-center text-[13px] font-bold text-[#94a3b8]">표시할 이슈가 없습니다.</div>
)}
</div>
<footer className="flex justify-end border-t border-[#cbd5e1] px-5 py-4">
<button
type="button"
onClick={onClose}
className="h-9 rounded-md bg-[#2563eb] px-4 text-[13px] font-bold text-white hover:bg-[#1d4ed8]"
>
확인
</button>
</footer>
</section>
</div>
);
}
function SummaryCard({ label, value, icon: Icon, tone }: { label: string; value: string; icon: LucideIcon; tone: string }) {
return (
<article className="rounded-lg border border-[#c7d2e0] bg-white px-5 py-4">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-[12px] font-semibold text-[#64748b]">{label}</p>
<p className="mt-2 text-[20px] font-black leading-none text-[#111827]">{value}</p>
</div>
<span className={`grid h-11 w-11 shrink-0 place-items-center rounded-md ${tone}`}>
<Icon size={28} aria-hidden="true" />
</span>
</div>
</article>
);
}
+935
View File
@@ -0,0 +1,935 @@
import { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { BookOpen, CalendarDays, CheckCircle2, Clock3, FileCode2, FolderOpen, GitCommitHorizontal, Loader2, Save, UsersRound, UserRound, X, XCircle } from 'lucide-react';
import { useToast } from '../../components/ToastProvider';
import { PageHeader } from '../../components/PageHeader';
import { listInstructorSubmissions, saveInstructorSubmissionScore, type InstructorSubmissionItem } from '../../lib/submissions';
import type { AuthSession } from '../../types/auth';
type CommitFile = InstructorSubmissionItem['commits'][number]['files'][number];
type DiffViewLine = {
key: string;
type: 'file' | 'hunk' | 'context' | 'add' | 'delete' | 'note';
oldLine: number | null;
newLine: number | null;
content: string;
};
type PatchModalState = {
title: string;
subtitle: string;
files: CommitFile[];
activePath: string;
} | null;
const statusLabel: Record<InstructorSubmissionItem['status'], string> = {
SUBMITTED: '제출완료',
PENDING: '미제출',
NOT_INSTALLED: '미설치',
};
const statusTone: Record<InstructorSubmissionItem['status'], string> = {
SUBMITTED: 'bg-[#ecfdf5] text-[#15803d]',
PENDING: 'bg-[#fff7ed] text-[#c2410c]',
NOT_INSTALLED: 'bg-[#f1f5f9] text-[#64748b]',
};
const parseDiffViewLines = (diff: string): DiffViewLine[] => {
const viewLines: DiffViewLine[] = [];
let oldLine = 0;
let newLine = 0;
diff.split('\n').forEach((line, index) => {
if (line.startsWith('diff --git ')) {
const match = line.match(/^diff --git a\/(.+) b\/(.+)$/);
viewLines.push({
key: `file-${index}`,
type: 'file',
oldLine: null,
newLine: null,
content: match?.[2] ?? line.replace(/^diff --git\s+/, ''),
});
return;
}
if (
line.startsWith('index ') ||
line.startsWith('--- ') ||
line.startsWith('+++ ') ||
line.startsWith('new file mode ') ||
line.startsWith('deleted file mode ')
) {
return;
}
if (line.startsWith('\\ No newline')) {
viewLines.push({
key: `note-${index}`,
type: 'note',
oldLine: null,
newLine: null,
content: '파일 끝 줄바꿈 없음',
});
return;
}
if (line.startsWith('@@')) {
const match = line.match(/@@\s+-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@(.*)$/);
oldLine = Number(match?.[1] ?? 0);
newLine = Number(match?.[2] ?? 0);
viewLines.push({
key: `hunk-${index}`,
type: 'hunk',
oldLine: null,
newLine: null,
content: match?.[3]?.trim() || line,
});
return;
}
if (!viewLines.length) return;
if (line.startsWith('+')) {
viewLines.push({
key: `add-${index}`,
type: 'add',
oldLine: null,
newLine,
content: line.slice(1),
});
newLine += 1;
return;
}
if (line.startsWith('-')) {
viewLines.push({
key: `delete-${index}`,
type: 'delete',
oldLine,
newLine: null,
content: line.slice(1),
});
oldLine += 1;
return;
}
viewLines.push({
key: `context-${index}`,
type: 'context',
oldLine,
newLine,
content: line.startsWith(' ') ? line.slice(1) : line,
});
oldLine += 1;
newLine += 1;
});
return viewLines;
};
export function SubmissionsPage({ session }: { session: AuthSession }) {
const [items, setItems] = useState<InstructorSubmissionItem[]>([]);
const [students, setStudents] = useState<Array<{ id: string; name: string; email: string }>>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedCourseId, setSelectedCourseId] = useState<string | null>(null);
const [selectedWeekId, setSelectedWeekId] = useState<string | null>(null);
const [selectedGroupName, setSelectedGroupName] = useState<string | null>(null);
const [selectedStudentId, setSelectedStudentId] = useState<string | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [patchModal, setPatchModal] = useState<PatchModalState>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
listInstructorSubmissions(session.accessToken)
.then((payload) => {
if (cancelled) return;
setItems(payload.items);
setStudents(payload.students);
setSelectedCourseId((current) => current && payload.courses.some((course) => course.id === current) ? current : payload.courses[0]?.id ?? null);
setSelectedWeekId(null);
setSelectedGroupName(null);
setSelectedStudentId(null);
setSelectedId(null);
})
.catch((reason) => {
if (cancelled) return;
setError(reason instanceof Error ? reason.message : '제출 현황을 불러오지 못했습니다.');
setItems([]);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [session.accessToken]);
const courseNodes = useMemo(() => items.length
? Array.from(new Map(items.map((item) => [item.course.id, item.course])).values())
: [], [items]);
const selectedCourse = courseNodes.find((course) => course.id === selectedCourseId) ?? null;
const courseItems = useMemo(() => selectedCourse ? items.filter((item) => item.course.id === selectedCourse.id) : [], [items, selectedCourse]);
const weekNodes = useMemo(() => {
const byWeek = new Map<string, {
id: string;
week: number;
title: string;
total: number;
submitted: number;
pending: number;
notInstalled: number;
}>();
courseItems.forEach((item) => {
const current = byWeek.get(item.week.id) ?? {
id: item.week.id,
week: item.week.week,
title: item.week.title,
total: 0,
submitted: 0,
pending: 0,
notInstalled: 0,
};
current.total += 1;
if (item.status === 'SUBMITTED') current.submitted += 1;
if (item.status === 'PENDING') current.pending += 1;
if (item.status === 'NOT_INSTALLED') current.notInstalled += 1;
byWeek.set(item.week.id, current);
});
return Array.from(byWeek.values()).sort((a, b) => a.week - b.week);
}, [courseItems]);
const selectedWeek = weekNodes.find((week) => week.id === selectedWeekId) ?? null;
const weekItems = useMemo(() => selectedWeek ? courseItems.filter((item) => item.week.id === selectedWeek.id) : [], [courseItems, selectedWeek]);
const groupNodes = useMemo(() => {
const groupMap = new Map<string, { name: string; total: number; submitted: number; pending: number; notInstalled: number }>();
weekItems.forEach((item) => {
const group = item.student.group || '미배정';
const current = groupMap.get(group) ?? { name: group, total: 0, submitted: 0, pending: 0, notInstalled: 0 };
current.total += 1;
if (item.status === 'SUBMITTED') current.submitted += 1;
if (item.status === 'PENDING') current.pending += 1;
if (item.status === 'NOT_INSTALLED') current.notInstalled += 1;
groupMap.set(group, current);
});
return Array.from(groupMap.values()).sort((a, b) => a.name.localeCompare(b.name, 'ko'));
}, [weekItems]);
const selectedGroup = groupNodes.find((group) => group.name === selectedGroupName) ?? null;
const studentNodes = useMemo(() => students.map((student) => {
const studentItems = weekItems.filter((item) => item.student.group === selectedGroup?.name && item.student.id === student.id);
return {
...student,
total: studentItems.length,
submitted: studentItems.filter((item) => item.status === 'SUBMITTED').length,
pending: studentItems.filter((item) => item.status === 'PENDING').length,
notInstalled: studentItems.filter((item) => item.status === 'NOT_INSTALLED').length,
};
}).filter((student) => student.total > 0), [selectedGroup?.name, students, weekItems]);
const selectedStudent = studentNodes.find((student) => student.id === selectedStudentId) ?? null;
const detailItems = weekItems.filter((item) => item.student.id === selectedStudent?.id);
const selectedItem = detailItems.find((item) => item.id === selectedId) ?? null;
useEffect(() => {
if (selectedCourseId && courseNodes.some((course) => course.id === selectedCourseId)) return;
setSelectedCourseId(courseNodes[0]?.id ?? null);
setSelectedWeekId(null);
setSelectedGroupName(null);
setSelectedStudentId(null);
setSelectedId(null);
}, [courseNodes, selectedCourseId]);
useEffect(() => {
if (selectedWeekId && weekNodes.some((week) => week.id === selectedWeekId)) return;
setSelectedWeekId(null);
setSelectedGroupName(null);
setSelectedId(null);
setSelectedStudentId(null);
}, [selectedWeekId, weekNodes]);
useEffect(() => {
if (selectedGroupName && groupNodes.some((group) => group.name === selectedGroupName)) return;
setSelectedGroupName(null);
setSelectedStudentId(null);
setSelectedId(null);
}, [groupNodes, selectedGroupName]);
useEffect(() => {
if (selectedStudentId && studentNodes.some((student) => student.id === selectedStudentId)) return;
setSelectedStudentId(null);
setSelectedId(null);
}, [selectedStudentId, studentNodes, weekItems]);
useEffect(() => {
if (selectedId && detailItems.some((item) => item.id === selectedId)) return;
setSelectedId(null);
}, [detailItems, selectedId]);
const handleSaveScore = async (item: InstructorSubmissionItem, score: number) => {
const result = await saveInstructorSubmissionScore(session.accessToken, {
studentId: item.student.id,
weekId: item.week.id,
score,
});
setItems((current) => current.map((entry) => (
entry.student.id === item.student.id && entry.week.id === item.week.id
? { ...entry, score: result.score }
: entry
)));
return result.score;
};
return (
<div className="mx-auto max-w-[1320px]">
<PageHeader route="submissions" />
<section className="grid gap-4">
<div className="grid gap-3 rounded-lg border border-[#c7d2e0] bg-white p-3 xl:grid-cols-4">
<SelectionPanel icon={<BookOpen size={15} />} title="강좌" count={courseNodes.length}>
{loading ? <PanelLoadingState message="강좌를 불러오는 중입니다." /> : error ? <EmptyState message={error} /> : courseNodes.length ? courseNodes.map((course) => {
const courseItemCount = items.filter((item) => item.course.id === course.id).length;
const submittedCount = items.filter((item) => item.course.id === course.id && item.status === 'SUBMITTED').length;
return (
<button
key={course.id}
type="button"
onClick={() => {
setSelectedCourseId(course.id);
setSelectedWeekId(null);
setSelectedGroupName(null);
setSelectedStudentId(null);
setSelectedId(null);
}}
className={selectionButtonClass(selectedCourse?.id === course.id)}
>
<span className="min-w-0">
<span className="block truncate text-[13px] font-black text-[#111827]">{course.title}</span>
<span className="mt-1 block text-[11px] font-bold text-[#64748b]">제출 {submittedCount}/{courseItemCount}</span>
</span>
</button>
);
}) : <EmptyState message="조회된 강좌가 없습니다." />}
</SelectionPanel>
<SelectionPanel icon={<CalendarDays size={15} />} title="주차" count={weekNodes.length}>
{loading ? <PanelLoadingState message="주차를 불러오는 중입니다." /> : !selectedCourse ? <EmptyState message="강좌를 선택하세요." /> : weekNodes.length ? weekNodes.map((week) => (
<button
key={week.id}
type="button"
onClick={() => {
setSelectedWeekId(week.id);
setSelectedGroupName(null);
setSelectedStudentId(null);
setSelectedId(null);
}}
className={selectionButtonClass(selectedWeek?.id === week.id)}
>
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-md bg-[#eef2ff] text-[12px] font-black text-[#4f46e5]">{week.week}</span>
<span className="min-w-0">
<span className="block truncate text-[13px] font-black text-[#111827]">{week.title}</span>
<span className="mt-1 block text-[11px] font-bold text-[#64748b]">제출 {week.submitted}/{week.total} · 미제출 {week.pending}</span>
</span>
</button>
)) : <EmptyState message="조회된 주차가 없습니다." />}
</SelectionPanel>
<SelectionPanel icon={<UsersRound size={15} />} title="그룹" count={groupNodes.length}>
{!selectedWeek ? <EmptyState message="주차를 선택하세요." /> : groupNodes.length ? groupNodes.map((group) => (
<button
key={group.name}
type="button"
onClick={() => {
setSelectedGroupName(group.name);
setSelectedStudentId(null);
setSelectedId(null);
}}
className={selectionButtonClass(selectedGroup?.name === group.name)}
>
<span className="min-w-0">
<span className="block truncate text-[13px] font-black text-[#111827]">{group.name}</span>
<span className="mt-1 block text-[11px] font-bold text-[#64748b]">제출 {group.submitted}/{group.total} · 미제출 {group.pending} · 미설치 {group.notInstalled}</span>
</span>
</button>
)) : <EmptyState message="조회된 그룹이 없습니다." />}
</SelectionPanel>
<SelectionPanel icon={<UserRound size={15} />} title="수강생" count={studentNodes.length}>
{!selectedGroup ? <EmptyState message="그룹을 선택하세요." /> : studentNodes.length ? studentNodes.map((student) => (
<button
key={student.id}
type="button"
onClick={() => {
setSelectedStudentId(student.id);
const firstItem = weekItems.find((item) => item.student.id === student.id);
setSelectedId(firstItem?.id ?? null);
}}
className={selectionButtonClass(selectedStudent?.id === student.id)}
>
<span className="min-w-0">
<span className="block truncate text-[13px] font-black text-[#111827]">{student.name}</span>
<span className="mt-1 block truncate text-[11px] font-bold text-[#64748b]">{student.email}</span>
<span className="mt-1 block text-[11px] font-bold text-[#64748b]">제출 {student.submitted}/{student.total} · 미제출 {student.pending}</span>
</span>
</button>
)) : <EmptyState message="조회된 수강생이 없습니다." />}
</SelectionPanel>
</div>
<section className="overflow-hidden rounded-lg border border-[#c7d2e0] bg-white">
<SubmissionDetail
item={selectedItem}
items={detailItems}
emptyMessage={selectedStudent ? '제출 내역이 없습니다.' : '수강생을 선택하세요.'}
onSelect={setSelectedId}
onSaveScore={handleSaveScore}
onOpenPatch={(title, subtitle, files, activePath) => setPatchModal({ title, subtitle, files, activePath })}
/>
</section>
</section>
{patchModal ? createPortal(
<PatchModal
modal={patchModal}
onChangeActivePath={(activePath) => setPatchModal((current) => current ? { ...current, activePath } : current)}
onClose={() => setPatchModal(null)}
/>,
document.body,
) : null}
</div>
);
}
function SubmissionDetail({
item,
items,
emptyMessage,
onSelect,
onSaveScore,
onOpenPatch,
}: {
item: InstructorSubmissionItem | null;
items: InstructorSubmissionItem[];
emptyMessage: string;
onSelect: (id: string) => void;
onSaveScore: (item: InstructorSubmissionItem, score: number) => Promise<number>;
onOpenPatch: (title: string, subtitle: string, files: CommitFile[], activePath: string) => void;
}) {
const [detailTab, setDetailTab] = useState<'final' | 'history'>('final');
const [scoreDraft, setScoreDraft] = useState('0');
const [scoreSaving, setScoreSaving] = useState(false);
const [scoreMessage, setScoreMessage] = useState<{ tone: 'success' | 'error'; text: string } | null>(null);
const { showToast } = useToast();
useEffect(() => {
setScoreDraft(String(item?.score ?? 0));
setScoreMessage(null);
}, [item?.id, item?.score]);
const handleScoreSave = async () => {
if (!item) return;
const score = Number(scoreDraft);
if (!Number.isInteger(score) || score < 1 || score > 100) {
setScoreMessage({ tone: 'error', text: '1~100점으로 입력해 주세요.' });
showToast({
title: '점수 저장 실패',
message: '총점은 1~100점으로 입력해 주세요.',
variant: 'error',
});
return;
}
setScoreSaving(true);
setScoreMessage(null);
try {
const savedScore = await onSaveScore(item, score);
setScoreDraft(String(savedScore));
setScoreMessage({ tone: 'success', text: '점수가 저장되었습니다.' });
showToast({
title: '점수가 저장되었습니다.',
message: `${item.student.name} · ${item.week.week}주차 과정 총점 ${savedScore}점`,
variant: 'success',
});
} catch (error) {
const message = error instanceof Error ? error.message : '점수를 저장하지 못했습니다.';
setScoreMessage({
tone: 'error',
text: message,
});
showToast({
title: '점수 저장 실패',
message,
variant: 'error',
});
} finally {
setScoreSaving(false);
}
};
if (!item) {
return <div className="grid min-h-[420px] place-items-center text-[13px] font-bold text-[#94a3b8]">{emptyMessage}</div>;
}
return (
<div className="min-w-0">
<div className="border-b border-[#cbd5e1] px-5 py-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-[12px] font-black text-[#4f46e5]">{item.course.title}</p>
<h2 className="mt-1 truncate text-[17px] font-black text-[#111827]">{item.student.name} · {item.module.name}</h2>
<p className="mt-1 text-[12px] font-bold text-[#64748b]">{item.week.week}주차 {item.week.title} · {item.student.email}</p>
</div>
<span className={`inline-flex h-8 items-center gap-1.5 rounded-md px-3 text-[12px] font-black ${statusTone[item.status]}`}>
{item.status === 'SUBMITTED' ? <CheckCircle2 size={14} /> : item.status === 'PENDING' ? <Clock3 size={14} /> : <XCircle size={14} />}
{statusLabel[item.status]}
</span>
</div>
</div>
<div className="grid max-h-[760px] overflow-hidden lg:grid-cols-[280px_minmax(0,1fr)]">
<div className="flex min-h-0 flex-col border-r border-[#cbd5e1] p-3">
<p className="mb-2 px-1 text-[11px] font-black text-[#64748b]">제출 내역</p>
<div className="min-h-0 flex-1 overflow-y-auto">
{items.map((entry) => (
<button
key={entry.id}
type="button"
onClick={() => onSelect(entry.id)}
className={`${selectionButtonClass(entry.id === item.id)} mb-2 min-h-[62px]`}
>
<span className="min-w-0 flex-1">
<span className="flex items-start justify-between gap-2">
<span className="min-w-0 truncate text-[12px] font-black text-[#111827]">{entry.module.name}</span>
<span className={`shrink-0 rounded px-1.5 py-0.5 text-[10px] font-black ${statusTone[entry.status]}`}>{statusLabel[entry.status]}</span>
</span>
<span className="mt-1 block text-[11px] font-bold text-[#64748b]">{entry.commitCount}개 커밋 · {formatDate(entry.submittedAt)}</span>
</span>
</button>
))}
</div>
<section className="mt-3 rounded-lg border border-[#d1d9e6] bg-[#f8fafc] p-3">
<div className="flex items-center justify-between gap-3">
<p className="text-[12px] font-black text-[#334155]">과정 총점</p>
</div>
<div className="mt-2 flex items-center gap-2">
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
maxLength={3}
value={scoreDraft}
onChange={(event) => {
const digits = event.target.value.replace(/\D/g, '');
if (!digits) {
setScoreDraft('');
return;
}
setScoreDraft(String(Math.min(100, Number(digits))));
}}
onBlur={() => {
if (!scoreDraft) setScoreDraft('0');
}}
className="h-10 min-w-0 flex-1 rounded-md border border-[#cbd5e1] bg-white px-3 text-right text-[14px] font-black text-[#111827] outline-none transition-colors focus:border-[#4f46e5]"
aria-label="총점"
/>
<span className="text-[12px] font-black text-[#64748b]">점</span>
<button
type="button"
onClick={handleScoreSave}
disabled={scoreSaving}
className="inline-flex h-10 cursor-pointer items-center gap-1.5 rounded-md bg-[#4f46e5] px-3 text-[12px] font-black text-white transition-colors hover:bg-[#4338ca] disabled:cursor-wait disabled:bg-[#a5b4fc]"
>
{scoreSaving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
저장
</button>
</div>
{scoreMessage ? (
<p className={[
'mt-2 text-[11px] font-bold',
scoreMessage.tone === 'success' ? 'text-[#15803d]' : 'text-[#dc2626]',
].join(' ')}
>
{scoreMessage.text}
</p>
) : null}
</section>
</div>
<div className="min-w-0 overflow-y-auto px-5 py-4">
<div className="mb-4 flex items-center justify-between gap-3">
<div className="inline-flex rounded-lg bg-[#eef2f7] p-1">
<button
type="button"
onClick={() => setDetailTab('final')}
className={[
'h-8 cursor-pointer rounded-md px-3 text-[12px] font-black transition-colors',
detailTab === 'final' ? 'bg-white text-[#4f46e5] shadow-sm' : 'text-[#64748b] hover:text-[#111827]',
].join(' ')}
>
최종 변경사항
</button>
<button
type="button"
onClick={() => setDetailTab('history')}
className={[
'h-8 cursor-pointer rounded-md px-3 text-[12px] font-black transition-colors',
detailTab === 'history' ? 'bg-white text-[#4f46e5] shadow-sm' : 'text-[#64748b] hover:text-[#111827]',
].join(' ')}
>
제출 이력
</button>
</div>
<p className="text-[11px] font-bold text-[#94a3b8]">
최종 {item.finalFiles.length}개 파일 · 제출 {item.commitCount}회
</p>
</div>
{detailTab === 'final' ? (
item.finalFiles.length ? (
<article className="min-w-0 overflow-hidden rounded-lg border border-[#d1d9e6] transition-[border-color,box-shadow] hover:border-[#c7d2fe] hover:shadow-[0_10px_26px_rgba(15,23,42,0.10)]">
<div className="border-b border-[#cbd5e1] bg-[#f8fafc] px-4 py-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-[13px] font-black text-[#111827]">원본 대비 최종 변경사항</p>
<p className="mt-1 text-[12px] font-semibold text-[#64748b]">마지막 제출 기준 · {formatDate(item.submittedAt)}</p>
</div>
<span className="shrink-0 rounded bg-[#eef2ff] px-2.5 py-1 text-[11px] font-black text-[#4f46e5]">{item.finalFiles.length}개 파일</span>
</div>
</div>
<div className="divide-y divide-[#edf2f7]">
{item.finalFiles.map((file) => (
<button
key={`${item.id}-final-${file.path}`}
type="button"
onClick={() => onOpenPatch(
`${item.module.name} · 최종 변경사항`,
`${item.student.name} · ${formatDate(item.submittedAt)}`,
item.finalFiles,
file.path,
)}
className="group flex w-full cursor-pointer items-center justify-between gap-3 px-4 py-3 text-left transition-[background-color,color] hover:bg-[#e2e8f0]"
>
<span className="inline-flex min-w-0 items-center gap-2">
<FileCode2 size={14} className="shrink-0 text-[#64748b]" />
<span className="truncate font-mono text-[12px] font-bold text-[#334155]">{file.path}</span>
</span>
<span className="shrink-0 rounded bg-[#f1f5f9] px-2 py-1 text-[10px] font-black uppercase text-[#64748b] transition-colors group-hover:bg-white">{file.status}</span>
</button>
))}
</div>
</article>
) : (
<div className="grid h-[360px] place-items-center text-center text-[13px] font-bold text-[#94a3b8]">
{item.status === 'NOT_INSTALLED' ? '아직 모듈 저장소가 생성되지 않았습니다.' : '최종 변경 파일이 없습니다.'}
</div>
)
) : item.commits.length ? (
<div className="grid gap-4">
{item.commits.map((commit) => (
<article key={commit.hash} className="min-w-0 overflow-hidden rounded-lg border border-[#d1d9e6] transition-[border-color,box-shadow] hover:border-[#c7d2fe] hover:shadow-[0_10px_26px_rgba(15,23,42,0.10)]">
<div className="border-b border-[#cbd5e1] bg-[#f8fafc] px-4 py-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-[13px] font-black text-[#111827]">{commit.message}</p>
<p className="mt-1 text-[12px] font-semibold text-[#64748b]">{formatDate(commit.date)}</p>
</div>
<span className="inline-flex items-center gap-1.5 rounded-md bg-white px-2.5 py-1 font-mono text-[11px] font-black text-[#4f46e5]">
<GitCommitHorizontal size={13} />
{commit.shortHash}
</span>
</div>
</div>
<div className="divide-y divide-[#edf2f7]">
{commit.files.length ? commit.files.map((file) => (
<button
key={`${commit.hash}-${file.path}`}
type="button"
onClick={() => onOpenPatch(
`${item.module.name} · ${commit.shortHash}`,
`${item.student.name} · ${formatDate(commit.date)}`,
commit.files,
file.path,
)}
className="group flex w-full cursor-pointer items-center justify-between gap-3 px-4 py-3 text-left transition-[background-color,color] hover:bg-[#e2e8f0]"
>
<span className="inline-flex min-w-0 items-center gap-2">
<FileCode2 size={14} className="shrink-0 text-[#64748b]" />
<span className="truncate font-mono text-[12px] font-bold text-[#334155]">{file.path}</span>
</span>
<span className="shrink-0 rounded bg-[#f1f5f9] px-2 py-1 text-[10px] font-black uppercase text-[#64748b] transition-colors group-hover:bg-white">{file.status}</span>
</button>
)) : (
<p className="px-4 py-4 text-[12px] font-bold text-[#94a3b8]">파일 목록 없음</p>
)}
</div>
</article>
))}
</div>
) : (
<div className="grid h-[360px] place-items-center text-center text-[13px] font-bold text-[#94a3b8]">
{item.status === 'NOT_INSTALLED' ? '아직 모듈 저장소가 생성되지 않았습니다.' : '아직 제출된 커밋이 없습니다.'}
</div>
)}
</div>
</div>
</div>
);
}
function SelectionPanel({ icon, title, count, children }: { icon: React.ReactNode; title: string; count: number; children: React.ReactNode }) {
return (
<section className="min-w-0 overflow-hidden rounded-lg border border-[#d1d9e6] bg-[#f8fafc]">
<div className="flex h-10 items-center justify-between border-b border-[#cbd5e1] px-3">
<span className="inline-flex items-center gap-2 text-[12px] font-black text-[#475569]">{icon}{title}</span>
<span className="text-[11px] font-black text-[#94a3b8]">{count}개</span>
</div>
<div className="grid max-h-[340px] min-h-[340px] gap-2 overflow-y-auto p-2 content-start">
{children}
</div>
</section>
);
}
function selectionButtonClass(active: boolean) {
return [
'relative flex min-h-[66px] w-full cursor-pointer items-center gap-3 overflow-hidden rounded-lg border py-2 pl-4 pr-3 text-left transition-[background-color,border-color,box-shadow] before:absolute before:left-0 before:top-0 before:h-full before:w-1.5 before:content-[""]',
active
? 'border-[#c7d2fe] bg-[#eef2ff] shadow-[0_8px_20px_rgba(79,70,229,0.10)] before:bg-[#2563eb]'
: 'border-[#cbd5e1] bg-white before:bg-transparent hover:border-[#c7d2fe] hover:bg-[#f8faff] hover:shadow-[0_8px_20px_rgba(79,70,229,0.10)] hover:before:bg-[#bfdbfe]',
].join(' ');
}
function PanelLoadingState({ message }: { message: string }) {
return (
<div className="grid min-h-[180px] place-items-center px-4 text-center text-[13px] font-bold text-[#64748b]">
<span className="inline-flex items-center gap-2">
<Loader2 className="animate-spin" size={17} aria-hidden="true" />
{message}
</span>
</div>
);
}
function PatchModal({
modal,
onChangeActivePath,
onClose,
}: {
modal: NonNullable<PatchModalState>;
onChangeActivePath: (path: string) => void;
onClose: () => void;
}) {
const activeFile = modal.files.find((file) => file.path === modal.activePath) ?? modal.files[0] ?? null;
const visibleFiles = modal.files.filter((file) => !isIgnoredSubmissionFile(file.path));
const visibleActiveFile = visibleFiles.find((file) => file.path === activeFile?.path) ?? visibleFiles[0] ?? null;
const treeNodes = buildFileTree(visibleFiles);
return (
<div className="fixed inset-0 z-[80] grid place-items-center bg-[#0f172a]/34 px-6 py-6 backdrop-blur-[2px]">
<section className="grid h-[min(760px,calc(100vh-48px))] w-full max-w-[1240px] grid-rows-[78px_minmax(0,1fr)] overflow-hidden rounded-[14px] border border-white/70 bg-[#fbfdff] shadow-[0_34px_100px_rgba(15,23,42,0.30)]">
<header className="flex items-center justify-between border-b border-[#dfe6f1] bg-[#fbfdff] px-5">
<div className="flex min-w-0 items-center gap-3">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-md bg-[#eef2ff] text-[#4f46e5] shadow-[inset_0_0_0_1px_rgba(79,70,229,0.08)]">
<FileCode2 size={18} />
</span>
<div className="min-w-0">
<h2 className="truncate text-[16px] font-black text-[#111827]">{modal.title}</h2>
<p className="mt-1 truncate text-[12px] font-bold text-[#64748b]">{modal.subtitle}</p>
</div>
</div>
<button
type="button"
onClick={onClose}
className="grid h-9 w-9 cursor-pointer place-items-center rounded-md border border-[#dbe3ef] bg-white text-[#475569] shadow-sm transition-[background-color,border-color,color] hover:border-[#c7d2fe] hover:bg-[#f8faff] hover:text-[#111827]"
aria-label="닫기"
>
<X size={17} />
</button>
</header>
<div className="grid min-h-0 grid-cols-[320px_minmax(0,1fr)]">
<aside className="min-h-0 border-r border-[#dfe6f1] bg-[#fbfdff]">
<div className="flex h-[52px] items-center justify-between px-5">
<span className="text-[13px] font-black text-[#334155]">파일 목록</span>
<span className="rounded-full bg-[#eef2f7] px-2.5 py-1 text-[11px] font-black text-[#475569]">{visibleFiles.length}개</span>
</div>
<div className="h-[calc(100%-52px)] overflow-auto px-4 pb-4">
{treeNodes.map((node) => (
<FileTreeNode
key={node.path}
node={node}
activePath={visibleActiveFile?.path ?? ''}
onSelect={onChangeActivePath}
/>
))}
</div>
</aside>
<main className="grid min-h-0 grid-rows-[60px_minmax(0,1fr)] bg-[#fbfdff]">
<div className="flex items-center justify-between gap-3 px-5">
<div className="flex min-w-0 items-center gap-2.5">
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-md border border-[#d1d9e6] bg-white text-[#334155]">
<FileCode2 size={16} />
</span>
<span
className="min-w-0 truncate text-[14px] font-black text-[#1f2937]"
title={visibleActiveFile?.path ?? undefined}
>
{visibleActiveFile?.path.split('/').slice(-1)[0] ?? '파일 없음'}
</span>
</div>
{visibleActiveFile ? <span className="rounded-full bg-[#eef2ff] px-2.5 py-1 text-[10px] font-black uppercase text-[#4f46e5]">{visibleActiveFile.status}</span> : null}
</div>
<div className="min-h-0 overflow-auto px-5 pb-5">
{visibleActiveFile?.patch ? (
<div className="w-full min-w-0 overflow-hidden rounded-lg border border-[#d5deea] bg-white font-mono text-[11px] shadow-[0_8px_24px_rgba(15,23,42,0.06)]">
{parseDiffViewLines(visibleActiveFile.patch).map(renderDiffLine)}
</div>
) : (
<div className="grid h-full place-items-center text-[12px] font-bold text-[#94a3b8]">변경 내용 없음</div>
)}
</div>
</main>
</div>
</section>
</div>
);
}
function isIgnoredSubmissionFile(path: string) {
return path === '.gitignore' || path.endsWith('/.gitignore') || path === '.autonomy-studio-git.json';
}
function renderDiffLine(line: DiffViewLine) {
const rowClass = line.type === 'file'
? 'bg-[#edf4ff] text-[#4169e1]'
: line.type === 'hunk'
? 'bg-[#eef5ff] text-[#4169e1]'
: line.type === 'note'
? 'bg-[#fff7d6] text-[#9a5200]'
: line.type === 'add'
? 'bg-[#e8fff0] text-[#166534]'
: line.type === 'delete'
? 'bg-[#ffe3ea] text-[#9f1239]'
: 'bg-white text-[#263247]';
const marker = line.type === 'file' ? 'file' : line.type === 'add' ? '+' : line.type === 'delete' ? '-' : line.type === 'hunk' ? '@' : line.type === 'note' ? '!' : ' ';
return (
<div key={line.key} className={`grid min-w-0 grid-cols-[48px_48px_34px_minmax(0,1fr)] border-b border-[#dfe6f1] last:border-b-0 ${rowClass}`}>
<span className="px-2 py-1.5 text-right font-normal tabular-nums text-[#8b99ad]">{line.oldLine ?? ''}</span>
<span className="px-2 py-1.5 text-right font-normal tabular-nums text-[#8b99ad]">{line.newLine ?? ''}</span>
<span className="px-2 py-1.5 text-center font-semibold">{marker}</span>
<code className={`min-w-0 max-w-full whitespace-pre-wrap break-all px-3 py-1.5 font-normal ${line.type === 'file' || line.type === 'hunk' ? 'font-sans' : ''}`}>
{line.content || ' '}
</code>
</div>
);
}
type FileTreeNodeModel = {
name: string;
path: string;
kind: 'folder' | 'file';
file?: CommitFile;
children: FileTreeNodeModel[];
};
function buildFileTree(files: CommitFile[]): FileTreeNodeModel[] {
const roots: FileTreeNodeModel[] = [];
const nodeMap = new Map<string, FileTreeNodeModel>();
files.forEach((file) => {
const parts = file.path.split('/').filter(Boolean);
parts.forEach((part, index) => {
const path = parts.slice(0, index + 1).join('/');
const isFile = index === parts.length - 1;
if (!nodeMap.has(path)) {
const node: FileTreeNodeModel = {
name: part,
path,
kind: isFile ? 'file' : 'folder',
file: isFile ? file : undefined,
children: [],
};
nodeMap.set(path, node);
const parentPath = parts.slice(0, index).join('/');
const parent = parentPath ? nodeMap.get(parentPath) : null;
if (parent) parent.children.push(node);
else roots.push(node);
}
});
});
return roots;
}
function FileTreeNode({
node,
activePath,
onSelect,
depth = 0,
}: {
node: FileTreeNodeModel;
activePath: string;
onSelect: (path: string) => void;
depth?: number;
}) {
const active = node.kind === 'file' && node.path === activePath;
if (node.kind === 'folder') {
return (
<div>
<div
className="mb-1 flex h-7 items-center gap-2 rounded-md pr-2 text-[11px] font-black text-[#64748b] transition-colors hover:bg-[#f8faff] hover:text-[#334155]"
style={{ paddingLeft: depth * 14 }}
title={node.path}
>
<FolderOpen size={14} className="shrink-0 text-[#94a3b8]" />
<span className="truncate">{node.name}</span>
</div>
{node.children.map((child) => (
<FileTreeNode key={child.path} node={child} activePath={activePath} onSelect={onSelect} depth={depth + 1} />
))}
</div>
);
}
return (
<button
type="button"
onClick={() => onSelect(node.path)}
className={[
'mb-1.5 grid min-h-[58px] w-full cursor-pointer grid-cols-[24px_minmax(0,1fr)_auto] items-center gap-2 rounded-md border py-2 pr-2.5 text-left transition-[background-color,border-color,box-shadow,color]',
active ? 'border-[#c7d2fe] bg-[#eef2ff] text-[#4f46e5] shadow-[inset_4px_0_0_#4f46e5]' : 'border-transparent bg-transparent text-[#334155] hover:border-[#cbd5e1] hover:bg-[#f1f5f9] hover:shadow-[0_8px_18px_rgba(15,23,42,0.08)]',
].join(' ')}
style={{ paddingLeft: depth * 12 + 10 }}
title={node.path}
>
<FileCode2 size={14} className="shrink-0 text-[#64748b]" />
<span className="min-w-0 flex-1">
<span className="block truncate text-[12px] font-black text-[#111827]">{node.name}</span>
<span className="mt-0.5 block truncate font-mono text-[10px] font-semibold text-[#64748b]">{node.path}</span>
</span>
{node.file ? <span className="shrink-0 rounded bg-[#eef2ff] px-1.5 py-0.5 text-[9px] font-black uppercase text-[#4f46e5]">{node.file.status}</span> : null}
</button>
);
}
function EmptyState({ message }: { message: string }) {
return <div className="grid min-h-[180px] place-items-center px-4 text-center text-[13px] font-bold text-[#94a3b8]">{message}</div>;
}
function formatDate(value: string | null) {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString('ko-KR', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
}
+57
View File
@@ -0,0 +1,57 @@
import { Activity, BarChart3, BookOpen, Car, ClipboardList, GitPullRequest, Settings, Users, type LucideIcon } from 'lucide-react';
export type AppRoute = 'dashboard' | 'curriculum' | 'groups' | 'platforms' | 'students' | 'submissions' | 'analytics' | 'settings' | 'profile';
export type NavItem = {
id: AppRoute;
label: string;
icon: LucideIcon;
// children?: string[];
};
export const defaultRoute: AppRoute = 'dashboard';
export const routePaths: Record<AppRoute, string> = {
dashboard: '/dashboard',
curriculum: '/curriculum',
groups: '/groups',
platforms: '/platforms',
students: '/students',
submissions: '/submissions',
analytics: '/analytics',
settings: '/settings',
profile: '/profile'
};
export const navItems: NavItem[] = [
{ id: 'dashboard', label: '대시보드', icon: Activity },
{ id: 'curriculum', label: '커리큘럼 관리', icon: ClipboardList },
{ id: 'groups', label: '그룹 관리', icon: Users },
{ id: 'platforms', label: '플랫폼 관리', icon: Car },
{ id: 'students', label: '수강생 현황', icon: BookOpen },
{ id: 'submissions', label: '제출 현황', icon: GitPullRequest },
{ id: 'analytics', label: '통계 분석', icon: BarChart3 },
{ id: 'settings', label: '설정', icon: Settings }
];
// 2depth menu candidates. Keep this until the left menu structure is finalized.
// export const navItemChildren: Record<Exclude<AppRoute, 'profile'>, string[]> = {
// dashboard: ['운영 요약', '오늘 할 일', '빠른 설정'],
// groups: ['그룹 목록', '플랫폼 배정', '실습 주차'],
// platforms: ['실시간 상태', '긴급 정지 로그', '장비 설정'],
// students: ['수강생 목록', '접속 현황', '과제 제출'],
// analytics: ['진도 분석', '플랫폼 사용률', '질문 추이'],
// settings: ['권한 정책', '알림 설정', '서비스 연결']
// };
const appRoutes: AppRoute[] = [...navItems.map((item) => item.id), 'profile'];
export const isAppRoute = (value: string): value is AppRoute => appRoutes.includes(value as AppRoute);
export const getRoutePath = (route: AppRoute) => routePaths[route];
export const getRouteFromPathname = (pathname: string): AppRoute | null => {
const normalizedPath = pathname === '/' ? routePaths[defaultRoute] : pathname.replace(/\/+$/, '');
const entry = Object.entries(routePaths).find(([, path]) => path === normalizedPath);
return entry ? (entry[0] as AppRoute) : null;
};
+38
View File
@@ -0,0 +1,38 @@
export type AuthAppRole = {
appKey?: string;
app_key?: string;
roleCode?: string;
role_code?: string;
role?: string;
code?: string;
};
export type AuthUser = {
id: string;
email: string;
name: string | null;
role: string;
roles?: string[];
appRoles?: AuthAppRole[];
app_roles?: AuthAppRole[];
primaryAffiliation?: {
id: string;
organizationId: string;
organizationName: string;
organizationType: string;
department: string | null;
position: string | null;
} | null;
};
export type AuthSession = {
accessToken: string;
obtainedAt?: number;
accessTokenExpiresIn?: number;
refreshToken?: string;
refreshTokenExpiresIn?: number;
roles?: string[];
appRoles?: AuthAppRole[];
app_roles?: AuthAppRole[];
user: AuthUser;
};
+12
View File
@@ -0,0 +1,12 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string;
readonly VITE_LAUNCHER_API_BASE_URL?: string;
readonly VITE_ATNMY_AUTH_URL?: string;
readonly VITE_ADS_IDE_AUTH_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}