20 Commits

Author SHA1 Message Date
chyoo c813325b54 fix.. 2026-08-12 17:56:05 +09:00
chyoo d5d15acf5f fix select curriculumn error.. 2026-08-12 15:40:54 +09:00
chyoo 2a3e2ead65 add commits.. 2026-08-12 14:46:19 +09:00
chyoo 4869dbe4c1 fix: scope instructor chat threads to the selected course 2026-08-11 14:19:52 +09:00
chyoo 5d2c93dd40 add instructor invite code.. 2026-08-07 11:56:59 +09:00
chyoo 4c37717483 patch curriculum.. 2026-08-07 10:27:41 +09:00
chyoo 604da5d6e1 feat: add Monaco diff editor for submission changes 2026-08-06 16:16:30 +09:00
chyoo 4b2e25d385 curriculum modify.. 2026-08-04 11:08:44 +09:00
chyoo def692a28b patch.. 2026-08-03 13:58:20 +09:00
chyoo ab40abf5b3 add images for chatting room title 2026-07-09 15:34:04 +09:00
chyoo 97a780f661 modify avatar url.. 2026-07-09 15:29:16 +09:00
chyoo 4925adc07b modify avatar url.. 2026-07-09 15:25:21 +09:00
chyoo f67ddb7bec modify file upload.. 2026-07-08 14:07:37 +09:00
chyoo e63b1c6f43 respect lms admin base path in routing 2026-07-08 09:48:35 +09:00
chyoo 6d64337467 route lms admin socket through lms api path 2026-07-08 09:42:59 +09:00
chyoo b581ee59d3 use deployed api endpoints for lms admin 2026-07-08 09:14:41 +09:00
chyoo 705ffeb490 set default lms admin base path 2026-07-08 09:03:28 +09:00
chyoo 21952d2fd6 serve lms admin under subpath 2026-07-08 09:01:01 +09:00
chyoo a940e02f75 add dockerfile for lms adm frontend build 2026-07-07 17:32:44 +09:00
chyoo d682e9a382 chore: init lms adm fe 2026-07-07 17:23:40 +09:00
16 changed files with 51 additions and 2452 deletions
+2 -1469
View File
File diff suppressed because it is too large Load Diff
-2
View File
@@ -17,9 +17,7 @@
"monaco-editor": "^0.56.0", "monaco-editor": "^0.56.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.18.0", "react-router-dom": "^7.18.0",
"remark-gfm": "^4.0.1",
"socket.io-client": "^4.8.3", "socket.io-client": "^4.8.3",
"tailwindcss": "^4.3.0", "tailwindcss": "^4.3.0",
"vite": "^6.3.5" "vite": "^6.3.5"
-164
View File
@@ -1,164 +0,0 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const ts = require('typescript');
const React = require('react');
const source = ts.createSourceFile('SettingsPage.tsx', fs.readFileSync(require.resolve('../src/pages/settings/SettingsPage.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const constants = source.statements.filter(node => ts.isVariableStatement(node) && ['AI_MODEL_DEFAULTS', 'AI_MODEL_OPTIONS', 'CUSTOM_AI_MODEL', 'AI_PROVIDER_LABELS', 'DEFAULT_QWEN_ENDPOINT'].includes(node.declarationList.declarations[0].name.getText(source)));
function compile(code, globals = {}) {
const exports = {};
vm.runInNewContext(ts.transpileModule(code, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX } }).outputText, { exports, require, ...globals });
return exports;
}
const helpers = source.statements.filter(node => ts.isFunctionDeclaration(node) && ['aiSettingDraft', 'aiSettingDrafts'].includes(node.name?.text));
const catalogs = [...constants, ...helpers].map(node => `export ${node.getText(source)}`).join('\n');
const { AI_MODEL_OPTIONS, AI_MODEL_DEFAULTS, CUSTOM_AI_MODEL, aiSettingDrafts } = compile(catalogs);
function find(node, predicate) {
if (predicate(node)) return node;
return ts.forEachChild(node, child => find(child, predicate));
}
const section = find(source, node => ts.isJsxElement(node) && node.openingElement.tagName.getText(source) === 'section' && node.openingElement.attributes.getText(source).includes('border-t border-[#e2e8f0] pt-5') && node.getText(source).includes('AI 공급자'));
function controls(provider, model, custom = false, configured = true) {
const state = { aiProvider: provider, drafts: aiSettingDrafts() };
Object.assign(state.drafts[provider], { model, customModel: custom });
for (const [name, field] of [['aiModel', 'model'], ['aiCustomModel', 'customModel'], ['aiApiKey', 'apiKey'], ['aiEndpointUrl', 'endpointUrl']]) {
Object.defineProperty(state, name, { enumerable: true, get: () => state.drafts[state.aiProvider][field] });
}
const context = {
aiSettingLoading: false, saving: false, aiEndpointUrl: 'https://example.test', aiApiKey: '', aiHasApiKey: false, aiConfiguredProvider: null,
aiCopilotConfigured: configured,
DEFAULT_QWEN_ENDPOINT: 'https://example.test', KeyRound: () => null,
setAiProvider(value) { state.aiProvider = value; }, setAiModel(value) { state.drafts[state.aiProvider].model = value; },
setAiCustomModel(value) { state.drafts[state.aiProvider].customModel = value; },
setAiApiKey(value) { state.drafts[state.aiProvider].apiKey = value; },
setAiEndpointUrl(value) { state.drafts[state.aiProvider].endpointUrl = value; }, setError() {},
};
const render = () => compile(`${catalogs}\nexport const tree = (${section.getText(source)});`, { ...context, ...state }).tree;
return { state, render };
}
function elements(root, predicate) {
if (!React.isValidElement(root)) return [];
return [...(predicate(root) ? [root] : []), ...React.Children.toArray(root.props.children).flatMap(child => elements(child, predicate))];
}
for (const provider of Object.keys(AI_MODEL_OPTIONS)) {
test(`${provider} offers current models and a custom model field`, () => {
const h = controls(provider, AI_MODEL_DEFAULTS[provider]);
let tree = h.render();
const select = elements(tree, node => node.type === 'select')[0];
assert.equal(select.props.value, AI_MODEL_DEFAULTS[provider]);
assert.equal(elements(select, node => node.type === 'option' && node.props.value === CUSTOM_AI_MODEL).length, 1);
assert.equal(elements(tree, node => node.type === 'input' && node.props.type === 'text').length, 0);
select.props.onChange({ target: { value: CUSTOM_AI_MODEL } });
assert.equal(h.state.aiCustomModel, true);
assert.equal(h.state.aiModel, '');
tree = h.render();
const input = elements(tree, node => node.type === 'input' && node.props.type === 'text')[0];
assert.equal(input.props.maxLength, 120);
input.props.onChange({ target: { value: 'custom-model-2026' } });
assert.equal(h.state.aiModel, 'custom-model-2026');
assert.equal(elements(h.render(), node => node.type === 'select')[0].props.value, CUSTOM_AI_MODEL);
elements(h.render(), node => node.type === 'select')[0].props.onChange({ target: { value: AI_MODEL_OPTIONS[provider][0].value } });
assert.equal(h.state.aiCustomModel, false);
assert.equal(h.state.aiModel, AI_MODEL_OPTIONS[provider][0].value);
assert.equal(new Set(AI_MODEL_OPTIONS[provider].map(option => option.value)).size, AI_MODEL_OPTIONS[provider].length);
});
}
test('provider defaults select the highest-tier current models at the top of each list', () => {
const expected = { OPENAI: 'gpt-6-astra', ANTHROPIC: 'claude-fable-5-1', GEMINI: 'gemini-4-argon', QWEN: 'qwen3.8-max' };
for (const [provider, model] of Object.entries(expected)) {
assert.equal(AI_MODEL_DEFAULTS[provider], model);
assert.equal(AI_MODEL_OPTIONS[provider][0].value, model);
const h = controls('A_COPILOT', 'a-copilot');
elements(h.render(), node => node.props.role === 'radio' && node.props['aria-label'] === ({ OPENAI: 'OpenAI', ANTHROPIC: 'Claude', GEMINI: 'Gemini', QWEN: 'Qwen' })[provider])[0].props.onClick();
assert.equal(h.state.aiModel, model);
}
});
test('saved unknown model IDs remain visible in custom mode', () => {
const model = 'provider/private-model:revision-10';
const h = controls('QWEN', model, true);
const tree = h.render();
assert.equal(elements(tree, node => node.type === 'select')[0].props.value, CUSTOM_AI_MODEL);
assert.equal(elements(tree, node => node.type === 'input' && node.props.type === 'text')[0].props.value, model);
assert.equal(AI_MODEL_OPTIONS.QWEN.some(option => option.value === model), false);
});
test('Qwen Max 0902 is selectable and saved selections restore without custom mode', () => {
const model = 'qwen3.8-max-0902';
const h = controls('QWEN', AI_MODEL_DEFAULTS.QWEN);
const select = elements(h.render(), node => node.type === 'select')[0];
const option = elements(select, node => node.type === 'option' && node.props.value === model);
assert.equal(option.length, 1);
assert.equal(option[0].props.children, 'Qwen 3.8 Max (0902)');
select.props.onChange({ target: { value: model } });
assert.equal(h.state.aiModel, model);
assert.equal(h.state.aiCustomModel, false);
const restored = aiSettingDrafts([{ provider: 'QWEN', enabled: true, model, hasApiKey: true }]);
assert.equal(restored.QWEN.model, model);
assert.equal(restored.QWEN.customModel, false);
assert.equal(AI_MODEL_DEFAULTS.QWEN, 'qwen3.8-max');
});
test('the selected provider button preserves a custom draft', () => {
const h = controls('OPENAI', 'custom-gpt', true);
const providers = elements(h.render(), node => node.props.role === 'radio');
providers.find(node => node.props['aria-label'] === 'OpenAI').props.onClick();
assert.equal(h.state.aiModel, 'custom-gpt');
assert.equal(h.state.aiCustomModel, true);
providers.find(node => node.props['aria-label'] === 'Claude').props.onClick();
assert.equal(h.state.aiProvider, 'ANTHROPIC');
assert.equal(h.state.aiModel, AI_MODEL_DEFAULTS.ANTHROPIC);
assert.equal(h.state.aiCustomModel, false);
});
test('A-Copilot is first and exposes only fixed read-only fields without a model picker', () => {
const tree = controls('A_COPILOT', 'a-copilot').render();
assert.equal(elements(tree, node => node.props.role === 'radio')[0].props['aria-label'], 'A-Copilot');
assert.equal(elements(tree, node => node.type === 'select').length, 0);
const inputs = elements(tree, node => node.type === 'input');
assert.deepEqual(inputs.map(node => node.props.value), ['A-Copilot', '서버에서 관리']);
assert.ok(inputs.every(node => node.props.readOnly && !node.props.onChange));
assert.equal(AI_MODEL_DEFAULTS.A_COPILOT, 'a-copilot');
assert.equal(elements(controls('A_COPILOT', 'a-copilot', false, false).render(), node => node.props.role === 'alert').length, 1);
});
test('official current API IDs are present', () => {
for (const [provider, ids] of Object.entries({
OPENAI: ['gpt-6.1-sol', 'gpt-6-astra', 'gpt-6-luna'],
ANTHROPIC: ['claude-sonnet-5-5', 'claude-opus-5-5', 'claude-haiku-5-5', 'claude-fable-5-1'],
GEMINI: ['gemini-4-argon', 'gemini-3.8-flash', 'gemini-3.5-flash-lite'],
QWEN: ['qwen3.8-max', 'qwen3.8-flash'],
})) for (const id of ids) assert.ok(AI_MODEL_OPTIONS[provider].some(option => option.value === id), id);
});
test('all new providers default to disabled while saved settings stay intact', () => {
assert.ok(Object.values(aiSettingDrafts()).every(draft => !draft.enabled && !draft.hasApiKey && draft.apiKey === ''));
const drafts = aiSettingDrafts([{ provider: 'QWEN', enabled: true, model: 'custom-qwen', hasApiKey: true, endpointUrl: 'https://qwen.test/v1' }]);
assert.equal(drafts.QWEN.enabled, true);
assert.equal(drafts.QWEN.model, 'custom-qwen');
assert.equal(drafts.QWEN.customModel, true);
assert.equal(drafts.OPENAI.enabled, false);
assert.equal(drafts.OPENAI.hasApiKey, false);
});
test('switching providers preserves separate unsaved models, keys and availability', () => {
const h = controls('OPENAI', 'private-model', true);
Object.assign(h.state.drafts.OPENAI, { enabled: true, apiKey: 'openai-draft' });
const provider = name => elements(h.render(), node => node.props.role === 'radio' && node.props['aria-label'] === name)[0];
provider('Qwen').props.onClick();
assert.equal(h.state.aiApiKey, '');
Object.assign(h.state.drafts.QWEN, { model: 'qwen3.8-flash', enabled: false, apiKey: 'qwen-draft' });
provider('OpenAI').props.onClick();
assert.equal(h.state.aiModel, 'private-model');
assert.equal(h.state.aiCustomModel, true);
assert.equal(h.state.aiApiKey, 'openai-draft');
assert.equal(h.state.drafts.OPENAI.enabled, true);
provider('Qwen').props.onClick();
assert.equal(h.state.aiModel, 'qwen3.8-flash');
assert.equal(h.state.aiApiKey, 'qwen-draft');
assert.equal(h.state.drafts.QWEN.enabled, false);
});
-64
View File
@@ -1,64 +0,0 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const ts = require('typescript');
const React = require('react');
const read = path => fs.readFileSync(require.resolve(`../src/${path}`), 'utf8');
function compile(source, globals = {}) {
const exports = {};
const code = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX } }).outputText;
vm.runInNewContext(code, { exports, require, ...globals });
return exports;
}
function elements(root, predicate) {
if (!React.isValidElement(root)) return [];
return [...(predicate(root) ? [root] : []), ...React.Children.toArray(root.props.children).flatMap(child => elements(child, predicate))];
}
const source = ts.createSourceFile('SettingsPage.tsx', read('pages/settings/SettingsPage.tsx'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const page = source.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'SettingsPage');
const CourseAiSettings = () => null;
const { SettingsPage } = compile(page.getText(source), { CourseAiSettings, PageHeader: () => null, Bot: () => null });
const props = {
session: { accessToken: 'test-token', user: { id: 'teacher' } },
selectedCourseId: 'course-a',
courses: [{ id: 'course-a', title: 'A' }, { id: 'course-b', title: 'B' }],
};
test('settings remounts the credential draft when course or session changes', () => {
const form = input => elements(SettingsPage(input), node => node.type === CourseAiSettings)[0];
const initial = form(props);
assert.equal(initial.props.selectedCourseId, 'course-a');
assert.notEqual(initial.key, form({ ...props, selectedCourseId: 'course-b' }).key);
assert.notEqual(initial.key, form({ ...props, session: { ...props.session, accessToken: 'new-token' } }).key);
});
test('settings is width-constrained and centered without a duplicate course selector', () => {
const tree = SettingsPage(props);
assert.match(tree.props.className, /mx-auto.*w-full.*max-w-\[920px\]/);
const header = React.Children.toArray(tree.props.children)[0];
assert.equal(header.props.action, undefined);
assert.equal(elements(tree, node => node.type === 'select').length, 0);
assert.equal(elements(SettingsPage({ ...props, selectedCourseId: '', courses: [] }), node => node.type === CourseAiSettings).length, 0);
});
test('curriculum no longer loads AI settings or offers an agent tab', () => {
const curriculum = read('pages/curriculum/CurriculumPage.tsx');
assert.doesNotMatch(curriculum, /getCourseAiSetting|updateCourseAiSetting|handleSaveAiSetting|setActiveTab\('ai'\)/);
assert.match(read('App.tsx'), /<SettingsPage session=\{session\} selectedCourseId=\{selectedCourseId\} courses=\{courses\} \/>/);
assert.match(read('App.tsx'), /<InstructorLayout[\s\S]*?onSelectCourse=\{handleSelectCourse\}/);
});
test('analytics is hidden from the menu without deleting its existing route', () => {
const routes = compile(read('routes.ts').replace('import.meta.env.BASE_URL', JSON.stringify('/lms-adm/')));
assert.equal(routes.navItems.some(item => item.id === 'analytics'), false);
assert.equal(routes.navItems.some(item => item.id === 'settings'), true);
assert.equal(routes.isAppRoute('analytics'), true);
assert.equal(routes.getRouteFromPathname('/lms-adm/analytics'), 'analytics');
});
test('settings has a general-purpose noun phrase rather than an AI-only sentence', () => {
const { pageMeta } = compile(read('pageMeta.ts'));
assert.equal(pageMeta.settings.description, '강좌 및 서비스 환경 설정');
});
+2 -3
View File
@@ -231,7 +231,6 @@ function App() {
route={visibleRoute} route={visibleRoute}
session={session} session={session}
selectedCourseId={selectedCourseId} selectedCourseId={selectedCourseId}
courses={courses}
onNavigate={setRoute} onNavigate={setRoute}
onOpenChatThread={(threadId) => setChatThreadOpenRequest({ threadId, requestId: Date.now() })} onOpenChatThread={(threadId) => setChatThreadOpenRequest({ threadId, requestId: Date.now() })}
/> />
@@ -295,7 +294,7 @@ function SessionExpiredPage({ onLogin }: { onLogin: () => void }) {
); );
} }
function RoutePage({ route, session, selectedCourseId, courses, onNavigate, onOpenChatThread }: { route: AppRoute; session: AuthSession; selectedCourseId: string; courses: InstructorCourse[]; onNavigate: (route: AppRoute) => void; onOpenChatThread: (threadId: string) => void }) { function RoutePage({ route, session, selectedCourseId, onNavigate, onOpenChatThread }: { route: AppRoute; session: AuthSession; selectedCourseId: string; onNavigate: (route: AppRoute) => void; onOpenChatThread: (threadId: string) => void }) {
switch (route) { switch (route) {
case 'dashboard': case 'dashboard':
return <DashboardPage session={session} selectedCourseId={selectedCourseId} onNavigate={onNavigate} onOpenChatThread={onOpenChatThread} />; return <DashboardPage session={session} selectedCourseId={selectedCourseId} onNavigate={onNavigate} onOpenChatThread={onOpenChatThread} />;
@@ -312,7 +311,7 @@ function RoutePage({ route, session, selectedCourseId, courses, onNavigate, onOp
case 'analytics': case 'analytics':
return <AnalyticsPage />; return <AnalyticsPage />;
case 'settings': case 'settings':
return <SettingsPage session={session} selectedCourseId={selectedCourseId} courses={courses} />; return <SettingsPage />;
case 'profile': case 'profile':
return <ProfilePage session={session} />; return <ProfilePage session={session} />;
} }
@@ -1,180 +0,0 @@
import { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { BookOpenText, X } from 'lucide-react';
type GuideLesson = {
code: string;
title: string;
guidance: string;
tools: string;
};
type GuideDay = {
title: string;
lessons: GuideLesson[];
};
const GUIDE_DAYS: GuideDay[] = [
{
title: 'DAY 1. ROS2 & Simulation',
lessons: [
{
code: '01',
title: 'ROS2 기초',
guidance: 'Gazebo와 TurtleBot3 또는 MK-mini를 실행하고 /odom, /tf, /scan, /cmd_vel, /map의 역할을 설명합니다. Goal 지정은 Nav2 Action의 실제 사례로 사용하고, TF tree는 터미널에서 map-odom-base_link 관계를 확인합니다.',
tools: '모듈 리스트, ROS 연결상태, 터미널, 시각화, RViz2',
},
{
code: '02',
title: 'Sensor Processing',
guidance: 'NAV2-Perception을 실행해 LiDAR /scan을 확인하고, 퍼셉션 입력 토픽과 TF, LiDAR 장착 위치 설정을 읽고 변경 전후를 비교합니다.',
tools: 'NAV2-Perception, 코드 편집, 파라미터 설정, 시각화 Sensor',
},
{
code: '03',
title: 'Simulation 환경 구축 Part 1',
guidance: '기본 월드와 실내 환경을 비교한 뒤 커스텀 맵에서 벽과 장애물을 배치합니다. Gazebo 레이저가 환경을 어떻게 인식하는지 함께 확인합니다.',
tools: '맵 설정, 커스텀 맵 편집, NAV2-Gazebo, 시각화',
},
{
code: '04',
title: 'Simulation 환경 구축 Part 2',
guidance: '동일한 맵에서 TurtleBot3와 MK-mini를 번갈아 실행해 차동구동과 애커만 조향 차량의 차이를 비교합니다.',
tools: 'NAV2-TurtleBot3Burger, NAV2-MKMini, Gazebo, 시각화',
},
],
},
{
title: 'DAY 2-3. SLAM & Localization',
lessons: [
{
code: '05',
title: 'LiDAR SLAM',
guidance: '차량 또는 Gazebo, Perception, SLAM, 시각화 순서로 실행합니다. /scan, /odom, /map, /tf 관계를 확인하고 결과 맵을 저장합니다.',
tools: 'NAV2-SLAM, NAV2-Perception, 맵 저장, 시각화, ROS 연결상태',
},
{
code: '06',
title: 'LiDAR Localization',
guidance: '저장한 맵을 선택하고 Map, Localization, Planning, Control 조합으로 전환합니다. Pose 지정과 Goal 주행으로 map 기준 위치 보정을 확인합니다.',
tools: 'NAV2-Map, NAV2-Localization, NAV2-Planning, NAV2-Control, 시각화 Pose/Goal',
},
{
code: '07',
title: 'Parameter 튜닝',
guidance: '모듈 파라미터와 설정 파일을 바꾸고 로그, 토픽, 주행 경로의 변화를 비교합니다. 수정 근거와 결과를 Git 변경사항으로 남기게 합니다.',
tools: '파라미터 설정, 코드 편집, 로그, 소스 제어',
},
],
},
{
title: 'DAY 4-5. Planning & Control',
lessons: [
{
code: '09',
title: 'Occupancy Map',
guidance: 'SLAM으로 생성된 2D 점유맵을 Nav2 장애물 인식 기준으로 설명합니다. 커스텀 월드, LiDAR 스캔, 저장된 맵을 비교해 맵 품질이 주행에 미치는 영향을 확인합니다.',
tools: '커스텀 맵, NAV2-SLAM, NAV2-Map, 시각화',
},
{
code: '10',
title: 'Planning',
guidance: 'Goal을 지정하고 NAV2-Planning 경로를 확인합니다. TurtleBot3와 MK-mini의 경로 차이, 장애물 회피, 전진 중심과 후진 허용 설정을 비교합니다.',
tools: 'NAV2-Planning, 시각화 Path/Goal, ROS 연결상태',
},
{
code: '11',
title: 'Control',
guidance: 'NAV2-Control의 /cmd_vel 흐름과 차량 조향을 연결해 설명합니다. 시뮬레이션으로 검증한 뒤 원격 플랫폼 제어권을 배정해 저속 실차 검증을 진행합니다.',
tools: 'NAV2-Control, NAV2-MKMini, 원격 플랫폼 워크스페이스, 시각화',
},
],
},
];
type InstructorLearningGuideModalProps = {
open: boolean;
onClose: () => void;
};
export function InstructorLearningGuideModal({ open, onClose }: InstructorLearningGuideModalProps) {
useEffect(() => {
if (!open) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose, open]);
if (!open) return null;
return createPortal(
<div
className="fixed inset-0 z-[1000] grid place-items-center bg-[#0f172a]/50 px-4 py-6 backdrop-blur-[2px]"
role="presentation"
onMouseDown={(event) => {
if (event.currentTarget === event.target) onClose();
}}
>
<section className="flex max-h-full w-full max-w-[940px] flex-col overflow-hidden rounded-xl border border-[#cbd5e1] bg-white shadow-[0_28px_80px_rgba(15,23,42,0.30)]" role="dialog" aria-modal="true" aria-labelledby="instructor-learning-guide-title">
<header className="flex items-start justify-between gap-4 border-b border-[#cbd5e1] px-6 py-4">
<div className="flex min-w-0 items-center gap-3">
<span className="grid size-9 shrink-0 place-items-center rounded-md bg-[#eef2ff] text-[#4f46e5]">
<BookOpenText size={18} aria-hidden="true" />
</span>
<div className="min-w-0">
<h2 id="instructor-learning-guide-title" className="text-[18px] font-black text-[#111827]">LMS 학습 운영 가이드</h2>
<p className="mt-1 text-[12px] font-semibold text-[#64748b]">ROS2, 시뮬레이션, SLAM, Planning 및 Control 실습 운영 기준</p>
</div>
</div>
<button type="button" onClick={onClose} aria-label="가이드 닫기" title="닫기" className="grid size-8 shrink-0 cursor-pointer place-items-center rounded-md text-[#64748b] transition hover:bg-[#f1f5f9] hover:text-[#172033]">
<X size={18} aria-hidden="true" />
</button>
</header>
<div className="min-h-0 overflow-y-auto px-6 py-5">
<section className="border-b border-[#e2e8f0] pb-5">
<h3 className="text-[14px] font-black text-[#111827]">강사 운영 흐름</h3>
<ol className="mt-3 grid gap-2.5 text-[13px] font-semibold leading-6 text-[#475569] md:grid-cols-3">
<li className="rounded-md border border-[#dbe4f0] bg-[#f8fafc] px-3 py-2.5"><strong className="block text-[#1e293b]">1. 수업 전 구성</strong>커리큘럼, 학습단계, 사용 모듈, 기본 파라미터를 구성하고 그룹에 플랫폼을 배정합니다.</li>
<li className="rounded-md border border-[#dbe4f0] bg-[#f8fafc] px-3 py-2.5"><strong className="block text-[#1e293b]">2. 수업 중 검증</strong>모듈 실행, ROS 연결상태, 코드 수정, 시각화 순서로 원인을 확인합니다.</li>
<li className="rounded-md border border-[#dbe4f0] bg-[#f8fafc] px-3 py-2.5"><strong className="block text-[#1e293b]">3. 수업 후 기록</strong>저장 맵, 파라미터, Git 변경사항, 주행 결과를 제출 기준으로 남깁니다.</li>
</ol>
</section>
<div className="mt-5 space-y-5">
{GUIDE_DAYS.map((day) => (
<section key={day.title}>
<h3 className="text-[14px] font-black text-[#111827]">{day.title}</h3>
<div className="mt-2 overflow-hidden rounded-lg border border-[#dbe4f0]">
{day.lessons.map((lesson, index) => (
<article key={lesson.code} className={['grid gap-3 px-4 py-3.5 md:grid-cols-[124px_minmax(0,1fr)_220px]', index ? 'border-t border-[#e2e8f0]' : ''].join(' ')}>
<div>
<span className="text-[11px] font-black text-[#4f46e5]">{lesson.code}</span>
<h4 className="mt-0.5 text-[13px] font-black text-[#1e293b]">{lesson.title}</h4>
</div>
<p className="text-[12px] font-semibold leading-5 text-[#475569]">{lesson.guidance}</p>
<p className="border-l border-[#e2e8f0] pl-3 text-[11px] font-bold leading-5 text-[#64748b]"><span className="block text-[10px] font-black text-[#94a3b8]">LMS 사용 기능</span>{lesson.tools}</p>
</article>
))}
</div>
</section>
))}
</div>
<section className="mt-5 rounded-lg border border-[#dbe4f0] bg-[#f8fafc] p-4">
<h3 className="text-[14px] font-black text-[#111827]">팀 프로젝트 운영</h3>
<p className="mt-2 text-[12px] font-semibold leading-6 text-[#475569]">3~4인 그룹마다 문제 상황과 맵을 정하고, 강사는 그룹별 원격 플랫폼 사용 시간을 배정합니다. 학생은 커스텀 맵에서 먼저 SLAM, Localization, Planning, Control을 검증한 뒤 원격 MK-mini에서 저속 검증합니다. 질의응답 강사 탭에는 주행 실패 원인, 파라미터 변경 근거, 토픽 상태 캡처를 남기게 합니다.</p>
</section>
</div>
<footer className="flex justify-end border-t border-[#cbd5e1] bg-[#f8fafc] px-6 py-3">
<button type="button" onClick={onClose} className="h-9 cursor-pointer rounded-md bg-[#1e293b] px-4 text-[12px] font-black text-white transition hover:bg-[#0f172a]">닫기</button>
</footer>
</section>
</div>,
document.body,
);
}
-54
View File
@@ -1,54 +0,0 @@
import { useRef, useState } from 'react';
import { ImagePlus, Loader2, Trash2 } from 'lucide-react';
import { deleteCurriculumReference, uploadCurriculumReference, type CurriculumReference } from '../lib/curriculum';
type Props = {
accessToken: string;
courseId: string;
week: number;
cover: CurriculumReference | null;
onChange: (cover: CurriculumReference | null) => void;
};
export function WeekCoverEditor({ accessToken, courseId, week, cover, onChange }: Props) {
const input = useRef<HTMLInputElement>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
async function save(file?: File) {
if (busy || (!file && !cover)) return;
if (file && (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type) || file.size > 8 * 1024 * 1024)) {
setError('8MB 이하의 PNG, JPEG, WebP 이미지를 선택해주세요.');
return;
}
setBusy(true);
setError('');
try {
const result = file
? await uploadCurriculumReference(accessToken, { courseId, week, file, kind: 'cover-image' })
: await deleteCurriculumReference(accessToken, cover!.id, courseId);
onChange(result.weeks.find((item) => item.week === week)?.detail.coverImage ?? null);
} catch (reason) {
setError(reason instanceof Error ? reason.message : '대표이미지를 저장하지 못했습니다.');
} finally { setBusy(false); }
}
return (
<section>
<div className="flex items-center justify-between gap-3">
<h3 className="text-[15px] font-black text-[#111827]">대표이미지</h3>
<div className="flex gap-2">
<button type="button" disabled={busy} onClick={() => input.current?.click()}
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-[#d1d9e6] bg-white px-3 text-[12px] font-bold disabled:opacity-50">
{busy ? <Loader2 size={14} className="animate-spin" /> : <ImagePlus size={14} />}
{busy ? '저장 중' : cover ? '이미지 변경' : '이미지 업로드'}
</button>
{cover && <button type="button" title="대표이미지 삭제" aria-label="대표이미지 삭제" disabled={busy}
onClick={() => void save()} className="grid h-8 w-8 place-items-center rounded-md border border-[#d1d9e6] bg-white text-red-600 disabled:opacity-50"><Trash2 size={14} /></button>}
</div>
</div>
<input ref={input} type="file" accept="image/png,image/jpeg,image/webp" className="hidden"
onChange={(event) => { const file = event.target.files?.[0]; event.target.value = ''; if (file) void save(file); }} />
{cover && <img src={cover.downloadUrl} alt={`${week}주차 대표이미지`} className="mt-3 h-[240px] w-full rounded-md object-cover object-center" />}
{error && <p role="alert" className="mt-2 text-[13px] text-red-600">{error}</p>}
</section>
);
}
+1 -6
View File
@@ -23,7 +23,6 @@ import { io } from 'socket.io-client';
import type { Socket } from 'socket.io-client'; import type { Socket } from 'socket.io-client';
import { ConfirmDialog } from '../components/ConfirmDialog'; import { ConfirmDialog } from '../components/ConfirmDialog';
import { InstructorChatPanel } from '../components/InstructorChatPanel'; import { InstructorChatPanel } from '../components/InstructorChatPanel';
import { InstructorLearningGuideModal } from '../components/InstructorLearningGuideModal';
import { useToast } from '../components/ToastProvider'; import { useToast } from '../components/ToastProvider';
import autonomyLogo from '../assets/icon.png'; import autonomyLogo from '../assets/icon.png';
import { useProfileAvatar } from '../hooks/useProfileAvatar'; import { useProfileAvatar } from '../hooks/useProfileAvatar';
@@ -79,7 +78,6 @@ export function InstructorLayout({ session, route, onNavigate, onLogout, courses
const [notificationTab, setNotificationTab] = useState<'unread' | 'read'>('unread'); const [notificationTab, setNotificationTab] = useState<'unread' | 'read'>('unread');
const [notificationPage, setNotificationPage] = useState(1); const [notificationPage, setNotificationPage] = useState(1);
const [chatOpen, setChatOpen] = useState(false); const [chatOpen, setChatOpen] = useState(false);
const [guideOpen, setGuideOpen] = useState(false);
const [hasUnreadChat, setHasUnreadChat] = useState(false); const [hasUnreadChat, setHasUnreadChat] = useState(false);
const [notifications, setNotifications] = useState<NotificationMessage[]>([]); const [notifications, setNotifications] = useState<NotificationMessage[]>([]);
const profileRef = useRef<HTMLDivElement | null>(null); const profileRef = useRef<HTMLDivElement | null>(null);
@@ -451,7 +449,6 @@ export function InstructorLayout({ session, route, onNavigate, onLogout, courses
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate text-[14px] font-black text-[#111827]">{displayName}</p> <p className="truncate text-[14px] font-black text-[#111827]">{displayName}</p>
<p className="mt-1 break-all text-[11px] leading-4 text-[#64748b]">{session.user.email}</p>
</div> </div>
</div> </div>
@@ -506,8 +503,6 @@ export function InstructorLayout({ session, route, onNavigate, onLogout, courses
onConfirm={() => void handleAvatarDelete()} onConfirm={() => void handleAvatarDelete()}
/> />
<InstructorLearningGuideModal open={guideOpen} onClose={() => setGuideOpen(false)} />
<InstructorChatPanel <InstructorChatPanel
session={session} session={session}
courseId={selectedCourseId} courseId={selectedCourseId}
@@ -580,7 +575,7 @@ export function InstructorLayout({ session, route, onNavigate, onLogout, courses
</div> </div>
</div> </div>
<button onClick={() => setGuideOpen(true)} 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"> <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" /> <ExternalLink size={13} aria-hidden="true" />
</button> </button>
+2 -49
View File
@@ -1,5 +1,4 @@
export type CurriculumWeek = { export type CurriculumWeek = {
canDelete?: boolean;
week: number; week: number;
title: string; title: string;
tags: string[]; tags: string[];
@@ -9,8 +8,6 @@ export type CurriculumWeek = {
duration: string; duration: string;
github: string; github: string;
detail: { detail: {
coverImage?: CurriculumReference | null;
camera?: { url: string };
overview: string; overview: string;
learningSteps?: Array<{ learningSteps?: Array<{
title: string; title: string;
@@ -112,19 +109,6 @@ export type InstructorCurriculumResponse = {
mapOptions?: CurriculumMap[]; mapOptions?: CurriculumMap[];
}; };
export type CourseAiSetting = {
courseId: string;
enabled: boolean;
provider: 'A_COPILOT' | 'OPENAI' | 'ANTHROPIC' | 'GEMINI' | 'QWEN';
model: string;
endpointUrl?: string | null;
hasApiKey: boolean;
copilotConfigured?: boolean;
updatedAt?: string | null;
};
export type CourseAiSettings = CourseAiSetting & { settings: CourseAiSetting[] };
const LMS_API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? 'https://workspace.autonomystudio.net/lms-api/api').replace(/\/+$/, ''); const LMS_API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? 'https://workspace.autonomystudio.net/lms-api/api').replace(/\/+$/, '');
function uploadErrorMessage(status: number, fallback: string) { function uploadErrorMessage(status: number, fallback: string) {
@@ -147,33 +131,6 @@ export async function getInstructorCurriculum(accessToken: string, groupId?: str
return response.json() as Promise<InstructorCurriculumResponse>; return response.json() as Promise<InstructorCurriculumResponse>;
} }
export async function getCourseAiSetting(accessToken: string, courseId: string): Promise<CourseAiSettings> {
const response = await fetch(`${LMS_API_BASE_URL}/instructor/courses/${encodeURIComponent(courseId)}/ai-setting`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!response.ok) throw new Error(`AI 에이전트 설정을 불러오지 못했습니다. (${response.status})`);
return response.json() as Promise<CourseAiSettings>;
}
export async function updateCourseAiSetting(
accessToken: string,
courseId: string,
input: Pick<CourseAiSetting, 'enabled' | 'provider'> & { model?: string; apiKey?: string; endpointUrl?: string },
): Promise<CourseAiSetting> {
const response = await fetch(`${LMS_API_BASE_URL}/instructor/courses/${encodeURIComponent(courseId)}/ai-setting`, {
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);
throw new Error(payload && typeof payload === 'object' && 'message' in payload
? String(payload.message)
: `AI 에이전트 설정을 저장하지 못했습니다. (${response.status})`);
}
return response.json() as Promise<CourseAiSetting>;
}
export async function updateInstructorCurriculum( export async function updateInstructorCurriculum(
accessToken: string, accessToken: string,
input: { input: {
@@ -187,7 +144,6 @@ export async function updateInstructorCurriculum(
title?: string; title?: string;
duration?: string; duration?: string;
overview?: string; overview?: string;
cameraViewUrl?: string;
deliverable?: string; deliverable?: string;
isTeamProject?: boolean; isTeamProject?: boolean;
topics?: string[]; topics?: string[];
@@ -267,10 +223,8 @@ export async function createInstructorCurriculumMap(
export async function deleteInstructorCurriculumWeek( export async function deleteInstructorCurriculumWeek(
accessToken: string, accessToken: string,
week: number, week: number,
courseId?: string | null,
): Promise<InstructorCurriculumResponse> { ): Promise<InstructorCurriculumResponse> {
const query = courseId ? `?courseId=${encodeURIComponent(courseId)}` : ''; const response = await fetch(`${LMS_API_BASE_URL}/instructor/curriculum/weeks/${week}`, {
const response = await fetch(`${LMS_API_BASE_URL}/instructor/curriculum/weeks/${week}${query}`, {
method: 'DELETE', method: 'DELETE',
headers: { headers: {
Authorization: `Bearer ${accessToken}`, Authorization: `Bearer ${accessToken}`,
@@ -296,7 +250,6 @@ export async function uploadCurriculumReference(
week: number; week: number;
title?: string; title?: string;
file: File; file: File;
kind?: 'cover-image';
}, },
): Promise<InstructorCurriculumResponse> { ): Promise<InstructorCurriculumResponse> {
const formData = new FormData(); const formData = new FormData();
@@ -304,7 +257,7 @@ export async function uploadCurriculumReference(
formData.set('courseId', input.courseId ?? ''); formData.set('courseId', input.courseId ?? '');
formData.set('title', input.title?.trim() || input.file.name); formData.set('title', input.title?.trim() || input.file.name);
const response = await fetch(`${LMS_API_BASE_URL}/instructor/curriculum/weeks/${input.week}/${input.kind ?? 'references'}`, { const response = await fetch(`${LMS_API_BASE_URL}/instructor/curriculum/weeks/${input.week}/references`, {
method: 'POST', method: 'POST',
headers: { headers: {
Authorization: `Bearer ${accessToken}`, Authorization: `Bearer ${accessToken}`,
-13
View File
@@ -83,7 +83,6 @@ export type MasterPlatform = {
name: string; name: string;
description: string; description: string;
iconKey: string; iconKey: string;
vehicleType: string;
}; };
export type InstructorPlatformsSummary = { export type InstructorPlatformsSummary = {
@@ -270,18 +269,6 @@ export async function assignInstructorPlatform(
return response.json() as Promise<InstructorPlatformsResponse>; return response.json() as Promise<InstructorPlatformsResponse>;
} }
export async function syncInstructorPlatformAccess(accessToken: string): Promise<{ ok: boolean; assignmentCount: number; syncedAt: string }> {
const response = await fetch(`${LMS_API_BASE_URL}/instructor/platforms/access-sync`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!response.ok) {
throw new Error(await platformErrorMessage(response, '플랫폼 접근 설정을 동기화하지 못했습니다.'));
}
return response.json() as Promise<{ ok: boolean; assignmentCount: number; syncedAt: string }>;
}
async function platformErrorMessage(response: Response, fallback: string) { async function platformErrorMessage(response: Response, fallback: string) {
const payload = await response.json().catch((): null => null); const payload = await response.json().catch((): null => null);
return platformErrorMessageFromPayload(response, payload, fallback); return platformErrorMessageFromPayload(response, payload, fallback);
+1 -1
View File
@@ -36,7 +36,7 @@ export const pageMeta: Record<AppRoute, PageMeta> = {
}, },
settings: { settings: {
title: '설정', title: '설정',
description: '강좌 및 서비스 환경 설정' description: '강사 포털의 운영 정책과 계정 설정을 관리합니다.'
}, },
profile: { profile: {
title: '프로필', title: '프로필',
+3 -23
View File
@@ -2,7 +2,6 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { CheckCircle2, CirclePlay, Clock3, FileCode2, FileText, Loader2, Map, Plus, RadioTower, Save, Target, Trash2, Upload, UsersRound, type LucideIcon } from 'lucide-react'; import { CheckCircle2, CirclePlay, Clock3, FileCode2, FileText, Loader2, Map, Plus, RadioTower, Save, Target, Trash2, Upload, UsersRound, type LucideIcon } from 'lucide-react';
import { ConfirmDialog } from '../../components/ConfirmDialog'; import { ConfirmDialog } from '../../components/ConfirmDialog';
import { WeekCoverEditor } from '../../components/WeekCoverEditor';
import { PageHeader } from '../../components/PageHeader'; import { PageHeader } from '../../components/PageHeader';
import { useToast } from '../../components/ToastProvider'; import { useToast } from '../../components/ToastProvider';
import { createInstructorCurriculumMap, deleteCurriculumReference, deleteInstructorCurriculumWeek, getInstructorCurriculum, updateInstructorCurriculum, uploadCurriculumExampleCode, uploadCurriculumReference, type CurriculumEnvironmentProfile, type CurriculumWeek, type InstructorCurriculumResponse } from '../../lib/curriculum'; import { createInstructorCurriculumMap, deleteCurriculumReference, deleteInstructorCurriculumWeek, getInstructorCurriculum, updateInstructorCurriculum, uploadCurriculumExampleCode, uploadCurriculumReference, type CurriculumEnvironmentProfile, type CurriculumWeek, type InstructorCurriculumResponse } from '../../lib/curriculum';
@@ -10,6 +9,7 @@ import { listInstructorGroups, type InstructorGroup } from '../../lib/groups';
import type { AuthSession } from '../../types/auth'; import type { AuthSession } from '../../types/auth';
const CURRICULUM_SELECTED_GROUP_STORAGE_PREFIX = 'lms-admin-curriculum-selected-group-id'; const CURRICULUM_SELECTED_GROUP_STORAGE_PREFIX = 'lms-admin-curriculum-selected-group-id';
type ModuleParameterDraft = { type ModuleParameterDraft = {
key: string; key: string;
value: string; value: string;
@@ -106,7 +106,6 @@ function readEnvironmentModuleDockerImages(
export function CurriculumPage({ session, selectedCourseId }: { session: AuthSession; selectedCourseId: string }) { export function CurriculumPage({ session, selectedCourseId }: { session: AuthSession; selectedCourseId: string }) {
const { showToast } = useToast(); const { showToast } = useToast();
const [weeks, setWeeks] = useState<CurriculumWeek[]>([]); const [weeks, setWeeks] = useState<CurriculumWeek[]>([]);
const [coverOverrides, setCoverOverrides] = useState<Record<string, CurriculumWeek['detail']['coverImage']>>({});
const [groups, setGroups] = useState<InstructorGroup[]>([]); const [groups, setGroups] = useState<InstructorGroup[]>([]);
const [selectedGroupId, setSelectedGroupId] = useState<string>(''); const [selectedGroupId, setSelectedGroupId] = useState<string>('');
const [activeWeek, setActiveWeek] = useState(1); const [activeWeek, setActiveWeek] = useState(1);
@@ -117,7 +116,6 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
const [draftTitle, setDraftTitle] = useState(''); const [draftTitle, setDraftTitle] = useState('');
const [draftDuration, setDraftDuration] = useState(''); const [draftDuration, setDraftDuration] = useState('');
const [draftOverview, setDraftOverview] = useState(''); const [draftOverview, setDraftOverview] = useState('');
const [draftCameraUrl, setDraftCameraUrl] = useState('');
const [draftDeliverable, setDraftDeliverable] = useState(''); const [draftDeliverable, setDraftDeliverable] = useState('');
const [draftIsTeamProject, setDraftIsTeamProject] = useState(false); const [draftIsTeamProject, setDraftIsTeamProject] = useState(false);
const [draftTopics, setDraftTopics] = useState<string[]>([]); const [draftTopics, setDraftTopics] = useState<string[]>([]);
@@ -158,7 +156,7 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
const primaryActionDisabled = saving || !selectedWeek || (activeTab === 'apply' && !selectedGroup); const primaryActionDisabled = saving || !selectedWeek || (activeTab === 'apply' && !selectedGroup);
const selectedTopics = activeTab === 'edit' ? draftTopics : selectedWeek?.topics ?? []; const selectedTopics = activeTab === 'edit' ? draftTopics : selectedWeek?.topics ?? [];
const selectedTopicDetails = activeTab === 'edit' ? draftTopicDetails : selectedWeek?.detail.topicDetails ?? []; const selectedTopicDetails = activeTab === 'edit' ? draftTopicDetails : selectedWeek?.detail.topicDetails ?? [];
const selectedWeekDeletable = selectedWeek?.canDelete === true; const selectedWeekDeletable = Boolean(selectedWeek?.tags.includes('추가'));
const tagOptionLabels = useMemo(() => new Set(tagOptions.map((option) => option.label)), [tagOptions]); const tagOptionLabels = useMemo(() => new Set(tagOptions.map((option) => option.label)), [tagOptions]);
const selectedTagLabel = tagOptionLabels.has(draftTagLabel) const selectedTagLabel = tagOptionLabels.has(draftTagLabel)
? draftTagLabel ? draftTagLabel
@@ -276,7 +274,6 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
setDraftTitle(selectedWeek.title); setDraftTitle(selectedWeek.title);
setDraftDuration(selectedWeek.duration); setDraftDuration(selectedWeek.duration);
setDraftOverview(selectedWeek.detail.overview); setDraftOverview(selectedWeek.detail.overview);
setDraftCameraUrl(selectedWeek.detail.camera?.url ?? '');
setDraftDeliverable(selectedWeek.detail.deliverable); setDraftDeliverable(selectedWeek.detail.deliverable);
setDraftIsTeamProject(Boolean(selectedWeek.isTeamProject)); setDraftIsTeamProject(Boolean(selectedWeek.isTeamProject));
setDraftTopics(selectedWeek.topics.length ? selectedWeek.topics : ['']); setDraftTopics(selectedWeek.topics.length ? selectedWeek.topics : ['']);
@@ -353,7 +350,6 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
title: draftTitle, title: draftTitle,
duration: draftDuration, duration: draftDuration,
overview: draftOverview, overview: draftOverview,
cameraViewUrl: draftCameraUrl,
deliverable: draftDeliverable, deliverable: draftDeliverable,
isTeamProject: draftIsTeamProject, isTeamProject: draftIsTeamProject,
topics: draftTopics.map((topic) => topic.trim()).filter(Boolean), topics: draftTopics.map((topic) => topic.trim()).filter(Boolean),
@@ -402,7 +398,6 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
title: draftTitle, title: draftTitle,
duration: draftDuration, duration: draftDuration,
overview: draftOverview, overview: draftOverview,
cameraViewUrl: draftCameraUrl,
deliverable: draftDeliverable, deliverable: draftDeliverable,
isTeamProject: draftIsTeamProject, isTeamProject: draftIsTeamProject,
topics, topics,
@@ -444,7 +439,6 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
title: draftTitle, title: draftTitle,
duration: draftDuration, duration: draftDuration,
overview: draftOverview, overview: draftOverview,
cameraViewUrl: draftCameraUrl,
deliverable: draftDeliverable, deliverable: draftDeliverable,
isTeamProject: draftIsTeamProject, isTeamProject: draftIsTeamProject,
topics: draftTopics.map((topic) => topic.trim()).filter(Boolean), topics: draftTopics.map((topic) => topic.trim()).filter(Boolean),
@@ -586,7 +580,7 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
setError(null); setError(null);
try { try {
const payload = await deleteInstructorCurriculumWeek(session.accessToken, selectedWeek.week, selectedCourseId); const payload = await deleteInstructorCurriculumWeek(session.accessToken, selectedWeek.week);
applyPayload(payload, { selectProgress: true }); applyPayload(payload, { selectProgress: true });
showToast({ showToast({
title: '주차가 삭제되었습니다.', title: '주차가 삭제되었습니다.',
@@ -1399,11 +1393,6 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
)} )}
</div> </div>
</section> </section>
{selectedWeek && <WeekCoverEditor key={`${selectedCourseId}:${selectedWeek.week}`}
accessToken={session.accessToken} courseId={selectedCourseId} week={selectedWeek.week}
cover={coverOverrides[`${selectedCourseId}:${selectedWeek.week}`] !== undefined
? coverOverrides[`${selectedCourseId}:${selectedWeek.week}`] ?? null : selectedWeek.detail.coverImage ?? null}
onChange={(cover) => setCoverOverrides((current) => ({ ...current, [`${selectedCourseId}:${selectedWeek.week}`]: cover }))} />}
<label className="block"> <label className="block">
<h3 className="text-[15px] font-black text-[#111827]">제출 기준</h3> <h3 className="text-[15px] font-black text-[#111827]">제출 기준</h3>
<textarea <textarea
@@ -1487,15 +1476,6 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
</div> </div>
</div> </div>
</section> </section>
<section className="grid min-w-0 gap-3">
<h3 className="text-[15px] font-black text-[#111827]">오버뷰</h3>
<label className="block min-w-0">
<span className="text-[12px] font-black text-[#475569]">오버뷰 시청 URL</span>
<input type="url" value={draftCameraUrl} onChange={(event) => setDraftCameraUrl(event.target.value)}
placeholder="시청 링크"
className="mt-1.5 h-10 w-full min-w-0 rounded-md border border-[#d1d9e6] bg-white px-3 text-[13px] outline-none focus:border-[#2563eb]" />
</label>
</section>
</div> </div>
)} )}
</div> </div>
+7 -15
View File
@@ -1,7 +1,5 @@
import { useEffect, useState, type FormEvent } from 'react'; import { useEffect, useState, type FormEvent } from 'react';
import { CheckCircle2, Eye, EyeOff } from 'lucide-react'; import { CheckCircle2, Eye, EyeOff } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { BrandMark } from '../../components/BrandMark'; import { BrandMark } from '../../components/BrandMark';
import { loginInstructor } from '../../lib/auth'; import { loginInstructor } from '../../lib/auth';
import type { AuthSession } from '../../types/auth'; import type { AuthSession } from '../../types/auth';
@@ -14,7 +12,7 @@ const REMEMBERED_EMAIL_STORAGE_KEY = 'atnmy.lms.admin.login.rememberedEmail';
const DEFAULT_EMAIL = ''; const DEFAULT_EMAIL = '';
const DEFAULT_PASSWORD = ''; const DEFAULT_PASSWORD = '';
const AUTH_BASE_URL = (import.meta.env.VITE_ATNMY_AUTH_URL ?? import.meta.env.VITE_ADS_IDE_AUTH_URL ?? 'https://auth.autonomystudio.net').replace(/\/+$/, ''); const AUTH_BASE_URL = (import.meta.env.VITE_ATNMY_AUTH_URL ?? import.meta.env.VITE_ADS_IDE_AUTH_URL ?? 'https://auth.autonomystudio.net').replace(/\/+$/, '');
const LAUNCHER_API_BASE_URL = (import.meta.env.VITE_LAUNCHER_API_BASE_URL ?? 'https://portal-api.autonomystudio.net/api').replace(/\/+$/, ''); const LAUNCHER_API_BASE_URL = (import.meta.env.VITE_ATNMY_STDO_API_BASE_URL ?? 'http://localhost:4200').replace(/\/+$/, '');
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function LoginPage({ onLogin }: LoginPageProps) { export function LoginPage({ onLogin }: LoginPageProps) {
@@ -135,6 +133,9 @@ export function LoginPage({ onLogin }: LoginPageProps) {
/> />
아이디 기억 아이디 기억
</label> </label>
<button className="text-[12px] font-extrabold text-[#4f46e5]" type="button">
비밀번호를 잊으셨나요?
</button>
</div> </div>
<button <button
@@ -274,7 +275,7 @@ function InstructorSignupForm({ onCancel, onComplete }: InstructorSignupFormProp
const title = key === 'terms' ? '이용약관' : '개인정보 처리방침'; const title = key === 'terms' ? '이용약관' : '개인정보 처리방침';
setTermModal({ title, body: '', loading: true }); setTermModal({ title, body: '', loading: true });
try { try {
const response = await fetch(`${LAUNCHER_API_BASE_URL}/terms/${key}`); const response = await fetch(`${LAUNCHER_API_BASE_URL}/api/terms/${key}`);
if (!response.ok) throw new Error('약관을 불러오지 못했습니다.'); if (!response.ok) throw new Error('약관을 불러오지 못했습니다.');
const payload = await response.json() as { term?: { body?: string } }; const payload = await response.json() as { term?: { body?: string } };
setTermModal({ title, body: payload.term?.body || '등록된 약관 내용이 없습니다.', loading: false }); setTermModal({ title, body: payload.term?.body || '등록된 약관 내용이 없습니다.', loading: false });
@@ -295,7 +296,7 @@ function InstructorSignupForm({ onCancel, onComplete }: InstructorSignupFormProp
<span className="h-3 w-px bg-[#d8dde8]" /> <span className="h-3 w-px bg-[#d8dde8]" />
<h2 className="text-[26px] font-black tracking-[0] text-[#4f46e5]">강사 회원가입</h2> <h2 className="text-[26px] font-black tracking-[0] text-[#4f46e5]">강사 회원가입</h2>
</div> </div>
<p className="mt-2.5 text-[13px] font-bold text-[#6b7280]">강사 가입 코드를 입력해주세요.</p> <p className="mt-2.5 text-[13px] font-bold text-[#6b7280]">조직에서 받은 강사 가입 코드를 입력해주세요.</p>
</div> </div>
<form className="grid gap-3" onSubmit={handleSubmit}> <form className="grid gap-3" onSubmit={handleSubmit}>
<SignupInput label="이름" value={name} onChange={setName} placeholder="이름을 입력하세요" /> <SignupInput label="이름" value={name} onChange={setName} placeholder="이름을 입력하세요" />
@@ -324,16 +325,7 @@ function InstructorSignupForm({ onCancel, onComplete }: InstructorSignupFormProp
{termModal ? <div className="fixed inset-0 z-50 flex items-center justify-center bg-[#111827]/45 px-4 backdrop-blur-sm" role="dialog" aria-modal="true" aria-label={termModal.title}> {termModal ? <div className="fixed inset-0 z-50 flex items-center justify-center bg-[#111827]/45 px-4 backdrop-blur-sm" role="dialog" aria-modal="true" aria-label={termModal.title}>
<div className="flex max-h-[78vh] w-full max-w-2xl flex-col overflow-hidden rounded-lg border border-[#d8dde8] bg-white shadow-2xl"> <div className="flex max-h-[78vh] w-full max-w-2xl flex-col overflow-hidden rounded-lg border border-[#d8dde8] bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-[#e3e7ef] px-5 py-4"><h3 className="text-[15px] font-extrabold text-[#303747]">{termModal.title}</h3><button className="text-[20px] leading-none text-[#64748b] hover:text-[#111827]" type="button" onClick={() => setTermModal(null)} aria-label="닫기">×</button></div> <div className="flex items-center justify-between border-b border-[#e3e7ef] px-5 py-4"><h3 className="text-[15px] font-extrabold text-[#303747]">{termModal.title}</h3><button className="text-[20px] leading-none text-[#64748b] hover:text-[#111827]" type="button" onClick={() => setTermModal(null)} aria-label="닫기">×</button></div>
<div className="min-h-0 overflow-y-auto px-5 py-4 text-[13px] leading-6 text-[#4b5563]"> <div className="overflow-y-auto whitespace-pre-wrap px-5 py-4 text-[13px] leading-6 text-[#4b5563]">{termModal.loading ? '불러오는 중...' : termModal.body}</div>
{termModal.loading ? '불러오는 중...' : (
<div className="break-words [overflow-wrap:anywhere] [&_h1]:mb-4 [&_h1]:text-xl [&_h1]:font-bold [&_h2]:mb-3 [&_h2]:mt-6 [&_h2]:text-lg [&_h2]:font-bold [&_h3]:mb-2 [&_h3]:mt-4 [&_h3]:text-base [&_h3]:font-bold [&_p]:my-3 [&_ul]:my-3 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:my-3 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:my-1 [&_blockquote]:border-l-4 [&_blockquote]:border-[#d8dde8] [&_blockquote]:pl-4 [&_hr]:my-5 [&_hr]:border-[#d8dde8] [&_a]:text-[#2563eb] [&_a]:underline [&_pre]:overflow-x-auto [&_pre]:rounded [&_pre]:bg-[#f1f5f9] [&_pre]:p-3 [&_th]:border [&_th]:border-[#d8dde8] [&_th]:bg-[#f1f5f9] [&_th]:px-3 [&_th]:py-2 [&_td]:border [&_td]:border-[#d8dde8] [&_td]:px-3 [&_td]:py-2">
<ReactMarkdown remarkPlugins={[remarkGfm]} skipHtml components={{
a: ({ node: _node, ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" />,
table: ({ node: _node, ...props }) => <div className="my-3 overflow-x-auto"><table {...props} className="w-full border-collapse text-left" /></div>,
}}>{termModal.body}</ReactMarkdown>
</div>
)}
</div>
</div> </div>
</div> : null} </div> : null}
</> </>
+26 -65
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react'; import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { Activity, Car, CircleAlert, KeyRound, Loader2, Plus, RefreshCw, Search, Settings, ShieldAlert, Timer, Trash2, Users, X, type LucideIcon } from 'lucide-react'; import { Activity, Battery, Car, CircleAlert, KeyRound, Loader2, Plus, Search, Settings, ShieldAlert, Timer, Trash2, Users, X, type LucideIcon } from 'lucide-react';
import { ConfirmDialog } from '../../components/ConfirmDialog'; import { ConfirmDialog } from '../../components/ConfirmDialog';
import { PageHeader } from '../../components/PageHeader'; import { PageHeader } from '../../components/PageHeader';
import { PaginationBar } from '../../components/PaginationBar'; import { PaginationBar } from '../../components/PaginationBar';
@@ -12,7 +12,6 @@ import {
listInstructorPlatformAgents, listInstructorPlatformAgents,
listInstructorPlatforms, listInstructorPlatforms,
issueInstructorPlatformRegistration, issueInstructorPlatformRegistration,
syncInstructorPlatformAccess,
PlatformInUseError, PlatformInUseError,
updateInstructorPlatform, updateInstructorPlatform,
type InstructorPlatform, type InstructorPlatform,
@@ -68,7 +67,6 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
const [selectedMasterId, setSelectedMasterId] = useState(''); const [selectedMasterId, setSelectedMasterId] = useState('');
const [selectedAgentId, setSelectedAgentId] = useState(''); const [selectedAgentId, setSelectedAgentId] = useState('');
const [platformName, setPlatformName] = useState(''); const [platformName, setPlatformName] = useState('');
const [createError, setCreateError] = useState<string | null>(null);
const [agentsLoading, setAgentsLoading] = useState(false); const [agentsLoading, setAgentsLoading] = useState(false);
const [agentsError, setAgentsError] = useState<string | null>(null); const [agentsError, setAgentsError] = useState<string | null>(null);
const [registrationCode, setRegistrationCode] = useState<string | null>(null); const [registrationCode, setRegistrationCode] = useState<string | null>(null);
@@ -97,7 +95,6 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
const [assignmentGroupsError, setAssignmentGroupsError] = useState<string | null>(null); const [assignmentGroupsError, setAssignmentGroupsError] = useState<string | null>(null);
const [selectedAssignmentGroupId, setSelectedAssignmentGroupId] = useState(''); const [selectedAssignmentGroupId, setSelectedAssignmentGroupId] = useState('');
const [assigningPlatformId, setAssigningPlatformId] = useState<string | null>(null); const [assigningPlatformId, setAssigningPlatformId] = useState<string | null>(null);
const [syncingAccess, setSyncingAccess] = useState(false);
const [forceAssignmentTarget, setForceAssignmentTarget] = useState<{ platform: InstructorPlatform; group: InstructorGroup | null; session: PlatformInUseError['activeSession'] } | null>(null); const [forceAssignmentTarget, setForceAssignmentTarget] = useState<{ platform: InstructorPlatform; group: InstructorGroup | null; session: PlatformInUseError['activeSession'] } | null>(null);
const filteredPlatforms = useMemo(() => { const filteredPlatforms = useMemo(() => {
@@ -121,22 +118,7 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
}); });
return keys; return keys;
}, [platforms]); }, [platforms]);
const sortedAgents = useMemo(() => [...agents].sort((a, b) =>
Number(isRegisteredAgent(a, registeredAgentKeys)) - Number(isRegisteredAgent(b, registeredAgentKeys)),
), [agents, registeredAgentKeys]);
const pagedPlatforms = useMemo(() => filteredPlatforms.slice((page - 1) * pageSize, page * pageSize), [filteredPlatforms, page, pageSize]); const pagedPlatforms = useMemo(() => filteredPlatforms.slice((page - 1) * pageSize, page * pageSize), [filteredPlatforms, page, pageSize]);
const vehicleTypeOptions = useMemo(() => {
const options = new Map<string, string>();
const addOption = (value: string | null | undefined) => {
const normalized = value?.trim();
if (!normalized || normalized.toLowerCase() === 'simulator') return;
options.set(normalized.toLowerCase(), normalized);
};
masterPlatforms.forEach((platform) => addOption(platform.vehicleType));
addOption(settingsTarget?.vehicleType);
return Array.from(options.values());
}, [masterPlatforms, settingsTarget?.vehicleType]);
const summaryItems = [ const summaryItems = [
{ label: '전체 플랫폼', value: `${summary.totalPlatforms}대`, icon: Car, tone: 'bg-[#eff6ff] text-[#2563eb]' }, { label: '전체 플랫폼', value: `${summary.totalPlatforms}대`, icon: Car, tone: 'bg-[#eff6ff] text-[#2563eb]' },
{ label: '활성 플랫폼', value: `${summary.activePlatforms}대`, icon: Activity, tone: 'bg-[#f0efff] text-[#4f46e5]' }, { label: '활성 플랫폼', value: `${summary.activePlatforms}대`, icon: Activity, tone: 'bg-[#f0efff] text-[#4f46e5]' },
@@ -219,7 +201,6 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
}; };
const handleOpenCreateModal = () => { const handleOpenCreateModal = () => {
setCreateError(null);
setModalOpen(true); setModalOpen(true);
setSelectedAgentId(''); setSelectedAgentId('');
setAgents([]); setAgents([]);
@@ -239,7 +220,6 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
}; };
const handleCreatePlatform = async () => { const handleCreatePlatform = async () => {
setCreateError(null);
if (!selectedMasterId) return; if (!selectedMasterId) return;
if (!selectedAgentId) { if (!selectedAgentId) {
setAgentsError('플랫폼에 연결할 에이전트를 선택해 주세요.'); setAgentsError('플랫폼에 연결할 에이전트를 선택해 주세요.');
@@ -262,7 +242,7 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
message: created?.name ?? masterPlatforms.find((platform) => platform.id === selectedMasterId)?.name, message: created?.name ?? masterPlatforms.find((platform) => platform.id === selectedMasterId)?.name,
}); });
} catch (reason) { } catch (reason) {
setCreateError(reason instanceof Error ? reason.message : '플랫폼을 등록하지 못했습니다.'); setError(reason instanceof Error ? reason.message : '플랫폼을 등록하지 못했습니다.');
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -282,22 +262,6 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
} }
}; };
const handleSyncPlatformAccess = async () => {
setSyncingAccess(true);
setError(null);
try {
const result = await syncInstructorPlatformAccess(session.accessToken);
showToast({
title: '플랫폼 접근 설정을 동기화했습니다.',
message: `현재 배정 기준 ${result.assignmentCount}개 플랫폼을 반영했습니다.`,
});
} catch (reason) {
setError(reason instanceof Error ? reason.message : '플랫폼 접근 설정을 동기화하지 못했습니다.');
} finally {
setSyncingAccess(false);
}
};
const handleDeletePlatform = async (platform: InstructorPlatform, force = false) => { const handleDeletePlatform = async (platform: InstructorPlatform, force = false) => {
setDeletingPlatformId(platform.id); setDeletingPlatformId(platform.id);
setError(null); setError(null);
@@ -414,15 +378,6 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
route="platforms" route="platforms"
action={ action={
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button
className="inline-flex h-9 items-center gap-2 rounded-md border border-[#d1d9e6] bg-white px-3 text-[13px] font-bold text-[#475569] hover:bg-[#f8fafc] disabled:cursor-not-allowed disabled:opacity-60"
type="button"
onClick={handleSyncPlatformAccess}
disabled={syncingAccess}
>
{syncingAccess ? <Loader2 className="animate-spin" size={15} aria-hidden="true" /> : <RefreshCw size={15} aria-hidden="true" />}
접근 동기화
</button>
<button <button
className="inline-flex h-9 items-center gap-2 rounded-md border border-[#2563eb] bg-white px-3 text-[13px] font-bold text-[#2563eb] hover:bg-[#eff6ff]" className="inline-flex h-9 items-center gap-2 rounded-md border border-[#2563eb] bg-white px-3 text-[13px] font-bold text-[#2563eb] hover:bg-[#eff6ff]"
type="button" type="button"
@@ -453,7 +408,7 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[#cbd5e1] px-4 py-3"> <div className="flex flex-wrap items-center justify-between gap-3 border-b border-[#cbd5e1] px-4 py-3">
<div> <div>
<h2 className="text-[15px] font-black text-[#111827]">플랫폼 리스트</h2> <h2 className="text-[15px] font-black text-[#111827]">플랫폼 리스트</h2>
<p className="mt-1 text-[12px] font-bold text-[#64748b]">가동 시간, 할당 그룹 상태 조회</p> <p className="mt-1 text-[12px] font-bold text-[#64748b]">배터리, 가동 시간, 할당 그룹 상태 조회</p>
</div> </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]"> <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]">
@@ -464,11 +419,12 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<div className="xl:min-w-[1020px]"> <div className="xl:min-w-[1020px]">
<div className="hidden select-none gap-4 grid-cols-[44px_minmax(200px,1fr)_110px_92px_110px_150px_184px] items-center border-b border-[#cbd5e1] bg-[#f8fafc] px-4 py-3 text-center text-[11px] font-bold text-[#64748b] xl:grid"> <div className="hidden select-none gap-4 grid-cols-[44px_200px_110px_92px_90px_110px_150px_184px] 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>No.</span>
<span>플랫폼</span> <span>플랫폼</span>
<span>연결</span> <span>연결</span>
<span>상태</span> <span>상태</span>
<span>배터리</span>
<span>가동 시간</span> <span>가동 시간</span>
<span>할당</span> <span>할당</span>
<span>관리</span> <span>관리</span>
@@ -486,7 +442,7 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
</div> </div>
) : pagedPlatforms.length > 0 ? ( ) : pagedPlatforms.length > 0 ? (
pagedPlatforms.map((platform, index) => ( pagedPlatforms.map((platform, index) => (
<article className="grid select-none gap-4 px-4 py-4 xl:grid-cols-[44px_minmax(200px,1fr)_110px_92px_110px_150px_184px] xl:items-center" key={platform.id}> <article className="grid select-none gap-4 px-4 py-4 xl:grid-cols-[44px_200px_110px_92px_90px_110px_150px_184px] xl:items-center" key={platform.id}>
<div className="text-center"> <div className="text-center">
<p className="mb-2 text-[11px] font-bold text-[#94a3b8] xl:hidden">번호</p> <p className="mb-2 text-[11px] font-bold text-[#94a3b8] xl:hidden">번호</p>
<span className="text-[13px] font-bold text-[#64748b]">{filteredPlatforms.length - ((page - 1) * pageSize + index)}</span> <span className="text-[13px] font-bold text-[#64748b]">{filteredPlatforms.length - ((page - 1) * pageSize + index)}</span>
@@ -498,7 +454,7 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
</span> </span>
<div className="min-w-0"> <div className="min-w-0">
<h3 className="truncate text-[14px] font-black text-[#111827]">{platform.name}</h3> <h3 className="truncate text-[14px] font-black text-[#111827]">{platform.name}</h3>
<p className="mt-1 break-words text-[12px] font-bold text-[#64748b]">{platform.type}</p> <p className="mt-1 text-[12px] font-bold text-[#64748b]">{platform.type}</p>
</div> </div>
</div> </div>
@@ -521,6 +477,19 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
<span className={`rounded px-1.5 py-0.5 text-[11px] font-bold ${statusTone(platform.status)}`}>{platform.status}</span> <span className={`rounded px-1.5 py-0.5 text-[11px] font-bold ${statusTone(platform.status)}`}>{platform.status}</span>
</div> </div>
<div>
<div className="mb-2 flex items-center justify-between text-[12px] font-bold text-[#475569] xl:justify-center">
<span className="inline-flex items-center gap-1.5 xl:hidden">
<Battery size={13} aria-hidden="true" />
배터리
</span>
<span className="font-semibold text-[#4f46e5]">{platform.battery}%</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-[#dbe3ef]">
<div className="h-full rounded-full bg-[#4f46e5]" style={{ width: `${platform.battery}%` }} />
</div>
</div>
<PlatformMeta icon={Timer} label="가동 시간" value={platform.uptime} /> <PlatformMeta icon={Timer} label="가동 시간" value={platform.uptime} />
<div className="text-center"> <div className="text-center">
<p className="mb-2 text-[11px] font-bold text-[#94a3b8] xl:hidden">할당</p> <p className="mb-2 text-[11px] font-bold text-[#94a3b8] xl:hidden">할당</p>
@@ -621,7 +590,7 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
<div className="p-5"> <div className="p-5">
<div> <div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-[12px] font-black text-[#475569]">검색된 에이전트</span> <span className="text-[12px] font-black text-[#475569]">등록된 에이전트</span>
<span className="text-[11px] font-bold text-[#94a3b8]"> <span className="text-[11px] font-bold text-[#94a3b8]">
{agentsLoading ? '조회 중' : `${agents.filter((agent) => agent.connected && !agent.expired).length}/${agents.length} 연결`} {agentsLoading ? '조회 중' : `${agents.filter((agent) => agent.connected && !agent.expired).length}/${agents.length} 연결`}
</span> </span>
@@ -634,7 +603,7 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
</div> </div>
) : agentsError ? ( ) : agentsError ? (
<div className="rounded-md border border-dashed border-[#fecdd3] bg-[#fff1f2] px-3 py-8 text-center text-[12px] font-bold text-[#e11d48]">{agentsError}</div> <div className="rounded-md border border-dashed border-[#fecdd3] bg-[#fff1f2] px-3 py-8 text-center text-[12px] font-bold text-[#e11d48]">{agentsError}</div>
) : sortedAgents.length ? sortedAgents.map((agent) => { ) : agents.length ? agents.map((agent) => {
const selected = selectedAgentId === agent.id; const selected = selectedAgentId === agent.id;
const available = agent.connected && !agent.expired; const available = agent.connected && !agent.expired;
const registered = isRegisteredAgent(agent, registeredAgentKeys); const registered = isRegisteredAgent(agent, registeredAgentKeys);
@@ -692,11 +661,6 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
placeholder="예: F1Tenth #01" placeholder="예: F1Tenth #01"
/> />
</label> </label>
{createError ? (
<p role="alert" className="mt-3 rounded-md border border-[#fecdd3] bg-[#fff1f2] px-3 py-2 text-[12px] font-bold text-[#be123c]">
{createError}
</p>
) : null}
</div> </div>
<div className="flex justify-end gap-2 border-t border-[#cbd5e1] px-5 py-4"> <div className="flex justify-end gap-2 border-t border-[#cbd5e1] px-5 py-4">
@@ -812,7 +776,7 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
<div className="flex items-start justify-between gap-4 border-b border-[#c7d2e0] bg-[#f8fbff] px-5 py-4"> <div className="flex items-start justify-between gap-4 border-b border-[#c7d2e0] bg-[#f8fbff] px-5 py-4">
<div> <div>
<h3 className="text-[16px] font-black text-[#111827]">{settingsTarget.name}</h3> <h3 className="text-[16px] font-black text-[#111827]">{settingsTarget.name}</h3>
<p className="mt-1 text-[13px] font-semibold text-[#64748b]">차량 제원 및 제어 시간 설정</p> <p className="mt-1 text-[13px] font-semibold text-[#64748b]">센서 드라이버 및 위치 설정</p>
</div> </div>
<button className="grid h-8 w-8 place-items-center rounded-md text-[#64748b] hover:bg-[#f1f5f9] hover:text-[#111827]" type="button" onClick={() => setSettingsTarget(null)} aria-label="닫기" title="닫기"> <button className="grid h-8 w-8 place-items-center rounded-md text-[#64748b] hover:bg-[#f1f5f9] hover:text-[#111827]" type="button" onClick={() => setSettingsTarget(null)} aria-label="닫기" title="닫기">
<X size={17} aria-hidden="true" /> <X size={17} aria-hidden="true" />
@@ -827,9 +791,9 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
</SettingsField> </SettingsField>
<SettingsField label="차량 타입"> <SettingsField label="차량 타입">
<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]" value={settingsVehicleType} onChange={(event) => setSettingsVehicleType(event.target.value)}> <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]" value={settingsVehicleType} onChange={(event) => setSettingsVehicleType(event.target.value)}>
{vehicleTypeOptions.map((vehicleType) => ( <option>F1tenth</option>
<option key={vehicleType} value={vehicleType}>{vehicleType}</option> <option>MK-mini</option>
))} <option>Simulator</option>
</select> </select>
</SettingsField> </SettingsField>
<SettingsField label="휠베이스 (m)"> <SettingsField label="휠베이스 (m)">
@@ -844,7 +808,6 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
</div> </div>
</SettingsSection> </SettingsSection>
<div hidden>
<SettingsSection title="부착 센서"> <SettingsSection title="부착 센서">
{settingsSensors.map((sensor, index) => ( {settingsSensors.map((sensor, index) => (
<SensorSettingRow <SensorSettingRow
@@ -876,8 +839,6 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
</div> </div>
</SettingsSection> </SettingsSection>
</div>
<SettingsSection title="제어 시간 설정"> <SettingsSection title="제어 시간 설정">
<div className="grid gap-3 md:grid-cols-2"> <div className="grid gap-3 md:grid-cols-2">
<SettingsField label="1회 제어 시간 (분)"> <SettingsField label="1회 제어 시간 (분)">
+4 -342
View File
@@ -1,344 +1,6 @@
import { useEffect, useRef, useState } from 'react'; import { Settings } from 'lucide-react';
import { Bot, KeyRound, Loader2, RefreshCw, Save } from 'lucide-react'; import { PlaceholderPage } from '../common/PlaceholderPage';
import { PageHeader } from '../../components/PageHeader';
import { useToast } from '../../components/ToastProvider';
import { getCourseAiSetting, updateCourseAiSetting, type CourseAiSetting } from '../../lib/curriculum';
import type { InstructorCourse } from '../../lib/courses';
import type { AuthSession } from '../../types/auth';
const AI_MODEL_DEFAULTS: Record<CourseAiSetting['provider'], string> = { export function SettingsPage() {
A_COPILOT: 'a-copilot', return <PlaceholderPage icon={Settings} route="settings" items={['권한 정책', '알림 설정', '서비스 연결']} />;
OPENAI: 'gpt-6-astra',
ANTHROPIC: 'claude-fable-5-1',
GEMINI: 'gemini-4-argon',
QWEN: 'qwen3.8-max',
};
const DEFAULT_QWEN_ENDPOINT = 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1';
const CUSTOM_AI_MODEL = '__custom__';
const AI_PROVIDER_LABELS: Record<CourseAiSetting['provider'], string> = {
A_COPILOT: 'A-Copilot',
OPENAI: 'OpenAI',
ANTHROPIC: 'Claude',
GEMINI: 'Gemini',
QWEN: 'Qwen',
};
const AI_MODEL_OPTIONS: Record<Exclude<CourseAiSetting['provider'], 'A_COPILOT'>, Array<{ value: string; label: string }>> = {
// Official provider model catalogs checked on 2026-10-19; keep existing selections intact.
OPENAI: [
{ value: 'gpt-6-astra', label: 'GPT-6 Astra' },
{ value: 'gpt-6.1-sol', label: 'GPT-6.1 Sol' },
{ value: 'gpt-6-luna', label: 'GPT-6 Luna' },
{ value: 'gpt-4.1-mini', label: 'GPT-4.1 Mini' },
{ value: 'gpt-4.1', label: 'GPT-4.1' },
{ value: 'gpt-4o-mini', label: 'GPT-4o Mini' },
],
ANTHROPIC: [
{ value: 'claude-fable-5-1', label: 'Claude Fable 5.1' },
{ value: 'claude-opus-5-5', label: 'Claude Opus 5.5' },
{ value: 'claude-sonnet-5-5', label: 'Claude Sonnet 5.5' },
{ value: 'claude-haiku-5-5', label: 'Claude Haiku 5.5' },
{ value: 'claude-sonnet-4-5', label: 'Claude Sonnet 4.5' },
{ value: 'claude-haiku-4-5', label: 'Claude Haiku 4.5' },
{ value: 'claude-opus-4-1', label: 'Claude Opus 4.1' },
],
GEMINI: [
{ value: 'gemini-4-argon', label: 'Gemini 4 Argon' },
{ value: 'gemini-3.8-flash', label: 'Gemini 3.8 Flash' },
{ value: 'gemini-3.5-flash-lite', label: 'Gemini 3.5 Flash-Lite' },
{ value: 'gemini-3.1-pro-preview', label: 'Gemini 3.1 Pro (Preview)' },
{ value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
{ value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
{ value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' },
],
QWEN: [
{ value: 'qwen3.8-max', label: 'Qwen 3.8 Max' },
{ value: 'qwen3.8-max-0902', label: 'Qwen 3.8 Max (0902)' },
{ value: 'qwen3.8-flash', label: 'Qwen 3.8 Flash' },
{ value: 'qwen3.7-plus', label: 'Qwen 3.7 Plus' },
{ value: 'qwen3.7-flash', label: 'Qwen 3.7 Flash' },
{ value: 'qwen-vl-plus', label: 'Qwen VL Plus' },
{ value: 'qwen-vl-max', label: 'Qwen VL Max' },
{ value: 'qwen-plus', label: 'Qwen Plus (text)' },
],
};
type AiSettingDraft = {
enabled: boolean;
model: string;
customModel: boolean;
endpointUrl: string;
apiKey: string;
hasApiKey: boolean;
};
function aiSettingDraft(provider: CourseAiSetting['provider'], setting?: CourseAiSetting): AiSettingDraft {
const model = provider === 'A_COPILOT' ? AI_MODEL_DEFAULTS.A_COPILOT : setting?.model ?? AI_MODEL_DEFAULTS[provider];
return {
enabled: setting?.enabled ?? false,
model,
customModel: provider !== 'A_COPILOT' && !AI_MODEL_OPTIONS[provider].some(option => option.value === model),
endpointUrl: setting?.endpointUrl || DEFAULT_QWEN_ENDPOINT,
apiKey: '',
hasApiKey: setting?.hasApiKey ?? false,
};
}
function aiSettingDrafts(settings: CourseAiSetting[] = []) {
return Object.fromEntries((Object.keys(AI_PROVIDER_LABELS) as CourseAiSetting['provider'][])
.map(provider => [provider, aiSettingDraft(provider, settings.find(setting => setting.provider === provider))])) as Record<CourseAiSetting['provider'], AiSettingDraft>;
}
type SettingsPageProps = {
session: AuthSession;
selectedCourseId: string;
courses: InstructorCourse[];
};
export function SettingsPage({ session, selectedCourseId, courses }: SettingsPageProps) {
const selectedCourse = courses.find(course => course.id === selectedCourseId);
return (
<div className="mx-auto w-full max-w-[920px]">
<PageHeader route="settings" />
{selectedCourse ? (
<CourseAiSettings key={`${session.user.id}:${session.accessToken}:${selectedCourseId}`} session={session} selectedCourseId={selectedCourseId} />
) : (
<section className="border-y border-[#d1d9e6] bg-white px-5 py-8">
<h2 className="flex items-center gap-2 text-[15px] font-black text-[#111827]"><Bot size={17} className="text-[#2563eb]" aria-hidden="true" />AI 에이전트</h2>
<p className="mt-3 text-[13px] font-semibold text-[#64748b]">설정할 강좌가 없습니다.</p>
</section>
)}
</div>
);
}
function CourseAiSettings({ session, selectedCourseId }: Pick<SettingsPageProps, 'session' | 'selectedCourseId'>) {
const { showToast } = useToast();
const [aiProvider, setAiProvider] = useState<CourseAiSetting['provider']>('A_COPILOT');
const [drafts, setDrafts] = useState(aiSettingDrafts);
const [aiCopilotConfigured, setAiCopilotConfigured] = useState(false);
const [aiSettingLoading, setAiSettingLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [loaded, setLoaded] = useState(false);
const [loadAttempt, setLoadAttempt] = useState(0);
const [error, setError] = useState<string | null>(null);
const mounted = useRef(false);
const draft = drafts[aiProvider];
const aiEnabled = draft.enabled;
const aiModel = draft.model;
const aiCustomModel = draft.customModel;
const aiEndpointUrl = draft.endpointUrl;
const aiApiKey = draft.apiKey;
const aiHasApiKey = draft.hasApiKey;
const aiConfiguredProvider = aiHasApiKey ? aiProvider : null;
const updateDraft = (update: Partial<AiSettingDraft>) => setDrafts(current => ({ ...current, [aiProvider]: { ...current[aiProvider], ...update } }));
const setAiEnabled = (enabled: boolean) => updateDraft({ enabled });
const setAiModel = (model: string) => updateDraft({ model });
const setAiCustomModel = (customModel: boolean) => updateDraft({ customModel });
const setAiEndpointUrl = (endpointUrl: string) => updateDraft({ endpointUrl });
const setAiApiKey = (apiKey: string) => updateDraft({ apiKey });
const applySetting = (setting: CourseAiSetting) => {
setDrafts(current => ({ ...current, [setting.provider]: aiSettingDraft(setting.provider, setting) }));
setAiCopilotConfigured(setting.copilotConfigured === true);
};
useEffect(() => {
mounted.current = true;
return () => { mounted.current = false; };
}, []);
useEffect(() => {
let cancelled = false;
setAiSettingLoading(true);
setLoaded(false);
setError(null);
getCourseAiSetting(session.accessToken, selectedCourseId)
.then(setting => {
if (cancelled) return;
setDrafts(aiSettingDrafts(setting.settings ?? [setting]));
setAiProvider(setting.provider);
setAiCopilotConfigured(setting.copilotConfigured === true);
setLoaded(true);
})
.catch((reason: Error) => {
if (!cancelled) setError(reason.message);
})
.finally(() => {
if (!cancelled) setAiSettingLoading(false);
});
return () => { cancelled = true; };
}, [session.accessToken, selectedCourseId, loadAttempt]);
const handleSaveAiSetting = async () => {
if (!loaded || aiSettingLoading || saving) return;
if (aiProvider !== 'A_COPILOT' && (!aiModel.trim() || aiModel.trim().length > 120)) {
setError('모델 ID는 1~120자로 입력해주세요.');
return;
}
setSaving(true);
setError(null);
try {
const setting = await updateCourseAiSetting(session.accessToken, selectedCourseId, {
enabled: aiEnabled,
provider: aiProvider,
...(aiProvider !== 'A_COPILOT' ? { model: aiModel.trim() } : {}),
...(aiProvider === 'QWEN' ? { endpointUrl: aiEndpointUrl.trim() } : {}),
...(aiProvider !== 'A_COPILOT' && aiApiKey.trim() ? { apiKey: aiApiKey.trim() } : {}),
});
if (!mounted.current) return;
applySetting(setting);
showToast({ title: 'AI 에이전트 설정이 저장되었습니다.', message: setting.enabled ? setting.provider === 'A_COPILOT' ? 'A-Copilot' : `${AI_PROVIDER_LABELS[setting.provider]} · ${setting.model}` : '사용 안 함' });
} catch (reason) {
if (mounted.current) setError(reason instanceof Error ? reason.message : 'AI 에이전트 설정을 저장하지 못했습니다.');
} finally {
if (mounted.current) setSaving(false);
}
};
return (
<form
className="border-y border-[#d1d9e6] bg-white"
onSubmit={event => { event.preventDefault(); void handleSaveAiSetting(); }}
>
<fieldset disabled={!loaded || aiSettingLoading || saving} className="min-w-0 px-5 py-5">
<legend className="sr-only">AI 에이전트</legend>
<div className="flex items-center justify-between gap-4 pb-5">
<h2 className="flex items-center gap-2 text-[15px] font-black text-[#111827]"><Bot size={17} className="text-[#2563eb]" aria-hidden="true" />AI 에이전트</h2>
<label className="inline-flex shrink-0 cursor-pointer items-center gap-2 text-[12px] font-black text-[#475569]">
<input type="checkbox" aria-label={`${AI_PROVIDER_LABELS[aiProvider]} 사용`} checked={aiEnabled} onChange={event => setAiEnabled(event.target.checked)} className="h-4 w-4 rounded border-[#cbd5e1] text-[#2563eb] focus:ring-[#2563eb]" />
{AI_PROVIDER_LABELS[aiProvider]} 사용
</label>
</div>
{aiSettingLoading ? (
<p role="status" className="flex items-center gap-2 border-t border-[#e2e8f0] py-8 text-[13px] font-semibold text-[#64748b]"><Loader2 size={16} className="animate-spin" aria-hidden="true" />불러오는 중</p>
) : loaded ? (
<section className="border-t border-[#e2e8f0] pt-5">
<div>
<span className="text-[12px] font-black text-[#475569]">AI 공급자</span>
<div className="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-5" role="radiogroup" aria-label="AI 공급자">
{(Object.keys(AI_PROVIDER_LABELS) as CourseAiSetting['provider'][]).map((provider) => (
<button
key={provider}
type="button"
role="radio"
aria-label={AI_PROVIDER_LABELS[provider]}
aria-checked={aiProvider === provider}
disabled={aiSettingLoading || saving}
onClick={() => {
if (provider === aiProvider) return;
setAiProvider(provider);
setError(null);
}}
className={`h-10 rounded-md border text-[13px] font-black transition disabled:cursor-not-allowed disabled:opacity-60 ${
aiProvider === provider
? 'border-[#2563eb] bg-[#eff6ff] text-[#1d4ed8] shadow-[0_0_0_1px_rgba(37,99,235,0.12)]'
: 'border-[#cbd5e1] bg-white text-[#475569] hover:border-[#94a3b8] hover:bg-[#f8fafc]'
}`}
>
<span>{AI_PROVIDER_LABELS[provider]}</span>
<span className="mt-0.5 flex items-center justify-center gap-1 text-[10px] font-semibold">
<span className={`size-1.5 rounded-full ${drafts[provider].enabled ? 'bg-[#16a34a]' : 'bg-[#94a3b8]'}`} aria-hidden="true" />
{drafts[provider].enabled ? '사용' : '미사용'}
</span>
</button>
))}
</div>
</div>
{aiProvider === 'A_COPILOT' ? (
<div className="mt-4 grid gap-4">
<label className="block">
<span className="text-[12px] font-black text-[#475569]">모델</span>
<input type="text" aria-label="모델" readOnly value="A-Copilot" className="mt-1.5 h-10 w-full cursor-default rounded-md border border-[#d1d9e6] bg-[#f8fafc] px-3 text-[13px] font-bold text-[#64748b] outline-none" />
</label>
<label className="block">
<span className="flex items-center gap-1.5 text-[12px] font-black text-[#475569]"><KeyRound size={13} aria-hidden="true" />API 키</span>
<input type="text" aria-label="API 키" readOnly value={aiCopilotConfigured ? '서버에서 관리' : '서버 미설정'} className="mt-1.5 h-10 w-full cursor-default rounded-md border border-[#d1d9e6] bg-[#f8fafc] px-3 text-[13px] font-bold text-[#64748b] outline-none" />
</label>
{!aiCopilotConfigured ? <p role="alert" className="text-[12px] font-semibold text-[#be123c]">A-Copilot 서버 연결이 설정되어 있지 않습니다.</p> : null}
</div>
) : (
<>
<label className="mt-4 block">
<span className="text-[12px] font-black text-[#475569]">모델</span>
<select
aria-label="모델"
value={aiCustomModel ? CUSTOM_AI_MODEL : aiModel}
disabled={aiSettingLoading || saving}
onChange={(event) => {
const custom = event.target.value === CUSTOM_AI_MODEL;
setAiCustomModel(custom);
setAiModel(custom ? '' : event.target.value);
}}
className="mt-1.5 h-10 w-full rounded-md border border-[#d1d9e6] bg-white px-3 text-[13px] font-bold text-[#111827] outline-none focus:border-[#2563eb]"
>
{AI_MODEL_OPTIONS[aiProvider].map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
<option value={CUSTOM_AI_MODEL}>직접 입력</option>
</select>
</label>
{aiCustomModel ? (
<label className="mt-3 block">
<span className="text-[12px] font-black text-[#475569]">모델 ID</span>
<input
type="text"
value={aiModel}
disabled={aiSettingLoading || saving}
onChange={(event) => setAiModel(event.target.value)}
maxLength={120}
autoComplete="off"
spellCheck={false}
placeholder={AI_MODEL_OPTIONS[aiProvider][0].value}
className="mt-1.5 h-10 w-full rounded-md border border-[#d1d9e6] bg-white px-3 font-mono text-[12px] font-bold text-[#111827] outline-none focus:border-[#2563eb] disabled:opacity-60"
/>
</label>
) : null}
{aiProvider === 'QWEN' ? (
<label className="mt-4 block">
<span className="text-[12px] font-black text-[#475569]">엔드포인트 주소</span>
<input
type="url"
value={aiEndpointUrl}
onChange={(event) => setAiEndpointUrl(event.target.value)}
placeholder={DEFAULT_QWEN_ENDPOINT}
className="mt-1.5 h-10 w-full rounded-md border border-[#d1d9e6] bg-white px-3 font-mono text-[12px] font-bold text-[#111827] outline-none focus:border-[#2563eb]"
/>
<span className="mt-1.5 block text-[11px] font-semibold text-[#64748b]">OpenAI 호환 API의 기본 주소를 입력하세요. HTTPS 주소만 사용할 수 있습니다.</span>
</label>
) : null}
<label className="mt-4 block">
<span className="flex items-center gap-1.5 text-[12px] font-black text-[#475569]"><KeyRound size={13} aria-hidden="true" />API 키</span>
<input
type="password"
autoComplete="new-password"
value={aiApiKey}
onChange={(event) => setAiApiKey(event.target.value)}
placeholder={aiHasApiKey && aiConfiguredProvider === aiProvider ? '설정됨 · 변경할 때만 새 키 입력' : 'API 키 입력'}
className="mt-1.5 h-10 w-full rounded-md border border-[#d1d9e6] bg-white px-3 text-[13px] font-bold text-[#111827] outline-none focus:border-[#2563eb]"
/>
<span className="mt-1.5 block text-[11px] font-semibold text-[#64748b]">키는 서버에서 암호화되어 저장되며 학생 앱으로 전달되지 않습니다.</span>
</label>
</>
)}
</section>
) : null}
</fieldset>
{error ? (
<div role="alert" className="mx-5 mb-5 flex flex-wrap items-center justify-between gap-3 rounded-md border border-[#fecdd3] bg-[#fff1f2] px-4 py-3 text-[13px] font-semibold text-[#be123c]">
<span className="min-w-0 break-words">{error}</span>
{!loaded ? <button type="button" onClick={() => setLoadAttempt(attempt => attempt + 1)} className="inline-flex h-8 shrink-0 cursor-pointer items-center gap-1.5 rounded-md border border-[#fda4af] px-3"><RefreshCw size={14} aria-hidden="true" />다시 시도</button> : null}
</div>
) : null}
<div className="flex justify-end border-t border-[#e2e8f0] px-5 py-4">
<button type="submit" disabled={!loaded || aiSettingLoading || saving || (aiProvider === 'A_COPILOT' ? aiEnabled && !aiCopilotConfigured : !aiModel.trim() || aiModel.trim().length > 120)} className="inline-flex h-10 cursor-pointer items-center justify-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">
{saving ? <Loader2 size={15} className="animate-spin" aria-hidden="true" /> : <Save size={15} aria-hidden="true" />}
{AI_PROVIDER_LABELS[aiProvider]} 설정 저장
</button>
</div>
</form>
);
} }
+3 -2
View File
@@ -1,4 +1,4 @@
import { Activity, BookOpen, Car, ClipboardList, GitPullRequest, Settings, Users, type LucideIcon } from 'lucide-react'; 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 AppRoute = 'dashboard' | 'curriculum' | 'groups' | 'platforms' | 'students' | 'submissions' | 'analytics' | 'settings' | 'profile';
@@ -30,6 +30,7 @@ export const navItems: NavItem[] = [
{ id: 'platforms', label: '플랫폼 관리', icon: Car }, { id: 'platforms', label: '플랫폼 관리', icon: Car },
{ id: 'students', label: '수강생 현황', icon: BookOpen }, { id: 'students', label: '수강생 현황', icon: BookOpen },
{ id: 'submissions', label: '제출 현황', icon: GitPullRequest }, { id: 'submissions', label: '제출 현황', icon: GitPullRequest },
{ id: 'analytics', label: '통계 분석', icon: BarChart3 },
{ id: 'settings', label: '설정', icon: Settings } { id: 'settings', label: '설정', icon: Settings }
]; ];
@@ -43,7 +44,7 @@ export const navItems: NavItem[] = [
// settings: ['권한 정책', '알림 설정', '서비스 연결'] // settings: ['권한 정책', '알림 설정', '서비스 연결']
// }; // };
const appRoutes = Object.keys(routePaths) as AppRoute[]; const appRoutes: AppRoute[] = [...navItems.map((item) => item.id), 'profile'];
const publicBasePath = import.meta.env.BASE_URL.replace(/\/+$/, ''); const publicBasePath = import.meta.env.BASE_URL.replace(/\/+$/, '');
const stripPublicBasePath = (pathname: string): string => { const stripPublicBasePath = (pathname: string): string => {