platform/lms-adm-fe release promotion snapshot
This commit is contained in:
+59
-1
@@ -3,7 +3,7 @@ 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 { createInitialInstructorCourse, listInstructorCourses, type InstructorCourse } from './lib/courses';
|
||||
import { AnalyticsPage } from './pages/analytics/AnalyticsPage';
|
||||
import { CurriculumPage } from './pages/curriculum/CurriculumPage';
|
||||
import { DashboardPage } from './pages/dashboard/DashboardPage';
|
||||
@@ -22,6 +22,10 @@ function App() {
|
||||
const [courses, setCourses] = useState<InstructorCourse[]>([]);
|
||||
const [selectedCourseId, setSelectedCourseId] = useState(() => window.localStorage.getItem('lms-admin-selected-course-id') ?? '');
|
||||
const [sessionExpired, setSessionExpired] = useState(false);
|
||||
const [initialCourseOpen, setInitialCourseOpen] = useState(false);
|
||||
const [initialCourseTitle, setInitialCourseTitle] = useState('');
|
||||
const [initialCourseError, setInitialCourseError] = useState<string | null>(null);
|
||||
const [creatingInitialCourse, setCreatingInitialCourse] = useState(false);
|
||||
const [chatThreadOpenRequest, setChatThreadOpenRequest] = useState<{ threadId: string; requestId: number } | null>(null);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
@@ -96,6 +100,7 @@ function App() {
|
||||
.then((payload) => {
|
||||
if (cancelled) return;
|
||||
setCourses(payload.courses);
|
||||
setInitialCourseOpen(payload.courses.length === 0);
|
||||
setSelectedCourseId((current) => {
|
||||
const next = payload.courses.some((course) => course.id === current)
|
||||
? current
|
||||
@@ -117,6 +122,28 @@ function App() {
|
||||
if (courseId) window.localStorage.setItem('lms-admin-selected-course-id', courseId);
|
||||
};
|
||||
|
||||
const handleCreateInitialCourse = async () => {
|
||||
const title = initialCourseTitle.trim();
|
||||
if (!title) {
|
||||
setInitialCourseError('강좌명을 입력해 주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setCreatingInitialCourse(true);
|
||||
setInitialCourseError(null);
|
||||
try {
|
||||
const { course } = await createInitialInstructorCourse(session!.accessToken, title);
|
||||
setCourses([course]);
|
||||
setSelectedCourseId(course.id);
|
||||
window.localStorage.setItem('lms-admin-selected-course-id', course.id);
|
||||
setInitialCourseOpen(false);
|
||||
} catch (error) {
|
||||
setInitialCourseError(error instanceof Error ? error.message : '강좌를 생성하지 못했습니다.');
|
||||
} finally {
|
||||
setCreatingInitialCourse(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!session?.accessToken || !session.refreshToken) return;
|
||||
|
||||
@@ -203,6 +230,37 @@ function App() {
|
||||
/>
|
||||
</div>
|
||||
</InstructorLayout>
|
||||
{initialCourseOpen && (
|
||||
<div className="fixed inset-0 z-[100] grid place-items-center bg-[#111827]/35 px-5" role="dialog" aria-modal="true" aria-labelledby="initial-course-title">
|
||||
<form
|
||||
className="w-full max-w-[400px] rounded-lg border border-[#d9e0ea] bg-white p-6 shadow-[0_22px_70px_rgba(15,23,42,0.22)]"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void handleCreateInitialCourse();
|
||||
}}
|
||||
>
|
||||
<h1 id="initial-course-title" className="text-[18px] font-black text-[#111827]">강좌명을 입력하세요</h1>
|
||||
<p className="mt-2 text-[13px] leading-relaxed text-[#64748b]">NAV2 기반 자율주행 교육 과정 구성을 새 강좌로 복제합니다.</p>
|
||||
<input
|
||||
autoFocus
|
||||
value={initialCourseTitle}
|
||||
maxLength={120}
|
||||
onChange={(event) => setInitialCourseTitle(event.target.value)}
|
||||
className="mt-5 h-10 w-full rounded-md border border-[#cbd5e1] px-3 text-[14px] font-semibold text-[#111827] outline-none focus:border-[#2563eb]"
|
||||
placeholder="예: 2026 하반기 자율주행 교육"
|
||||
disabled={creatingInitialCourse}
|
||||
/>
|
||||
{initialCourseError && <p className="mt-2 text-[12px] font-semibold text-[#dc2626]">{initialCourseError}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={creatingInitialCourse}
|
||||
className="mt-5 h-10 w-full rounded-md bg-[#2563eb] text-[14px] font-black text-white transition hover:bg-[#1d4ed8] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{creatingInitialCourse ? '생성 중...' : '확인'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { AuthSession } from '../types/auth';
|
||||
|
||||
type InstructorChatPanelProps = {
|
||||
session: AuthSession;
|
||||
courseId: string;
|
||||
open: boolean;
|
||||
initialThreadId?: string | null;
|
||||
initialThreadRequestId?: number;
|
||||
@@ -30,7 +31,7 @@ type ChatNotificationPayload = {
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export function InstructorChatPanel({ session, open, initialThreadId = null, initialThreadRequestId = 0, onClose }: InstructorChatPanelProps) {
|
||||
export function InstructorChatPanel({ session, courseId, open, initialThreadId = null, initialThreadRequestId = 0, onClose }: InstructorChatPanelProps) {
|
||||
const [threads, setThreads] = useState<InstructorChatThread[]>([]);
|
||||
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -45,7 +46,7 @@ export function InstructorChatPanel({ session, open, initialThreadId = null, ini
|
||||
if (showLoading) setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const request = listInstructorChatThreads(session.accessToken)
|
||||
const request = listInstructorChatThreads(session.accessToken, courseId)
|
||||
.then((items) => {
|
||||
if (!cancelled) setThreads(items);
|
||||
})
|
||||
@@ -91,7 +92,7 @@ export function InstructorChatPanel({ session, open, initialThreadId = null, ini
|
||||
return () => {
|
||||
refresh.cancel();
|
||||
};
|
||||
}, [open, session.accessToken]);
|
||||
}, [courseId, open, session.accessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !initialThreadId) return;
|
||||
@@ -124,7 +125,7 @@ export function InstructorChatPanel({ session, open, initialThreadId = null, ini
|
||||
socket.off('chat.message.created', handleMessageCreated);
|
||||
socket.disconnect();
|
||||
};
|
||||
}, [open, selectedThreadId, session.accessToken]);
|
||||
}, [courseId, open, selectedThreadId, session.accessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setSelectedThreadId(null);
|
||||
|
||||
@@ -505,6 +505,7 @@ export function InstructorLayout({ session, route, onNavigate, onLogout, courses
|
||||
|
||||
<InstructorChatPanel
|
||||
session={session}
|
||||
courseId={selectedCourseId}
|
||||
open={chatOpen}
|
||||
initialThreadId={chatThreadId}
|
||||
initialThreadRequestId={chatThreadRequestId}
|
||||
|
||||
+2
-1
@@ -49,8 +49,9 @@ type ChatThreadsPayload = {
|
||||
data?: unknown[];
|
||||
};
|
||||
|
||||
export async function listInstructorChatThreads(accessToken: string, week?: number): Promise<InstructorChatThread[]> {
|
||||
export async function listInstructorChatThreads(accessToken: string, courseId: string, week?: number): Promise<InstructorChatThread[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (courseId) params.set('courseId', courseId);
|
||||
if (week) params.set('week', String(week));
|
||||
const queryString = params.toString();
|
||||
|
||||
|
||||
@@ -24,3 +24,20 @@ export async function listInstructorCourses(accessToken: string): Promise<{ cour
|
||||
if (!response.ok) throw new Error(`강좌 목록을 불러오지 못했습니다. (${response.status})`);
|
||||
return response.json() as Promise<{ courses: InstructorCourse[] }>;
|
||||
}
|
||||
|
||||
export async function createInitialInstructorCourse(accessToken: string, title: string): Promise<{ course: InstructorCourse }> {
|
||||
const response = await fetch(`${LMS_API_BASE_URL}/instructor/courses/initial`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ title }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null) as { message?: string } | null;
|
||||
throw new Error(payload?.message || `강좌를 생성하지 못했습니다. (${response.status})`);
|
||||
}
|
||||
return response.json() as Promise<{ course: InstructorCourse }>;
|
||||
}
|
||||
|
||||
@@ -26,10 +26,6 @@ function isPlatformEnvironment(config: unknown): boolean {
|
||||
return target === 'remote-jetson' || target === 'platform';
|
||||
}
|
||||
|
||||
function environmentStorageKey(profile: Pick<CurriculumEnvironmentProfile, 'config' | 'profileType'>): 'local' | 'platform' {
|
||||
return profile.profileType.toUpperCase() === 'PLATFORM' || isPlatformEnvironment(profile.config) ? 'platform' : 'local';
|
||||
}
|
||||
|
||||
function parseModuleParameter(parameter: string): ModuleParameterDraft {
|
||||
const separatorIndex = parameter.indexOf(':=');
|
||||
if (separatorIndex < 0) return { key: parameter.trim(), value: '' };
|
||||
@@ -1584,3 +1580,6 @@ function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
function environmentStorageKey(profile: Pick<CurriculumEnvironmentProfile, 'config' | 'profileType'>): 'local' | 'platform' {
|
||||
return profile.profileType.toUpperCase() === 'PLATFORM' || isPlatformEnvironment(profile.config) ? 'platform' : 'local';
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ export function DashboardPage({ session, selectedCourseId, onNavigate, onOpenCha
|
||||
Promise.allSettled([
|
||||
listInstructorPlatforms(session.accessToken),
|
||||
listInstructorGroups(session.accessToken, selectedCourseId || null),
|
||||
listInstructorChatThreads(session.accessToken),
|
||||
listInstructorChatThreads(session.accessToken, selectedCourseId),
|
||||
])
|
||||
.then(([platformResult, groupResult, chatResult]) => {
|
||||
if (cancelled) return;
|
||||
|
||||
Reference in New Issue
Block a user