Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1dd065ec79 | |||
| 675c990380 | |||
| 1290171d9c | |||
| 5aac96fba3 | |||
| ebdf3775db | |||
| 34f930421a | |||
| eb643d5d7d | |||
| 238a84423a | |||
| 7654822424 | |||
| c08ba5d0a1 | |||
| c553e589a5 | |||
| 9644daa900 | |||
| 6a5bd04ee2 | |||
| 1afa60e237 | |||
| 2157f8eaa2 | |||
| cf5baebdeb | |||
| 551b5fe939 | |||
| 25cb8e1e0f | |||
| 7bf76c4265 | |||
| 8e15589413 | |||
| 6b45e51105 | |||
| ad41509aa4 | |||
| d44b235bd6 | |||
| c1d3c660c2 | |||
| 14e0cee1a0 | |||
| 61dd5d25e7 | |||
| 3b4bed7089 | |||
| 102d37c1af | |||
| 9421431fb5 | |||
| 7d51bf81b5 | |||
| 3228a9c520 | |||
| 7370790aa0 | |||
| 97043579bc | |||
| d029bdeda7 |
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.*
|
||||
*.tsbuildinfo
|
||||
@@ -0,0 +1,4 @@
|
||||
VITE_API_BASE_URL=https://workspace.autonomystudio.net/lms-api/api
|
||||
VITE_LAUNCHER_API_BASE_URL=https://portal-api.autonomystudio.net/api
|
||||
VITE_PUBLIC_BASE_PATH=/lms-adm/
|
||||
VITE_ATNMY_AUTH_URL=https://auth.autonomystudio.net
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.local
|
||||
*.tsbuildinfo
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"hash": "c60753b8",
|
||||
"configHash": "84cc1fb0",
|
||||
"lockfileHash": "707b26ba",
|
||||
"browserHash": "992b5a32",
|
||||
"optimized": {},
|
||||
"chunks": {}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html/lms-adm
|
||||
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -1,3 +1,19 @@
|
||||
# lms-adm-fe
|
||||
# LMS Instructor Admin
|
||||
|
||||
LMS 강사 웹
|
||||
React 기반 강사용 LMS 관리 프론트엔드입니다. 학생용 Electron 앱(`/home/chyoo/tmp/lms`)과 같은 백엔드를 바라보는 웹 관리자 콘솔로 분리하는 구조를 기준으로 만들었습니다.
|
||||
|
||||
## Scripts
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Environment
|
||||
|
||||
`.env.example`을 참고해 API 서버 주소를 설정합니다.
|
||||
|
||||
```bash
|
||||
VITE_API_BASE_URL=http://localhost:4400/api
|
||||
```
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>오토노미스튜디오 LMS | 강사로그인</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location = /lms-adm {
|
||||
return 301 /lms-adm/;
|
||||
}
|
||||
|
||||
location /lms-adm/ {
|
||||
try_files $uri $uri/ /lms-adm/index.html;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location = /health {
|
||||
access_log off;
|
||||
add_header Content-Type text/plain;
|
||||
return 200 "ok\n";
|
||||
}
|
||||
}
|
||||
Generated
+4038
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "lms-adm-fe",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Instructor web admin for Autonomy Studio LMS",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"lucide-react": "^1.14.0",
|
||||
"monaco-editor": "^0.56.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.18.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"vite": "^6.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
@@ -0,0 +1,164 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
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, '강좌 및 서비스 환경 설정');
|
||||
});
|
||||
+321
@@ -0,0 +1,321 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Navigate, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { ToastProvider } from './components/ToastProvider';
|
||||
import { InstructorLayout } from './layouts/InstructorLayout';
|
||||
import { clearStoredSession, loadStoredSession, logoutInstructor, refreshCurrentUser, refreshInstructorSession } from './lib/auth';
|
||||
import { 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';
|
||||
import { GroupsPage } from './pages/groups/GroupsPage';
|
||||
import { LoginPage } from './pages/login/LoginPage';
|
||||
import { PlatformsPage } from './pages/platforms/PlatformsPage';
|
||||
import { ProfilePage } from './pages/profile/ProfilePage';
|
||||
import { SettingsPage } from './pages/settings/SettingsPage';
|
||||
import { SubmissionsPage } from './pages/submissions/SubmissionsPage';
|
||||
import { StudentsPage } from './pages/students/StudentsPage';
|
||||
import { defaultRoute, getRouteFromPathname, getRoutePath, type AppRoute } from './routes';
|
||||
import type { AuthSession } from './types/auth';
|
||||
|
||||
function App() {
|
||||
const [session, setSession] = useState<AuthSession | null>(() => loadStoredSession());
|
||||
const [courses, setCourses] = useState<InstructorCourse[]>([]);
|
||||
const [selectedCourseId, setSelectedCourseId] = useState(() => window.localStorage.getItem('lms-admin-selected-course-id') ?? '');
|
||||
const [sessionExpired, setSessionExpired] = useState(false);
|
||||
const [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();
|
||||
const route = getRouteFromPathname(location.pathname);
|
||||
const activeRoute = route ?? defaultRoute;
|
||||
const [visibleRoute, setVisibleRoute] = useState<AppRoute>(activeRoute);
|
||||
const [transitionState, setTransitionState] = useState<'entering' | 'leaving'>('entering');
|
||||
|
||||
const setRoute = (nextRoute: AppRoute) => {
|
||||
navigate(getRoutePath(nextRoute));
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logoutInstructor();
|
||||
setSession(null);
|
||||
setSessionExpired(false);
|
||||
};
|
||||
|
||||
const expireSession = () => {
|
||||
clearStoredSession();
|
||||
setSession(null);
|
||||
setSessionExpired(true);
|
||||
};
|
||||
|
||||
const moveToLogin = () => {
|
||||
clearStoredSession();
|
||||
setSession(null);
|
||||
setSessionExpired(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (activeRoute === visibleRoute) return;
|
||||
|
||||
setTransitionState('leaving');
|
||||
const leaveTimer = window.setTimeout(() => {
|
||||
setVisibleRoute(activeRoute);
|
||||
setTransitionState('entering');
|
||||
}, 120);
|
||||
|
||||
return () => window.clearTimeout(leaveTimer);
|
||||
}, [activeRoute, visibleRoute]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
refreshCurrentUser(session)
|
||||
.then((nextSession) => {
|
||||
if (!cancelled) setSession(nextSession);
|
||||
})
|
||||
.catch(async () => {
|
||||
if (!cancelled) {
|
||||
try {
|
||||
const nextSession = await refreshInstructorSession(session);
|
||||
if (!cancelled) setSession(nextSession);
|
||||
} catch {
|
||||
if (!cancelled) expireSession();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [session?.accessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session?.accessToken) return;
|
||||
let cancelled = false;
|
||||
listInstructorCourses(session.accessToken)
|
||||
.then((payload) => {
|
||||
if (cancelled) return;
|
||||
setCourses(payload.courses);
|
||||
setInitialCourseOpen(payload.courses.length === 0);
|
||||
setSelectedCourseId((current) => {
|
||||
const next = payload.courses.some((course) => course.id === current)
|
||||
? current
|
||||
: payload.courses[0]?.id ?? '';
|
||||
if (next) window.localStorage.setItem('lms-admin-selected-course-id', next);
|
||||
return next;
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) return;
|
||||
|
||||
setCourses([]);
|
||||
setSelectedCourseId('');
|
||||
window.localStorage.removeItem('lms-admin-selected-course-id');
|
||||
setInitialCourseOpen(true);
|
||||
setInitialCourseError(error instanceof Error ? error.message : '강좌 목록을 불러오지 못했습니다.');
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [session?.accessToken]);
|
||||
|
||||
const handleSelectCourse = (courseId: string) => {
|
||||
setSelectedCourseId(courseId);
|
||||
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;
|
||||
|
||||
const obtainedAt = session.obtainedAt ?? Date.now();
|
||||
const expiresInMs = (session.accessTokenExpiresIn ?? 3600) * 1000;
|
||||
const refreshAt = obtainedAt + expiresInMs - 60_000;
|
||||
const delayMs = Math.max(5_000, refreshAt - Date.now());
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
refreshInstructorSession(session)
|
||||
.then((nextSession) => {
|
||||
setSession(nextSession);
|
||||
setSessionExpired(false);
|
||||
})
|
||||
.catch(() => expireSession());
|
||||
}, delayMs);
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [session?.accessToken, session?.refreshToken, session?.obtainedAt, session?.accessTokenExpiresIn]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session) return;
|
||||
|
||||
const handleExpired = () => expireSession();
|
||||
window.addEventListener('lms-admin-session-expired', handleExpired);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('lms-admin-session-expired', handleExpired);
|
||||
};
|
||||
}, [session]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session) return;
|
||||
|
||||
const originalFetch = window.fetch.bind(window);
|
||||
const wrappedFetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const response = await originalFetch(input, init);
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
||||
const isAuthMutation = url.includes('/auth/login') || url.includes('/auth/refresh') || url.includes('/auth/logout');
|
||||
if (response.status === 401 && !isAuthMutation) {
|
||||
window.dispatchEvent(new Event('lms-admin-session-expired'));
|
||||
}
|
||||
return response;
|
||||
};
|
||||
window.fetch = wrappedFetch as typeof window.fetch;
|
||||
|
||||
return () => {
|
||||
window.fetch = originalFetch as typeof window.fetch;
|
||||
};
|
||||
}, [session?.accessToken]);
|
||||
|
||||
if (sessionExpired) {
|
||||
return <SessionExpiredPage onLogin={moveToLogin} />;
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return <LoginPage onLogin={setSession} />;
|
||||
}
|
||||
|
||||
if (!route) {
|
||||
return <Navigate to={getRoutePath(defaultRoute)} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
<InstructorLayout
|
||||
session={session}
|
||||
route={activeRoute}
|
||||
onNavigate={setRoute}
|
||||
onLogout={handleLogout}
|
||||
courses={courses}
|
||||
selectedCourseId={selectedCourseId}
|
||||
onSelectCourse={handleSelectCourse}
|
||||
chatThreadId={chatThreadOpenRequest?.threadId ?? null}
|
||||
chatThreadRequestId={chatThreadOpenRequest?.requestId ?? 0}
|
||||
>
|
||||
<div className={`page-transition ${transitionState === 'leaving' ? 'page-transition-out' : 'page-transition-in'}`}>
|
||||
<RoutePage
|
||||
route={visibleRoute}
|
||||
session={session}
|
||||
selectedCourseId={selectedCourseId}
|
||||
courses={courses}
|
||||
onNavigate={setRoute}
|
||||
onOpenChatThread={(threadId) => setChatThreadOpenRequest({ threadId, requestId: Date.now() })}
|
||||
/>
|
||||
</div>
|
||||
</InstructorLayout>
|
||||
{initialCourseOpen && (
|
||||
<div className="fixed inset-x-0 bottom-0 top-[60px] z-30 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>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionExpiredPage({ onLogin }: { onLogin: () => void }) {
|
||||
return (
|
||||
<main className="grid min-h-screen place-items-center bg-[#f6f8fb] px-6">
|
||||
<section className="w-full max-w-[420px] rounded-xl border border-[#c7d2e0] bg-white px-7 py-8 text-center shadow-[0_24px_70px_rgba(15,23,42,0.12)]">
|
||||
<div className="mx-auto grid h-12 w-12 place-items-center rounded-full bg-[#fff1f2] text-[#e11d48]">
|
||||
<span className="text-[24px] font-black">!</span>
|
||||
</div>
|
||||
<h1 className="mt-5 text-[22px] font-black text-[#111827]">세션이 만료되었습니다</h1>
|
||||
<p className="mt-3 text-[14px] font-semibold leading-relaxed text-[#64748b]">
|
||||
보안을 위해 현재 로그인 세션을 사용할 수 없습니다. 새로고침 후 다시 로그인해 주세요.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-6 h-10 w-full rounded-md bg-[#2563eb] text-[14px] font-black text-white hover:bg-[#1d4ed8]"
|
||||
onClick={onLogin}
|
||||
>
|
||||
로그인으로 이동
|
||||
</button>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function RoutePage({ route, session, selectedCourseId, courses, onNavigate, onOpenChatThread }: { route: AppRoute; session: AuthSession; selectedCourseId: string; courses: InstructorCourse[]; onNavigate: (route: AppRoute) => void; onOpenChatThread: (threadId: string) => void }) {
|
||||
switch (route) {
|
||||
case 'dashboard':
|
||||
return <DashboardPage session={session} selectedCourseId={selectedCourseId} onNavigate={onNavigate} onOpenChatThread={onOpenChatThread} />;
|
||||
case 'curriculum':
|
||||
return <CurriculumPage session={session} selectedCourseId={selectedCourseId} />;
|
||||
case 'groups':
|
||||
return <GroupsPage session={session} selectedCourseId={selectedCourseId} />;
|
||||
case 'platforms':
|
||||
return <PlatformsPage session={session} />;
|
||||
case 'students':
|
||||
return <StudentsPage session={session} selectedCourseId={selectedCourseId} />;
|
||||
case 'submissions':
|
||||
return <SubmissionsPage session={session} />;
|
||||
case 'analytics':
|
||||
return <AnalyticsPage />;
|
||||
case 'settings':
|
||||
return <SettingsPage session={session} selectedCourseId={selectedCourseId} courses={courses} />;
|
||||
case 'profile':
|
||||
return <ProfilePage session={session} />;
|
||||
}
|
||||
}
|
||||
|
||||
export default App;
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
@@ -0,0 +1,779 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* @font-face {
|
||||
font-family: 'NanumGothic';
|
||||
src: url('./fonts/NanumGothicLight.woff2') format('woff2');
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'NanumGothic';
|
||||
src: url('./fonts/NanumGothic.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'NanumGothic';
|
||||
src: url('./fonts/NanumGothicBold.woff2') format('woff2');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'NanumGothic';
|
||||
src: url('./fonts/NanumGothicExtraBold.woff2') format('woff2');
|
||||
font-weight: 800;
|
||||
font-style: normal;
|
||||
} */
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 99px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.28);
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-deep: #0a0b0e;
|
||||
--bg-sidebar: #0d0f14;
|
||||
--bg-main: #0f1117;
|
||||
--border: rgba(255, 255, 255, 0.06);
|
||||
--accent: #3b7ffa;
|
||||
--accent-glow: rgba(59, 127, 250, 0.35);
|
||||
--text-primary: #e8eaf0;
|
||||
--text-secondary: rgba(200, 205, 220, 0.65);
|
||||
--text-muted: rgba(157, 163, 175, 0.911);
|
||||
--text-soft: #d6dae3;
|
||||
--sidebar-width: 280px;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #000;
|
||||
font-family: 'NanumSquare', sans-serif;
|
||||
}
|
||||
|
||||
/* ── Sidebar ── */
|
||||
.sidebar {
|
||||
background: var(--bg-sidebar);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px 0;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
overflow: visible;
|
||||
}
|
||||
.sidebar::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
rgba(59, 127, 250, 0.15) 40%,
|
||||
rgba(59, 127, 250, 0.15) 60%,
|
||||
transparent
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 13px 24px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
letter-spacing: 0.01em;
|
||||
transition: color 0.2s, background 0.2s;
|
||||
}
|
||||
.nav-item:hover {
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
.nav-item.active {
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
background: rgba(59, 127, 250, 0.07);
|
||||
}
|
||||
.nav-item.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 8px;
|
||||
bottom: 8px;
|
||||
width: 3px;
|
||||
background: var(--accent);
|
||||
border-radius: 0 3px 3px 0;
|
||||
box-shadow: 0 0 12px var(--accent-glow);
|
||||
}
|
||||
.nav-item-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
margin-right: 10px;
|
||||
opacity: 0.55;
|
||||
flex-shrink: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.nav-item:hover .nav-item-icon {
|
||||
opacity: 0.8;
|
||||
}
|
||||
.nav-item.active .nav-item-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
.nav-item-collapsed {
|
||||
justify-content: center;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
/* ── Sidebar Collapse Toggle Button ── */
|
||||
.sidebar-collapse-btn {
|
||||
position: absolute;
|
||||
right: -10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 50;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-sidebar);
|
||||
border: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
transition: color 0.2s, background 0.2s, border-color 0.2s;
|
||||
}
|
||||
.sidebar-collapse-btn:hover {
|
||||
color: var(--text-primary);
|
||||
background: rgba(255,255,255,0.06);
|
||||
border-color: rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
/* ── Main Content ── */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
background: var(--bg-main);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.main-content::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 60% at 65% 50%, rgba(20, 25, 40, 0.9) 0%, transparent 70%),
|
||||
radial-gradient(ellipse 50% 50% at 20% 50%, rgba(15, 30, 60, 0.3) 0%, transparent 60%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Update Button ── */
|
||||
.update-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 7px 16px;
|
||||
background: var(--accent);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-family: 'NanumGothic', sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, box-shadow 0.2s, transform 0.15s;
|
||||
box-shadow: 0 4px 20px rgba(59, 127, 250, 0.35);
|
||||
}
|
||||
.update-btn:hover {
|
||||
background: #5592ff;
|
||||
box-shadow: 0 6px 28px rgba(59, 127, 250, 0.55);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.update-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* ── Updating Button (in-progress state) ── */
|
||||
.updating-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 7px 16px;
|
||||
background: rgba(59, 127, 250, 0.15);
|
||||
border: 1px solid rgba(59, 127, 250, 0.3);
|
||||
border-radius: 6px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-family: 'NanumGothic', sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ── Update Progress Bar ── */
|
||||
.update-progress-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
.update-progress-track {
|
||||
flex: 1;
|
||||
height: 3px;
|
||||
background: rgba(255,255,255,0.08);
|
||||
border-radius: 99px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.update-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 99px;
|
||||
transition: width 0.2s ease;
|
||||
box-shadow: 0 0 6px rgba(59,127,250,0.6);
|
||||
}
|
||||
.update-progress-pct {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
min-width: 30px;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── Spinner ── */
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.spin {
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
/* ── Launch Button ── */
|
||||
.launch-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 7px 16px;
|
||||
background: var(--accent);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-family: 'NanumGothic', sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, box-shadow 0.2s, transform 0.15s;
|
||||
box-shadow: 0 4px 20px rgba(59, 127, 250, 0.35);
|
||||
}
|
||||
.launch-btn:hover {
|
||||
background: #5592ff;
|
||||
box-shadow: 0 6px 28px rgba(59, 127, 250, 0.55);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.launch-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* ── Car Video ── */
|
||||
.car-video {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center bottom;
|
||||
opacity: 0;
|
||||
animation: carFadeIn 1.4s 0.5s ease forwards;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.car-video-shadow {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(to right, var(--bg-main) 0%, var(--bg-main) 14%, rgba(15, 17, 23, 0.45) 32%, transparent 52%),
|
||||
linear-gradient(to left, var(--bg-main) 0%, rgba(15, 17, 23, 0.6) 8%, transparent 35%),
|
||||
linear-gradient(to top, rgba(5, 7, 12, 0.9) 0%, transparent 45%),
|
||||
radial-gradient(ellipse 60% 60% at 100% 100%, rgba(5, 7, 12, 0.8) 0%, transparent 70%),
|
||||
radial-gradient(ellipse 100% 100% at 80% 40%, transparent 40%, rgba(5, 7, 12, 0.5) 100%);
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.floor-reflection {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 120px;
|
||||
background: linear-gradient(to top, rgba(8, 10, 18, 0.8) 0%, transparent 100%);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ── Text Content ── */
|
||||
.text-content {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
max-width: 480px;
|
||||
animation: textReveal 1s 0.2s ease both;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-family: 'Rajdhani', sans-serif;
|
||||
font-size: 52px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1.0;
|
||||
margin-bottom: 20px;
|
||||
text-shadow: 0 0 60px rgba(59, 127, 250, 0.1);
|
||||
}
|
||||
.brand-title span {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 17px;
|
||||
font-weight: 400;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.7;
|
||||
margin-bottom: 28px;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 140px;
|
||||
height: 2px;
|
||||
background: linear-gradient(to right, var(--accent), transparent);
|
||||
margin-bottom: 22px;
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 0 10px var(--accent-glow);
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 13.5px;
|
||||
color: rgba(200, 210, 230, 0.85);
|
||||
line-height: 2.0;
|
||||
letter-spacing: 0.005em;
|
||||
}
|
||||
|
||||
/* ── Verify Code Button ── */
|
||||
.verify-code-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(59, 127, 250, 0.35);
|
||||
background: rgba(59, 127, 250, 0.10);
|
||||
color: var(--accent);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, border-color 0.2s, box-shadow 0.2s, transform 0.15s;
|
||||
font-family: 'NanumGothic', sans-serif;
|
||||
}
|
||||
.verify-code-btn:hover:not(:disabled) {
|
||||
background: rgba(59, 127, 250, 0.2);
|
||||
border-color: rgba(59, 127, 250, 0.6);
|
||||
box-shadow: 0 0 12px rgba(59, 127, 250, 0.25);
|
||||
}
|
||||
.verify-code-btn:disabled {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-color: rgba(255, 255, 255, 0.07);
|
||||
color: var(--text-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Notice Icon Button ── */
|
||||
.notice-icon-btn {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, border-color 0.2s, color 0.2s;
|
||||
}
|
||||
.notice-icon-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.notice-badge {
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
right: 7px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 6px var(--accent-glow);
|
||||
}
|
||||
|
||||
.notice-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 10px);
|
||||
right: 0;
|
||||
width: 320px;
|
||||
background: rgba(13, 15, 22, 0.96);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 10px;
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.notice-item {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.notice-item-border {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
/* ── Notice Panel ── */
|
||||
.notice-panel {
|
||||
padding: 14px 16px;
|
||||
background: rgba(13, 15, 20, 0.75);
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
border-radius: 10px;
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.notice-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 6px var(--accent-glow);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notice-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.notice-date {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.notice-title {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 5px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.notice-body {
|
||||
font-size: 11.5px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.7;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
/* ── Dashboard ── */
|
||||
.car-bg-radial {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(ellipse 90% 70% at 60% 55%, rgba(25, 35, 70, 0.15) 0%, transparent 60%);
|
||||
}
|
||||
|
||||
.version-label {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
right: 24px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--text-soft);
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
/* ── Login ── */
|
||||
.login-video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center bottom;
|
||||
opacity: 0;
|
||||
animation: carFadeIn 1.4s 0.3s ease forwards;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.login-video-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(to bottom, var(--bg-deep) 0%, rgba(10, 11, 14, 0.5) 35%, transparent 60%),
|
||||
linear-gradient(to top, var(--bg-deep) 0%, transparent 40%),
|
||||
linear-gradient(to right, rgba(10, 11, 14, 0.3) 0%, transparent 60%),
|
||||
linear-gradient(to left, rgba(10, 11, 14, 0.5) 0%, transparent 55%),
|
||||
radial-gradient(ellipse 60% 50% at 100% 100%, rgba(10, 11, 14, 0.7) 0%, transparent 70%);
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.07);
|
||||
background: rgba(13, 15, 20, 0.6);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
}
|
||||
|
||||
.login-panel-edge {
|
||||
position: absolute;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
width: 1px;
|
||||
background: linear-gradient(to bottom, transparent, rgba(59, 127, 250, 0.2) 30%, rgba(59, 127, 250, 0.2) 70%, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.login-panel-glow {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: 200px;
|
||||
background: radial-gradient(ellipse 80% 60% at 50% 0%, rgba(59, 127, 250, 0.06) 0%, transparent 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.animate-reveal {
|
||||
animation: textReveal 0.8s 0.2s ease both;
|
||||
}
|
||||
|
||||
.animate-reveal-late {
|
||||
animation: textReveal 0.8s 0.3s ease both;
|
||||
}
|
||||
|
||||
/* ── Login inputs ── */
|
||||
.login-input-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.login-input-icon {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
color: var(--text-muted);
|
||||
pointer-events: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.login-input-field {
|
||||
width: 100%;
|
||||
padding: 11px 16px 11px 40px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
font-family: 'NanumGothic', sans-serif;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.login-input-field::placeholder { color: var(--text-muted); }
|
||||
.login-input-field:focus {
|
||||
border-color: rgba(59, 127, 250, 0.5);
|
||||
background: rgba(59, 127, 250, 0.04);
|
||||
}
|
||||
|
||||
.login-input-eye {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--text-muted);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.login-input-eye:hover { color: var(--text-secondary); }
|
||||
.login-input-eye svg { width: 16px; height: 16px; }
|
||||
|
||||
.login-checkbox {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.login-google-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 10px 0;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
font-family: 'NanumGothic', sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
}
|
||||
.login-google-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
/* ── Login ── */
|
||||
.login-input {
|
||||
width: 100%;
|
||||
padding: 11px 16px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
font-family: 'NanumGothic', sans-serif;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.login-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.login-input:focus {
|
||||
border-color: rgba(59, 127, 250, 0.5);
|
||||
background: rgba(59, 127, 250, 0.04);
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 11px 0;
|
||||
background: var(--accent);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-family: 'NanumGothic', sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, box-shadow 0.2s, transform 0.15s;
|
||||
box-shadow: 0 4px 20px rgba(59, 127, 250, 0.3);
|
||||
}
|
||||
.login-btn:hover:not(:disabled) {
|
||||
background: #5592ff;
|
||||
box-shadow: 0 6px 28px rgba(59, 127, 250, 0.5);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.login-btn:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* ── Typing Cursor ── */
|
||||
.typing-cursor {
|
||||
display: inline-block;
|
||||
width: 2px;
|
||||
height: 1em;
|
||||
background: var(--text-muted);
|
||||
border-radius: 1px;
|
||||
animation: cursorBlink 0.9s step-end infinite;
|
||||
transform: translateY(2px);
|
||||
}
|
||||
|
||||
@keyframes cursorBlink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* ── Running State ── */
|
||||
.running-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 14px 6px 10px;
|
||||
border-radius: 20px;
|
||||
background: rgba(34, 197, 94, 0.07);
|
||||
border: 1px solid rgba(34, 197, 94, 0.2);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.running-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #22c55e;
|
||||
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.5);
|
||||
animation: running-pulse 1.8s ease infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes running-pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.5); }
|
||||
55% { box-shadow: 0 0 0 5px rgba(34, 197, 94, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0); }
|
||||
}
|
||||
|
||||
/* ── Animations ── */
|
||||
@keyframes textReveal {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes carFadeIn {
|
||||
from { opacity: 0; transform: translateX(30px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import autonomyLogo from '../assets/icon.png';
|
||||
|
||||
export function BrandMark({ dark = false }: { dark?: boolean }) {
|
||||
return (
|
||||
<div className={`flex items-center gap-3 ${dark ? 'text-[#111827]' : 'text-white'}`}>
|
||||
<span className="grid h-8 w-8 place-items-center overflow-hidden rounded-lg bg-white ring-1 ring-white/25">
|
||||
<img className="h-7 w-7 object-contain" src={autonomyLogo} alt="" />
|
||||
</span>
|
||||
<span className="text-[24px] font-black tracking-[0] sm:text-[24px]">
|
||||
Autonomy <span className={dark ? 'text-[#4f46e5]' : 'text-[#67e8f9]'}>Studio</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Loader2, type LucideIcon } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type ConfirmDialogProps = {
|
||||
open: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
detail?: ReactNode;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
loading?: boolean;
|
||||
danger?: boolean;
|
||||
icon?: LucideIcon;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
};
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
detail,
|
||||
confirmLabel = '확인',
|
||||
cancelLabel = '취소',
|
||||
loading = false,
|
||||
danger = false,
|
||||
icon: Icon,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: ConfirmDialogProps) {
|
||||
if (!open) return null;
|
||||
|
||||
const confirmClass = danger
|
||||
? 'bg-[#e11d48] text-white hover:bg-[#be123c]'
|
||||
: 'bg-[#2563eb] text-white hover:bg-[#1d4ed8]';
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[1000] grid place-items-center bg-[#0f172a]/45 px-4 backdrop-blur-[2px]">
|
||||
<section className="w-full max-w-[420px] overflow-hidden rounded-xl border border-[#cbd5e1] bg-white shadow-[0_28px_80px_rgba(15,23,42,0.30)]">
|
||||
<header className="border-b border-[#cbd5e1] px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-[18px] font-black leading-snug text-[#111827]">{title}</h2>
|
||||
</div>
|
||||
</header>
|
||||
<div className="px-5 py-4">
|
||||
<p className="text-[13px] font-semibold leading-relaxed text-[#64748b]">{message}</p>
|
||||
{detail ? <div className="mt-4">{detail}</div> : null}
|
||||
</div>
|
||||
<footer className="flex justify-end gap-2 border-t border-[#cbd5e1] bg-[#f8fafc] px-5 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={loading}
|
||||
className="h-9 cursor-pointer rounded-md border border-[#cbd5e1] bg-white px-4 text-[13px] font-black text-[#475569] transition hover:bg-[#f1f5f9] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={loading}
|
||||
className={`inline-flex h-9 cursor-pointer items-center gap-2 rounded-md px-4 text-[13px] font-black transition disabled:cursor-not-allowed disabled:opacity-60 ${confirmClass}`}
|
||||
>
|
||||
{loading ? <Loader2 className="animate-spin" size={14} aria-hidden="true" /> : Icon ? <Icon size={14} aria-hidden="true" /> : null}
|
||||
{loading ? `${confirmLabel} 중` : confirmLabel}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ArrowLeft, ChevronRight, Loader2, MessageCircle, Send, UserRound, X } from 'lucide-react';
|
||||
import { io } from 'socket.io-client';
|
||||
import type { Socket } from 'socket.io-client';
|
||||
import {
|
||||
CHAT_SOCKET_ORIGIN,
|
||||
CHAT_SOCKET_PATH,
|
||||
createInstructorChatMessage,
|
||||
getInstructorChatThread,
|
||||
listInstructorChatThreads,
|
||||
type InstructorChatMessage,
|
||||
type InstructorChatThread,
|
||||
} from '../lib/chat';
|
||||
import type { AuthSession } from '../types/auth';
|
||||
|
||||
type InstructorChatPanelProps = {
|
||||
session: AuthSession;
|
||||
courseId: string;
|
||||
open: boolean;
|
||||
initialThreadId?: string | null;
|
||||
initialThreadRequestId?: number;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
type ChatNotificationPayload = {
|
||||
type: 'chat.message.created';
|
||||
threadId: string;
|
||||
messageId: string;
|
||||
senderType: 'STUDENT' | 'AI' | 'INSTRUCTOR' | 'SYSTEM';
|
||||
preview: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export function InstructorChatPanel({ session, 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);
|
||||
const [roomLoading, setRoomLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [mounted, setMounted] = useState(open);
|
||||
|
||||
const selectedThread = useMemo(() => threads.find((thread) => thread.id === selectedThreadId) ?? null, [selectedThreadId, threads]);
|
||||
|
||||
const refreshThreadList = (showLoading = false) => {
|
||||
let cancelled = false;
|
||||
if (showLoading) setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const request = listInstructorChatThreads(session.accessToken, courseId)
|
||||
.then((items) => {
|
||||
if (!cancelled) setThreads(items);
|
||||
})
|
||||
.catch((reason: Error) => {
|
||||
if (!cancelled) {
|
||||
setThreads([]);
|
||||
setError(reason.message);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled && showLoading) setLoading(false);
|
||||
});
|
||||
|
||||
return {
|
||||
cancel: () => {
|
||||
cancelled = true;
|
||||
},
|
||||
request,
|
||||
};
|
||||
};
|
||||
|
||||
const refreshSelectedThread = (threadId: string, showLoading = false) => {
|
||||
if (showLoading) setRoomLoading(true);
|
||||
setError(null);
|
||||
|
||||
return getInstructorChatThread(session.accessToken, threadId)
|
||||
.then((thread) => {
|
||||
setThreads((current) => replaceThread(current, thread));
|
||||
})
|
||||
.catch((reason: Error) => {
|
||||
setError(reason.message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (showLoading) setRoomLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const refresh = refreshThreadList(true);
|
||||
|
||||
return () => {
|
||||
refresh.cancel();
|
||||
};
|
||||
}, [courseId, open, session.accessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !initialThreadId) return;
|
||||
setSelectedThreadId(initialThreadId);
|
||||
void refreshSelectedThread(initialThreadId, true);
|
||||
}, [open, initialThreadId, initialThreadRequestId, session.accessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const socket: Socket = io(CHAT_SOCKET_ORIGIN, {
|
||||
auth: { token: session.accessToken },
|
||||
path: CHAT_SOCKET_PATH,
|
||||
transports: ['websocket', 'polling'],
|
||||
});
|
||||
|
||||
const handleMessageCreated = (payload: ChatNotificationPayload) => {
|
||||
void refreshThreadList(false).request;
|
||||
if (selectedThreadId && payload.threadId === selectedThreadId) {
|
||||
void refreshSelectedThread(selectedThreadId, false);
|
||||
}
|
||||
};
|
||||
|
||||
socket.on('connect', () => {
|
||||
void refreshThreadList(false).request;
|
||||
if (selectedThreadId) void refreshSelectedThread(selectedThreadId, false);
|
||||
});
|
||||
socket.on('chat.message.created', handleMessageCreated);
|
||||
|
||||
return () => {
|
||||
socket.off('chat.message.created', handleMessageCreated);
|
||||
socket.disconnect();
|
||||
};
|
||||
}, [courseId, open, selectedThreadId, session.accessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setSelectedThreadId(null);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setMounted(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => setMounted(false), 220);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [open]);
|
||||
|
||||
const handleOpenThread = (threadId: string) => {
|
||||
setSelectedThreadId(threadId);
|
||||
void refreshSelectedThread(threadId, true);
|
||||
};
|
||||
|
||||
const handleSendMessage = async (threadId: string, content: string) => {
|
||||
const updatedThread = await createInstructorChatMessage(session.accessToken, threadId, content);
|
||||
setThreads((current) => replaceThread(current, updatedThread));
|
||||
};
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<aside className={`fixed bottom-0 right-0 top-[60px] z-30 flex w-full max-w-[420px] flex-col border-l border-[#cbd5e1] bg-white/92 shadow-[0_22px_70px_rgba(15,23,42,0.18)] backdrop-blur-md transition-transform duration-200 ease-out ${open ? 'translate-x-0' : 'translate-x-full'}`}>
|
||||
{selectedThread ? (
|
||||
<ChatRoom thread={selectedThread} loading={roomLoading} onBack={() => setSelectedThreadId(null)} onClose={onClose} onSend={handleSendMessage} />
|
||||
) : (
|
||||
<>
|
||||
<div className="flex h-[68px] items-center justify-between border-b border-[#cbd5e1] px-5">
|
||||
<div>
|
||||
<p className="text-[15px] font-black text-[#0f172a]">채팅</p>
|
||||
<p className="mt-1 text-[12px] font-bold text-[#64748b]">수강생 문의 목록</p>
|
||||
</div>
|
||||
<button className="grid h-9 w-9 place-items-center rounded-md text-[#64748b] hover:bg-[#f8fafc] hover:text-[#111827]" type="button" aria-label="채팅 닫기" title="채팅 닫기" onClick={onClose}>
|
||||
<X size={17} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto overscroll-contain px-4 py-4">
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center text-[#64748b]">
|
||||
<Loader2 className="mr-2 animate-spin" size={18} aria-hidden="true" />
|
||||
<span className="text-[13px] font-bold">문의 목록을 불러오는 중입니다.</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<EmptyState title="채팅 서버 연결이 필요합니다." description={error} />
|
||||
) : threads.length > 0 ? (
|
||||
<div className="space-y-2.5">
|
||||
{threads.map((thread) => (
|
||||
<ThreadCard key={thread.id} thread={thread} onOpen={() => handleOpenThread(thread.id)} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="대기 중인 문의가 없습니다." description="수강생이 강사 문의를 남기면 이곳에 표시됩니다." />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function ThreadCard({ thread, onOpen }: { thread: InstructorChatThread; onOpen: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="group w-full cursor-pointer rounded-lg border border-[#c7d2e0] bg-white px-4 py-3 text-left shadow-[0_10px_28px_rgba(15,23,42,0.05)] outline-none transition hover:border-[#818cf8] hover:bg-[#f8faff] hover:shadow-[0_16px_36px_rgba(79,70,229,0.14)] focus-visible:border-[#4f46e5] focus-visible:ring-2 focus-visible:ring-[#c7d2fe] active:bg-[#eef2ff]"
|
||||
role="button"
|
||||
onClick={onOpen}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<StudentAvatar thread={thread} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="truncate text-[14px] font-black text-[#111827]">{thread.studentName}</p>
|
||||
{thread.status === 'WAITING_INSTRUCTOR' ? <StatusBadge status={thread.status} /> : null}
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-[12px] font-bold leading-5 text-[#64748b]">{thread.lastMessage}</p>
|
||||
<div className="mt-3 flex items-center gap-2 text-[11px] font-bold text-[#94a3b8]">
|
||||
{thread.groupName ? <span>{thread.groupName}</span> : null}
|
||||
{thread.week ? <span>{thread.week}주차</span> : null}
|
||||
{thread.lastMessageAt ? <span>{formatDate(thread.lastMessageAt)}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<span className="mt-1 grid h-8 w-8 shrink-0 place-items-center rounded-md text-[#94a3b8] transition group-hover:bg-[#eef2ff] group-hover:text-[#4f46e5]">
|
||||
<ChevronRight size={17} aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function StudentAvatar({ thread, size = 'md' }: { thread: InstructorChatThread; size?: 'sm' | 'md' }) {
|
||||
const [avatarFailed, setAvatarFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setAvatarFailed(false);
|
||||
}, [thread.studentAvatarUrl]);
|
||||
|
||||
return (
|
||||
<span className={`${size === 'sm' ? 'h-9 w-9' : 'h-10 w-10'} grid shrink-0 place-items-center overflow-hidden rounded-full bg-[#eef2ff] text-[#4f46e5] transition group-hover:bg-[#4f46e5] group-hover:text-white`}>
|
||||
{thread.studentAvatarUrl && !avatarFailed ? (
|
||||
<img className="h-full w-full object-cover" src={thread.studentAvatarUrl} alt="" onError={() => setAvatarFailed(true)} />
|
||||
) : (
|
||||
<UserRound size={18} aria-hidden="true" />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatRoom({
|
||||
thread,
|
||||
loading,
|
||||
onBack,
|
||||
onClose,
|
||||
onSend,
|
||||
}: {
|
||||
thread: InstructorChatThread;
|
||||
loading: boolean;
|
||||
onBack: () => void;
|
||||
onClose: () => void;
|
||||
onSend: (threadId: string, content: string) => Promise<void>;
|
||||
}) {
|
||||
const messages = thread.messages.length > 0 ? thread.messages : createFallbackMessages(thread);
|
||||
const [draft, setDraft] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [sendError, setSendError] = useState<string | null>(null);
|
||||
const messageScrollRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const target = messageScrollRef.current;
|
||||
if (!target || loading) return;
|
||||
|
||||
target.scrollTo({
|
||||
top: target.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}, [loading, messages.length, thread.id]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const content = draft.trim();
|
||||
if (!content || sending) return;
|
||||
|
||||
setSending(true);
|
||||
setSendError(null);
|
||||
try {
|
||||
await onSend(thread.id, content);
|
||||
setDraft('');
|
||||
} catch (reason) {
|
||||
setSendError(reason instanceof Error ? reason.message : '강사 답변을 저장하지 못했습니다.');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-[68px] items-center gap-2 border-b border-[#cbd5e1] px-4">
|
||||
<button className="grid h-9 w-9 place-items-center rounded-md text-[#64748b] hover:bg-[#f8fafc] hover:text-[#111827]" type="button" aria-label="목록으로 돌아가기" title="목록으로 돌아가기" onClick={onBack}>
|
||||
<ArrowLeft size={17} aria-hidden="true" />
|
||||
</button>
|
||||
<StudentAvatar thread={thread} size="sm" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="truncate text-[15px] font-black text-[#0f172a]">{thread.studentName}</p>
|
||||
{thread.status === 'WAITING_INSTRUCTOR' ? <StatusBadge status={thread.status} /> : null}
|
||||
</div>
|
||||
<p className="mt-1 truncate text-[12px] font-bold text-[#64748b]">
|
||||
{[thread.groupName, thread.week ? `${thread.week}주차` : null].filter(Boolean).join(' · ') || '강사 문의'}
|
||||
</p>
|
||||
</div>
|
||||
<button className="grid h-9 w-9 place-items-center rounded-md text-[#64748b] hover:bg-[#f8fafc] hover:text-[#111827]" type="button" aria-label="채팅 닫기" title="채팅 닫기" onClick={onClose}>
|
||||
<X size={17} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div ref={messageScrollRef} className="flex-1 overflow-y-auto overscroll-contain px-5 py-5">
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center text-[#64748b]">
|
||||
<Loader2 className="mr-2 animate-spin" size={18} aria-hidden="true" />
|
||||
<span className="text-[13px] font-bold">채팅방을 불러오는 중입니다.</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{messages.map((message) => (
|
||||
<MessageBubble key={message.id} message={message} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[#cbd5e1] bg-white/86 p-4">
|
||||
<div>
|
||||
<div className="flex overflow-hidden rounded-lg border border-[#dbe3ef] bg-[#f8fafc]">
|
||||
<textarea
|
||||
className="h-[82px] min-w-0 flex-1 resize-none bg-transparent px-3 py-2 text-[13px] font-bold leading-5 text-[#111827] outline-none placeholder:text-[#94a3b8]"
|
||||
value={draft}
|
||||
placeholder="수강생에게 답변을 입력하세요."
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void handleSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="grid w-12 shrink-0 place-items-center bg-[#4f46e5] text-white hover:bg-[#4338ca] disabled:bg-[#cbd5e1]"
|
||||
type="button"
|
||||
disabled={!draft.trim() || sending}
|
||||
onClick={handleSubmit}
|
||||
aria-label={sending ? '전송 중' : '전송'}
|
||||
title={sending ? '전송 중' : '전송'}
|
||||
>
|
||||
{sending ? <Loader2 className="animate-spin" size={16} aria-hidden="true" /> : <Send size={16} aria-hidden="true" />}
|
||||
</button>
|
||||
</div>
|
||||
{sendError ? <p className="mt-1 text-[11px] font-bold text-[#e11d48]">{sendError}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageBubble({ message }: { message: InstructorChatMessage }) {
|
||||
const isInstructor = message.senderType === 'INSTRUCTOR';
|
||||
const isStudent = message.senderType === 'STUDENT';
|
||||
const senderLabel = message.senderName?.trim() || getSenderLabel(message.senderType);
|
||||
|
||||
return (
|
||||
<div className={`flex ${isInstructor ? 'justify-end' : 'justify-start'}`}>
|
||||
<div
|
||||
className={`max-w-[82%] rounded-lg px-3.5 py-2.5 ${
|
||||
isInstructor
|
||||
? 'bg-[#4f46e5] text-white'
|
||||
: isStudent
|
||||
? 'border border-[#c7d2e0] bg-white text-[#111827]'
|
||||
: 'border border-[#e0e7ff] bg-[#eef2ff] text-[#3730a3]'
|
||||
}`}
|
||||
>
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className={`text-[10px] font-black ${isInstructor ? 'text-white/78' : 'text-[#64748b]'}`}>{senderLabel}</span>
|
||||
{message.createdAt ? <span className={`text-[10px] font-bold ${isInstructor ? 'text-white/58' : 'text-[#94a3b8]'}`}>{formatDate(message.createdAt)}</span> : null}
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap text-[13px] font-bold leading-5">{message.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ title, description }: { title: string; description: string }) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center rounded-lg border border-dashed border-[#cbd5e1] bg-white/58 px-6 text-center">
|
||||
<span className="mb-3 grid h-11 w-11 place-items-center rounded-full bg-[#eef2ff] text-[#4f46e5]">
|
||||
<MessageCircle size={20} aria-hidden="true" />
|
||||
</span>
|
||||
<p className="text-[14px] font-black text-[#111827]">{title}</p>
|
||||
<p className="mt-2 text-[12px] font-bold leading-5 text-[#64748b]">{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const label = getStatusLabel(status);
|
||||
const tone = status === 'WAITING_INSTRUCTOR' ? 'bg-[#fff1f2] text-[#be123c] border-[#fecdd3]' : 'bg-[#eef2ff] text-[#4f46e5] border-[#c7d2fe]';
|
||||
|
||||
return <span className={`shrink-0 rounded-full border px-2 py-0.5 text-[10px] font-black ${tone}`}>{label}</span>;
|
||||
}
|
||||
|
||||
function getStatusLabel(status: string): string {
|
||||
if (status === 'WAITING_INSTRUCTOR') return '답변 대기';
|
||||
if (status === 'OPEN') return '진행 중';
|
||||
if (status === 'RESOLVED') return '완료';
|
||||
if (status === 'ARCHIVED') return '보관';
|
||||
return status;
|
||||
}
|
||||
|
||||
function getSenderLabel(senderType: string): string {
|
||||
if (senderType === 'STUDENT') return '수강생';
|
||||
if (senderType === 'INSTRUCTOR') return '강사';
|
||||
if (senderType === 'AI') return 'AI';
|
||||
return '시스템';
|
||||
}
|
||||
|
||||
function createFallbackMessages(thread: InstructorChatThread): InstructorChatMessage[] {
|
||||
return [
|
||||
{
|
||||
id: `${thread.id}-last`,
|
||||
senderType: 'STUDENT',
|
||||
content: thread.lastMessage,
|
||||
createdAt: thread.lastMessageAt,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function replaceThread(threads: InstructorChatThread[], thread: InstructorChatThread): InstructorChatThread[] {
|
||||
const exists = threads.some((item) => item.id === thread.id);
|
||||
if (!exists) return [thread, ...threads];
|
||||
|
||||
return threads.map((item) => (item.id === thread.id ? thread : item));
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
|
||||
return new Intl.DateTimeFormat('ko-KR', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(date);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { AppRoute } from '../routes';
|
||||
import { pageMeta } from '../pageMeta';
|
||||
|
||||
type PageHeaderProps = {
|
||||
route: AppRoute;
|
||||
action?: React.ReactNode;
|
||||
};
|
||||
|
||||
export function PageHeader({ route, action }: PageHeaderProps) {
|
||||
const meta = pageMeta[route];
|
||||
|
||||
return (
|
||||
<div className={`mb-7 ${action ? 'flex flex-wrap items-end justify-between gap-4' : ''}`}>
|
||||
<div>
|
||||
<h1 className="text-[24px] font-black tracking-[0] text-[#0f172a]">{meta.title}</h1>
|
||||
<p className="mt-2 text-[13px] font-bold text-[#64748b]">{meta.description}</p>
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
type PaginationBarProps = {
|
||||
totalLabel: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalItems: number;
|
||||
onPageChange: (page: number) => void;
|
||||
onPageSizeChange: (pageSize: number) => void;
|
||||
};
|
||||
|
||||
const pageSizeOptions = [10, 20, 50];
|
||||
|
||||
export function PaginationBar({ totalLabel, page, pageSize, totalItems, onPageChange, onPageSizeChange }: PaginationBarProps) {
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
|
||||
const pages = Array.from({ length: Math.min(totalPages, 5) }, (_, index) => {
|
||||
if (totalPages <= 5) return index + 1;
|
||||
const start = Math.min(Math.max(page - 2, 1), totalPages - 4);
|
||||
return start + index;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-[#cbd5e1] px-4 pb-3 pt-6">
|
||||
<p className="text-[12px] font-bold text-[#64748b]">{totalLabel}</p>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
className="grid h-8 w-8 place-items-center rounded-md border border-[#d1d9e6] bg-white text-[#94a3b8] hover:bg-[#f8fafc] disabled:cursor-default disabled:opacity-35"
|
||||
type="button"
|
||||
aria-label="이전 페이지"
|
||||
disabled={page <= 1}
|
||||
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||
>
|
||||
<ChevronLeft size={15} aria-hidden="true" />
|
||||
</button>
|
||||
{pages.map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
className={`grid h-8 w-8 place-items-center rounded-md text-[12px] font-bold ${
|
||||
item === page ? 'bg-[#4f46e5] text-white' : 'border border-[#d1d9e6] bg-white text-[#64748b] hover:bg-[#f8fafc]'
|
||||
}`}
|
||||
type="button"
|
||||
aria-current={item === page ? 'page' : undefined}
|
||||
onClick={() => onPageChange(item)}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
className="grid h-8 w-8 place-items-center rounded-md border border-[#d1d9e6] bg-white text-[#94a3b8] hover:bg-[#f8fafc] disabled:cursor-default disabled:opacity-35"
|
||||
type="button"
|
||||
aria-label="다음 페이지"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => onPageChange(Math.min(totalPages, page + 1))}
|
||||
>
|
||||
<ChevronRight size={15} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<select
|
||||
aria-label="페이지당 표시 개수"
|
||||
className="h-8 rounded-md border border-[#d1d9e6] bg-white px-2 text-[12px] font-bold text-[#64748b] outline-none hover:bg-[#f8fafc] focus:border-[#4f46e5] focus:ring-2 focus:ring-[#4f46e5]/10"
|
||||
value={pageSize}
|
||||
onChange={(event) => onPageSizeChange(Number(event.target.value))}
|
||||
>
|
||||
{pageSizeOptions.map((option) => (
|
||||
<option key={option} value={option}>{option}개씩 보기</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { CheckCircle2, CircleAlert, X } from 'lucide-react';
|
||||
|
||||
type Toast = {
|
||||
id: number;
|
||||
title: string;
|
||||
message?: string;
|
||||
variant?: 'success' | 'error';
|
||||
entering?: boolean;
|
||||
closing?: boolean;
|
||||
};
|
||||
|
||||
type ToastContextValue = {
|
||||
showToast: (toast: Omit<Toast, 'id'>) => void;
|
||||
};
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
const TOAST_DURATION_MS = 2800;
|
||||
const TOAST_EXIT_MS = 180;
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const removeToast = useCallback((id: number) => {
|
||||
setToasts((current) => current.map((toast) => (toast.id === id ? { ...toast, closing: true } : toast)));
|
||||
window.setTimeout(() => {
|
||||
setToasts((current) => current.filter((toast) => toast.id !== id));
|
||||
}, TOAST_EXIT_MS);
|
||||
}, []);
|
||||
|
||||
const showToast = useCallback((toast: Omit<Toast, 'id'>) => {
|
||||
const id = Date.now() + Math.floor(Math.random() * 1000);
|
||||
setToasts((current) => [...current, { ...toast, id, entering: true }]);
|
||||
window.setTimeout(() => {
|
||||
setToasts((current) => current.map((item) => (item.id === id ? { ...item, entering: false } : item)));
|
||||
}, 20);
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({ showToast }), [showToast]);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
{createPortal(
|
||||
<div className="fixed bottom-5 right-5 z-[80] flex w-[calc(100vw-40px)] max-w-[360px] flex-col gap-2">
|
||||
{toasts.map((toast) => (
|
||||
<ToastItem key={toast.id} toast={toast} onClose={() => removeToast(toast.id)} />
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) throw new Error('useToast must be used within ToastProvider');
|
||||
return context;
|
||||
}
|
||||
|
||||
function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(onClose, TOAST_DURATION_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [onClose]);
|
||||
|
||||
const Icon = toast.variant === 'error' ? CircleAlert : CheckCircle2;
|
||||
const iconTone = toast.variant === 'error' ? 'bg-[#e11d48] text-white' : 'bg-[#4f46e5] text-white';
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border border-[#1e293b] bg-[#111827] px-4 py-3 shadow-[0_22px_54px_rgba(15,23,42,0.32)] transition duration-180 ease-out ${toast.closing || toast.entering ? 'translate-y-2 opacity-0' : 'translate-y-0 opacity-100'}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={`mt-0.5 grid h-8 w-8 shrink-0 place-items-center rounded-md ${iconTone}`}>
|
||||
<Icon size={17} aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[13px] font-black text-white">{toast.title}</p>
|
||||
{toast.message ? <p className="mt-1 truncate text-[12px] font-bold text-[#cbd5e1]">{toast.message}</p> : null}
|
||||
</div>
|
||||
<button className="grid h-7 w-7 shrink-0 place-items-center rounded-md text-[#cbd5e1] hover:bg-white/10 hover:text-white" type="button" onClick={onClose} aria-label="토스트 닫기" title="토스트 닫기">
|
||||
<X size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { fetchProfileAvatar } from '../lib/profileAvatar';
|
||||
|
||||
type ProfileAvatarState = {
|
||||
avatarObjectUrl: string | null;
|
||||
avatarMissing: boolean;
|
||||
avatarLoading: boolean;
|
||||
refreshAvatar: () => void;
|
||||
setAvatarMissing: (missing: boolean) => void;
|
||||
};
|
||||
|
||||
export function useProfileAvatar(accessToken: string): ProfileAvatarState {
|
||||
const [avatarVersion, setAvatarVersion] = useState(() => Date.now());
|
||||
const [avatarObjectUrl, setAvatarObjectUrl] = useState<string | null>(null);
|
||||
const [avatarMissing, setAvatarMissing] = useState(false);
|
||||
const [avatarLoading, setAvatarLoading] = useState(false);
|
||||
|
||||
const refreshAvatar = useCallback(() => {
|
||||
setAvatarMissing(false);
|
||||
setAvatarVersion(Date.now());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleAvatarUpdated = () => refreshAvatar();
|
||||
const handleAvatarDeleted = () => {
|
||||
setAvatarObjectUrl((previous) => {
|
||||
if (previous) URL.revokeObjectURL(previous);
|
||||
return null;
|
||||
});
|
||||
setAvatarMissing(true);
|
||||
setAvatarLoading(false);
|
||||
};
|
||||
window.addEventListener('profile-avatar-updated', handleAvatarUpdated);
|
||||
window.addEventListener('profile-avatar-deleted', handleAvatarDeleted);
|
||||
return () => {
|
||||
window.removeEventListener('profile-avatar-updated', handleAvatarUpdated);
|
||||
window.removeEventListener('profile-avatar-deleted', handleAvatarDeleted);
|
||||
};
|
||||
}, [refreshAvatar]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let nextObjectUrl: string | null = null;
|
||||
|
||||
const loadAvatar = async () => {
|
||||
if (!accessToken) {
|
||||
setAvatarObjectUrl((previous) => {
|
||||
if (previous) URL.revokeObjectURL(previous);
|
||||
return null;
|
||||
});
|
||||
setAvatarMissing(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setAvatarLoading(true);
|
||||
|
||||
try {
|
||||
const blob = await fetchProfileAvatar(accessToken, avatarVersion);
|
||||
if (!blob) {
|
||||
if (!cancelled) {
|
||||
setAvatarObjectUrl((previous) => {
|
||||
if (previous) URL.revokeObjectURL(previous);
|
||||
return null;
|
||||
});
|
||||
setAvatarMissing(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
nextObjectUrl = URL.createObjectURL(blob);
|
||||
if (!cancelled) {
|
||||
setAvatarObjectUrl((previous) => {
|
||||
if (previous) URL.revokeObjectURL(previous);
|
||||
return nextObjectUrl;
|
||||
});
|
||||
setAvatarMissing(false);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setAvatarObjectUrl((previous) => {
|
||||
if (previous) URL.revokeObjectURL(previous);
|
||||
return null;
|
||||
});
|
||||
setAvatarMissing(true);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setAvatarLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void loadAvatar();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (nextObjectUrl) URL.revokeObjectURL(nextObjectUrl);
|
||||
};
|
||||
}, [accessToken, avatarVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setAvatarObjectUrl((previous) => {
|
||||
if (previous) URL.revokeObjectURL(previous);
|
||||
return null;
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
avatarObjectUrl,
|
||||
avatarMissing,
|
||||
avatarLoading,
|
||||
refreshAvatar,
|
||||
setAvatarMissing,
|
||||
};
|
||||
}
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@font-face {
|
||||
font-family: "NanumSquare";
|
||||
src: url("/assets/fonts/NanumSquareL.woff2") format("woff2");
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "NanumSquare";
|
||||
src: url("/assets/fonts/NanumSquare.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "NanumSquare";
|
||||
src: url("/assets/fonts/NanumSquareB.woff2") format("woff2");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "NanumSquare";
|
||||
src: url("/assets/fonts/NanumSquareEB.woff2") format("woff2");
|
||||
font-weight: 800 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@theme {
|
||||
--font-sans: "NanumSquare", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
color: #17171f;
|
||||
background: #fafafa;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #cbd5e1 transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
body {
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type="password"] {
|
||||
color: #111827;
|
||||
caret-color: #111827;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
-webkit-text-fill-color: #111827;
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: #a0a7b7;
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
input[type="password"]::placeholder {
|
||||
color: #a0a7b7;
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 700;
|
||||
-webkit-text-fill-color: #a0a7b7;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.icon-ripple-button {
|
||||
transition: color 150ms ease;
|
||||
}
|
||||
|
||||
.icon-ripple-button::after {
|
||||
position: absolute;
|
||||
inset: 5px;
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
border: 1px solid rgba(79, 70, 229, 0.42);
|
||||
border-radius: 9999px;
|
||||
opacity: 0;
|
||||
transform: scale(0.72);
|
||||
}
|
||||
|
||||
.icon-ripple-button:active::after {
|
||||
animation: icon-ripple 340ms ease-out;
|
||||
}
|
||||
|
||||
.icon-chat-blink {
|
||||
animation: icon-chat-blink 1.05s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes icon-ripple {
|
||||
0% {
|
||||
opacity: 0.42;
|
||||
transform: scale(0.72);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(1.75);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes icon-chat-blink {
|
||||
0% {
|
||||
opacity: 1;
|
||||
filter: drop-shadow(0 0 0 rgba(37, 99, 235, 0));
|
||||
}
|
||||
|
||||
45% {
|
||||
opacity: 0.34;
|
||||
filter: drop-shadow(0 0 7px rgba(37, 99, 235, 0.82));
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
filter: drop-shadow(0 0 0 rgba(37, 99, 235, 0));
|
||||
}
|
||||
}
|
||||
|
||||
.page-transition {
|
||||
will-change: opacity, transform;
|
||||
transition:
|
||||
opacity 120ms ease,
|
||||
transform 120ms ease;
|
||||
}
|
||||
|
||||
.page-transition-in {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.page-transition-out {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.icon-ripple-button:active::after {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.icon-chat-blink {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.page-transition {
|
||||
transition: none;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,804 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Bell,
|
||||
BookOpenText,
|
||||
Building2,
|
||||
Camera,
|
||||
CheckCheck,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
LogOut,
|
||||
MessageCircle,
|
||||
Search,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { io } from 'socket.io-client';
|
||||
import type { Socket } from 'socket.io-client';
|
||||
import { ConfirmDialog } from '../components/ConfirmDialog';
|
||||
import { InstructorChatPanel } from '../components/InstructorChatPanel';
|
||||
import { InstructorLearningGuideModal } from '../components/InstructorLearningGuideModal';
|
||||
import { useToast } from '../components/ToastProvider';
|
||||
import autonomyLogo from '../assets/icon.png';
|
||||
import { useProfileAvatar } from '../hooks/useProfileAvatar';
|
||||
import { CHAT_SOCKET_ORIGIN, CHAT_SOCKET_PATH } from '../lib/chat';
|
||||
import type { InstructorCourse } from '../lib/courses';
|
||||
import { deleteProfileAvatar, notifyProfileAvatarDeleted, notifyProfileAvatarUpdated, resizeImageToWebp, uploadProfileAvatar } from '../lib/profileAvatar';
|
||||
import { navItems, type AppRoute } from '../routes';
|
||||
import type { AuthSession } from '../types/auth';
|
||||
|
||||
type InstructorLayoutProps = {
|
||||
session: AuthSession;
|
||||
route: AppRoute;
|
||||
onNavigate: (route: AppRoute) => void;
|
||||
onLogout: () => void;
|
||||
courses: InstructorCourse[];
|
||||
selectedCourseId: string;
|
||||
onSelectCourse: (courseId: string) => void;
|
||||
chatThreadId?: string | null;
|
||||
chatThreadRequestId?: number;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
type NotificationMessage = {
|
||||
id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
category: string;
|
||||
categoryLabel?: string;
|
||||
audience?: string;
|
||||
createdAt: string;
|
||||
date: string;
|
||||
read: boolean;
|
||||
};
|
||||
|
||||
type ChatNotificationPayload = {
|
||||
type: 'chat.message.created';
|
||||
threadId: string;
|
||||
messageId: string;
|
||||
senderType: 'STUDENT' | 'AI' | 'INSTRUCTOR' | 'SYSTEM';
|
||||
preview: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
const NOTIFICATION_API_BASE_URL = (import.meta.env.VITE_LAUNCHER_API_BASE_URL ?? 'https://portal-api.autonomystudio.net/api').replace(/\/+$/, '');
|
||||
const NOTIFICATION_DROPDOWN_LIMIT = 10;
|
||||
const NOTIFICATION_DRAWER_PAGE_SIZE = 8;
|
||||
|
||||
export function InstructorLayout({ session, route, onNavigate, onLogout, courses, selectedCourseId, onSelectCourse, chatThreadId = null, chatThreadRequestId = 0, children }: InstructorLayoutProps) {
|
||||
const [profileOpen, setProfileOpen] = useState(false);
|
||||
const [notificationOpen, setNotificationOpen] = useState(false);
|
||||
const [notificationDrawerOpen, setNotificationDrawerOpen] = useState(false);
|
||||
const [notificationQuery, setNotificationQuery] = useState('');
|
||||
const [notificationTab, setNotificationTab] = useState<'unread' | 'read'>('unread');
|
||||
const [notificationPage, setNotificationPage] = useState(1);
|
||||
const [chatOpen, setChatOpen] = useState(false);
|
||||
const [guideOpen, setGuideOpen] = useState(false);
|
||||
const [hasUnreadChat, setHasUnreadChat] = useState(false);
|
||||
const [notifications, setNotifications] = useState<NotificationMessage[]>([]);
|
||||
const profileRef = useRef<HTMLDivElement | null>(null);
|
||||
const avatarInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const { showToast } = useToast();
|
||||
const [avatarUploading, setAvatarUploading] = useState(false);
|
||||
const [avatarDeleting, setAvatarDeleting] = useState(false);
|
||||
const [avatarDeleteConfirmOpen, setAvatarDeleteConfirmOpen] = useState(false);
|
||||
const displayName = getDisplayName(session);
|
||||
const affiliation = getAffiliation(session);
|
||||
const roleLabel = getRoleLabel(session.user.role);
|
||||
const { avatarObjectUrl, avatarMissing, avatarLoading, refreshAvatar, setAvatarMissing } = useProfileAvatar(session.accessToken);
|
||||
const hasAvatar = !avatarMissing && Boolean(avatarObjectUrl);
|
||||
const avatarBusy = avatarLoading || avatarUploading || avatarDeleting;
|
||||
const unreadCount = notifications.filter((notification) => !notification.read).length;
|
||||
const hasUnreadNotifications = unreadCount > 0;
|
||||
const dropdownNotifications = notifications.slice(0, NOTIFICATION_DROPDOWN_LIMIT);
|
||||
const filteredDrawerNotifications = useMemo(() => {
|
||||
const query = notificationQuery.trim().toLowerCase();
|
||||
return notifications.filter((notification) => {
|
||||
if (notificationTab === 'unread' && notification.read) return false;
|
||||
if (notificationTab === 'read' && !notification.read) return false;
|
||||
if (!query) return true;
|
||||
return [notification.title, notification.body, notification.categoryLabel ?? notification.category]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(query);
|
||||
});
|
||||
}, [notificationQuery, notificationTab, notifications]);
|
||||
const notificationTotalPages = Math.max(1, Math.ceil(filteredDrawerNotifications.length / NOTIFICATION_DRAWER_PAGE_SIZE));
|
||||
const drawerNotifications = filteredDrawerNotifications.slice(
|
||||
(notificationPage - 1) * NOTIFICATION_DRAWER_PAGE_SIZE,
|
||||
notificationPage * NOTIFICATION_DRAWER_PAGE_SIZE,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (!profileRef.current?.contains(event.target as Node)) {
|
||||
setProfileOpen(false);
|
||||
setNotificationOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('pointerdown', handlePointerDown);
|
||||
return () => window.removeEventListener('pointerdown', handlePointerDown);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
fetch(`${NOTIFICATION_API_BASE_URL}/notifications?appKey=LMS&audience=instructor&page=1&pageSize=100`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.accessToken}`,
|
||||
},
|
||||
})
|
||||
.then((response) => (response.ok ? response.json() : null))
|
||||
.then((payload: { notifications?: NotificationMessage[] } | null) => {
|
||||
if (!cancelled) {
|
||||
setNotifications((payload?.notifications ?? []).map((notification) => ({ ...notification, read: Boolean(notification.read) })));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setNotifications([]);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [session.accessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
setNotificationPage(1);
|
||||
}, [notificationQuery, notificationTab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (notificationPage > notificationTotalPages) setNotificationPage(notificationTotalPages);
|
||||
}, [notificationPage, notificationTotalPages]);
|
||||
|
||||
useEffect(() => {
|
||||
const socket: Socket = io(CHAT_SOCKET_ORIGIN, {
|
||||
auth: { token: session.accessToken },
|
||||
path: CHAT_SOCKET_PATH,
|
||||
transports: ['websocket', 'polling'],
|
||||
});
|
||||
|
||||
const handleMessageCreated = (payload: ChatNotificationPayload) => {
|
||||
if (payload.senderType === 'STUDENT' && !chatOpen) setHasUnreadChat(true);
|
||||
};
|
||||
|
||||
socket.on('chat.message.created', handleMessageCreated);
|
||||
|
||||
return () => {
|
||||
socket.off('chat.message.created', handleMessageCreated);
|
||||
socket.disconnect();
|
||||
};
|
||||
}, [chatOpen, session.accessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (chatOpen) setHasUnreadChat(false);
|
||||
}, [chatOpen]);
|
||||
|
||||
const handleNotificationClick = () => {
|
||||
setNotificationOpen((current) => !current);
|
||||
setProfileOpen(false);
|
||||
setChatOpen(false);
|
||||
setNotificationDrawerOpen(false);
|
||||
};
|
||||
|
||||
const handleChatClick = () => {
|
||||
setChatOpen((current) => !current);
|
||||
setHasUnreadChat(false);
|
||||
setNotificationOpen(false);
|
||||
setNotificationDrawerOpen(false);
|
||||
setProfileOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatThreadId) return;
|
||||
setChatOpen(true);
|
||||
setHasUnreadChat(false);
|
||||
setNotificationOpen(false);
|
||||
setNotificationDrawerOpen(false);
|
||||
setProfileOpen(false);
|
||||
}, [chatThreadId, chatThreadRequestId]);
|
||||
|
||||
const handleOpenNotificationDrawer = () => {
|
||||
setNotificationDrawerOpen(true);
|
||||
setNotificationOpen(false);
|
||||
setChatOpen(false);
|
||||
setProfileOpen(false);
|
||||
};
|
||||
|
||||
const handleMarkAllNotificationsRead = async () => {
|
||||
if (!session.accessToken || unreadCount === 0) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${NOTIFICATION_API_BASE_URL}/notifications/read-all`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
appKey: 'LMS',
|
||||
audience: 'instructor',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('mark_read_failed');
|
||||
setNotifications((current) => current.map((notification) => ({ ...notification, read: true })));
|
||||
} catch {
|
||||
// 다음 조회에서 서버 상태와 다시 동기화된다.
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkNotificationRead = async (notificationId: string) => {
|
||||
const target = notifications.find((notification) => notification.id === notificationId);
|
||||
if (!session.accessToken || !target || target.read) return;
|
||||
|
||||
setNotifications((current) =>
|
||||
current.map((notification) => (notification.id === notificationId ? { ...notification, read: true } : notification)),
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${NOTIFICATION_API_BASE_URL}/notifications/${notificationId}/read`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('mark_read_failed');
|
||||
} catch {
|
||||
setNotifications((current) =>
|
||||
current.map((notification) => (notification.id === notificationId ? { ...notification, read: false } : notification)),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAvatarSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
if (!file) return;
|
||||
|
||||
setAvatarUploading(true);
|
||||
|
||||
try {
|
||||
const imageBlob = await resizeImageToWebp(file, 128);
|
||||
await uploadProfileAvatar(session.accessToken, imageBlob);
|
||||
refreshAvatar();
|
||||
notifyProfileAvatarUpdated();
|
||||
showToast({ title: '프로필 이미지가 변경되었습니다.', variant: 'success' });
|
||||
} catch (error) {
|
||||
showToast({
|
||||
title: '프로필 이미지 변경 실패',
|
||||
message: error instanceof Error ? error.message : '이미지를 다시 확인해주세요.',
|
||||
variant: 'error',
|
||||
});
|
||||
} finally {
|
||||
setAvatarUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAvatarDelete = async () => {
|
||||
setAvatarDeleting(true);
|
||||
|
||||
try {
|
||||
await deleteProfileAvatar(session.accessToken);
|
||||
setAvatarMissing(true);
|
||||
notifyProfileAvatarDeleted();
|
||||
setAvatarDeleteConfirmOpen(false);
|
||||
showToast({ title: '프로필 이미지가 삭제되었습니다.', variant: 'success' });
|
||||
} catch {
|
||||
showToast({ title: '프로필 이미지 삭제 실패', message: '잠시 후 다시 시도해주세요.', variant: 'error' });
|
||||
} finally {
|
||||
setAvatarDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen overflow-hidden bg-[#f7f9ff] text-[#111827]">
|
||||
<div className="pointer-events-none fixed inset-x-0 top-0 h-[260px] bg-[radial-gradient(circle_at_42%_36%,rgba(124,58,237,0.13),transparent_22%),radial-gradient(circle_at_80%_28%,rgba(37,99,235,0.12),transparent_24%),linear-gradient(180deg,rgba(239,246,255,0.94)_0%,rgba(247,249,255,0)_100%)] lg:left-[252px]" />
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-0 h-[420px] bg-[linear-gradient(0deg,rgba(124,58,237,0.14)_0%,rgba(167,139,250,0.07)_44%,rgba(247,249,255,0)_100%)] lg:left-[252px]" />
|
||||
<div className="pointer-events-none fixed right-[8%] top-[78px] h-2 w-2 rounded-full bg-[#93c5fd]/70" />
|
||||
<div className="pointer-events-none fixed left-[53%] top-[92px] h-3 w-3 rounded-full bg-[#c4b5fd]/70" />
|
||||
|
||||
<header className="fixed right-0 top-0 z-40 flex h-[60px] items-center border-b border-[#94a3b8]/55 bg-white/[0.04] shadow-[inset_0_1px_0_rgba(255,255,255,0.28)] backdrop-blur-[4px] lg:left-[252px]">
|
||||
<div className="min-w-0 flex-1" />
|
||||
|
||||
<div className="relative flex h-full items-center gap-2 px-6" ref={profileRef}>
|
||||
<button
|
||||
className={`icon-ripple-button relative grid h-9 w-9 cursor-pointer place-items-center rounded-md ${
|
||||
notificationOpen ? 'text-[#4f46e5]' : 'text-[#64748b] hover:text-[#111827]'
|
||||
}`}
|
||||
type="button"
|
||||
aria-label="알림"
|
||||
aria-expanded={notificationOpen}
|
||||
onClick={handleNotificationClick}
|
||||
>
|
||||
<Bell
|
||||
size={17}
|
||||
aria-hidden="true"
|
||||
className={notificationOpen ? 'fill-[#4f46e5] stroke-[#4f46e5]' : ''}
|
||||
/>
|
||||
{hasUnreadNotifications ? <span className="absolute right-2 top-2 h-1.5 w-1.5 rounded-full bg-[#e11d48]" /> : null}
|
||||
</button>
|
||||
|
||||
{notificationOpen ? (
|
||||
<div className="absolute right-[176px] top-[48px] w-[360px] overflow-hidden rounded-lg border border-[#dfe5ee] bg-white shadow-[0_18px_50px_rgba(15,23,42,0.14)]">
|
||||
<div className="border-b border-[#cbd5e1] px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[13px] font-black text-[#111827]">알림</p>
|
||||
<span className="text-[11px] font-bold text-[#64748b]">안읽음 {unreadCount}건</span>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-end gap-2">
|
||||
<button
|
||||
className="inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-md border border-[#dbe3ef] px-2.5 text-[11px] font-bold text-[#64748b] hover:bg-[#f8fafc] hover:text-[#111827] disabled:cursor-default disabled:opacity-40"
|
||||
type="button"
|
||||
disabled={unreadCount === 0}
|
||||
onClick={() => void handleMarkAllNotificationsRead()}
|
||||
>
|
||||
<CheckCheck size={13} aria-hidden="true" />
|
||||
모두 읽음
|
||||
</button>
|
||||
<button
|
||||
className="h-7 cursor-pointer rounded-md border border-[#c7d2fe] bg-[#eef2ff] px-2.5 text-[11px] font-black text-[#4f46e5] hover:bg-[#e0e7ff]"
|
||||
type="button"
|
||||
onClick={handleOpenNotificationDrawer}
|
||||
>
|
||||
전체 보기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-[320px] overflow-y-auto pb-3">
|
||||
{dropdownNotifications.length > 0 ? (
|
||||
dropdownNotifications.map((notification, index) => (
|
||||
<NotificationRow
|
||||
key={notification.id}
|
||||
notification={notification}
|
||||
bordered={index < dropdownNotifications.length - 1}
|
||||
onClick={() => void handleMarkNotificationRead(notification.id)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="px-4 py-8 text-center">
|
||||
<p className="text-[13px] font-bold text-[#64748b]">표시할 알림이 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
className={`icon-ripple-button relative grid h-9 w-9 cursor-pointer place-items-center rounded-md ${
|
||||
chatOpen || hasUnreadChat ? 'text-[#2563eb]' : 'text-[#64748b] hover:text-[#111827]'
|
||||
}`}
|
||||
type="button"
|
||||
aria-label="강사 채팅"
|
||||
aria-expanded={chatOpen}
|
||||
onClick={handleChatClick}
|
||||
>
|
||||
<MessageCircle
|
||||
size={17}
|
||||
aria-hidden="true"
|
||||
className={[
|
||||
chatOpen || hasUnreadChat ? 'fill-[#2563eb] stroke-[#2563eb] text-white' : '',
|
||||
hasUnreadChat && !chatOpen ? 'icon-chat-blink' : ''
|
||||
].join(' ')}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="flex h-10 items-center gap-1.5 rounded-md px-2 text-left hover:bg-[#f8fafc] cursor-pointer"
|
||||
type="button"
|
||||
aria-expanded={profileOpen}
|
||||
onClick={() => setProfileOpen((current) => !current)}
|
||||
>
|
||||
<div className="hidden min-w-0 text-right sm:block">
|
||||
<p className="truncate text-[13px] font-extrabold text-[#1f2937]">
|
||||
{displayName} <span className="font-semibold text-[#64748b]">님</span>
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDown className={`text-[#94a3b8] transition-transform ${profileOpen ? 'rotate-180' : ''}`} size={14} aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
{profileOpen ? (
|
||||
<div className="absolute right-6 top-[48px] w-[292px] overflow-hidden rounded-lg border border-[#dfe5ee] bg-white shadow-[0_18px_50px_rgba(15,23,42,0.14)]">
|
||||
<div className="border-b border-[#cbd5e1] px-4 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative h-12 w-12 shrink-0">
|
||||
<span className="grid h-full w-full place-items-center overflow-hidden rounded-full border border-[#dbe3ef] bg-[#2563eb] text-[15px] font-extrabold text-white">
|
||||
{hasAvatar ? (
|
||||
<img
|
||||
src={avatarObjectUrl ?? undefined}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
onError={() => setAvatarMissing(true)}
|
||||
/>
|
||||
) : (
|
||||
displayName.slice(0, 1)
|
||||
)}
|
||||
</span>
|
||||
{avatarBusy ? (
|
||||
<span className="absolute inset-0 grid place-items-center rounded-full bg-white/65 text-[#2563eb] backdrop-blur-[2px]">
|
||||
<Loader2 size={16} className="animate-spin" aria-hidden="true" />
|
||||
</span>
|
||||
) : null}
|
||||
<input ref={avatarInputRef} type="file" accept="image/*" className="hidden" onChange={handleAvatarSelect} />
|
||||
<button
|
||||
type="button"
|
||||
className="absolute bottom-0 right-0 z-10 grid h-6 w-6 translate-x-1 translate-y-1 cursor-pointer place-items-center rounded-full border border-white bg-[#2563eb] text-white shadow-[0_8px_18px_rgba(37,99,235,0.28)] transition hover:bg-[#1d4ed8] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={avatarBusy}
|
||||
onClick={() => avatarInputRef.current?.click()}
|
||||
aria-label="프로필 이미지 등록"
|
||||
>
|
||||
<Camera size={12} aria-hidden="true" />
|
||||
</button>
|
||||
{hasAvatar ? (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-0 top-0 z-10 grid h-6 w-6 translate-x-1 -translate-y-1 cursor-pointer place-items-center rounded-full border border-white bg-[#fff1f2] text-[#e11d48] shadow-[0_8px_18px_rgba(225,29,72,0.18)] transition hover:bg-[#ffe4e6] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={avatarBusy}
|
||||
onClick={() => setAvatarDeleteConfirmOpen(true)}
|
||||
aria-label="프로필 이미지 삭제"
|
||||
>
|
||||
<Trash2 size={12} aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-[14px] font-black text-[#111827]">{displayName}</p>
|
||||
<p className="mt-1 break-all text-[11px] leading-4 text-[#64748b]">{session.user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-2 text-[12px] font-bold text-[#64748b]">
|
||||
<ProfileMeta icon={Building2} label={affiliation} />
|
||||
<ProfileMeta icon={ShieldCheck} label={roleLabel} />
|
||||
</div>
|
||||
<label className="mt-4 block">
|
||||
<span className="text-[11px] font-black text-[#64748b]">강좌 선택</span>
|
||||
<select
|
||||
className="mt-1 h-9 w-full rounded-md border border-[#d1d9e6] bg-white px-3 text-[12px] font-bold text-[#111827] outline-none transition focus:border-[#2563eb]"
|
||||
value={selectedCourseId}
|
||||
onChange={(event) => onSelectCourse(event.target.value)}
|
||||
>
|
||||
{courses.length ? courses.map((course) => (
|
||||
<option key={course.id} value={course.id}>
|
||||
{course.title}
|
||||
</option>
|
||||
)) : (
|
||||
<option value="">등록된 강좌 없음</option>
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="p-2">
|
||||
<ProfileAction
|
||||
icon={Settings}
|
||||
label="설정"
|
||||
onClick={() => {
|
||||
setProfileOpen(false);
|
||||
onNavigate('settings');
|
||||
}}
|
||||
/>
|
||||
<ProfileAction icon={LogOut} label="로그아웃" danger onClick={onLogout} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ConfirmDialog
|
||||
open={avatarDeleteConfirmOpen}
|
||||
title="프로필 이미지 삭제"
|
||||
message="등록된 썸네일 이미지를 삭제하시겠습니까?"
|
||||
confirmLabel="삭제"
|
||||
loading={avatarDeleting}
|
||||
danger
|
||||
icon={Trash2}
|
||||
onCancel={() => setAvatarDeleteConfirmOpen(false)}
|
||||
onConfirm={() => void handleAvatarDelete()}
|
||||
/>
|
||||
|
||||
<InstructorLearningGuideModal open={guideOpen} onClose={() => setGuideOpen(false)} />
|
||||
|
||||
<InstructorChatPanel
|
||||
session={session}
|
||||
courseId={selectedCourseId}
|
||||
open={chatOpen}
|
||||
initialThreadId={chatThreadId}
|
||||
initialThreadRequestId={chatThreadRequestId}
|
||||
onClose={() => setChatOpen(false)}
|
||||
/>
|
||||
<NotificationDrawer
|
||||
notifications={drawerNotifications}
|
||||
filteredCount={filteredDrawerNotifications.length}
|
||||
notificationPage={notificationPage}
|
||||
notificationTab={notificationTab}
|
||||
notificationTotalPages={notificationTotalPages}
|
||||
notificationQuery={notificationQuery}
|
||||
open={notificationDrawerOpen}
|
||||
unreadCount={unreadCount}
|
||||
readCount={notifications.length - unreadCount}
|
||||
onClose={() => setNotificationDrawerOpen(false)}
|
||||
onMarkAllRead={() => void handleMarkAllNotificationsRead()}
|
||||
onMarkRead={(notificationId) => void handleMarkNotificationRead(notificationId)}
|
||||
onPageChange={setNotificationPage}
|
||||
onQueryChange={setNotificationQuery}
|
||||
onTabChange={setNotificationTab}
|
||||
/>
|
||||
|
||||
<aside className="fixed bottom-0 left-0 top-0 z-50 hidden w-[252px] overflow-hidden bg-[linear-gradient(180deg,#071126_0%,#0b1630_56%,#101a3a_100%)] text-white lg:flex lg:flex-col">
|
||||
<div className="flex h-[82px] items-center gap-3 px-5">
|
||||
<img className="h-8 w-8 rounded-md object-contain" src={autonomyLogo} alt="" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-[16px] font-black tracking-[0] text-white">Autonomy <span className="text-[#67e8f9]">Studio</span></p>
|
||||
<p className="text-[11px] font-semibold leading-tight text-white/68">Instructor Portal</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-2 px-5 py-3">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = item.id === route;
|
||||
return (
|
||||
<div key={item.id}>
|
||||
<button
|
||||
className={`flex h-11 w-full items-center rounded-lg px-4 text-left text-[13px] transition ${
|
||||
active
|
||||
? 'bg-[linear-gradient(135deg,#6366f1_0%,#7c3aed_100%)] font-black text-white shadow-[0_12px_28px_rgba(79,70,229,0.32)]'
|
||||
: 'font-bold text-white/72 hover:bg-white/8 hover:text-white'
|
||||
}`}
|
||||
type="button"
|
||||
onClick={() => onNavigate(item.id)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-3">
|
||||
<Icon className={active ? 'text-white' : 'text-white/72'} size={16} aria-hidden="true" />
|
||||
{item.label}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="px-5 pb-5">
|
||||
<div className="rounded-lg border border-white/10 bg-white/8 p-3 shadow-[0_18px_46px_rgba(0,0,0,0.16)] backdrop-blur">
|
||||
<div className="mb-3 flex items-start gap-2.5">
|
||||
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-md bg-white/12 text-[#8b5cf6]">
|
||||
<BookOpenText size={15} aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[12px] font-black text-white">도움이 필요하신가요?</p>
|
||||
<p className="mt-1 text-[11px] font-bold leading-5 text-white/62">가이드를 확인하거나 문의해 보세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button 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">
|
||||
가이드 보기
|
||||
<ExternalLink size={13} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="relative min-w-0 lg:pl-[252px]">
|
||||
<div className="relative px-6 pb-24 pt-[100px] lg:px-12 lg:pb-32">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getDisplayName(session: AuthSession): string {
|
||||
const name = session.user.name?.trim();
|
||||
if (name && name !== 'ATNMY Studio Admin') return name;
|
||||
return session.user.email.split('@')[0] || '강사';
|
||||
}
|
||||
|
||||
function getAffiliation(session: AuthSession): string {
|
||||
const affiliation = session.user.primaryAffiliation;
|
||||
if (!affiliation) return '소속 미지정';
|
||||
|
||||
return [affiliation.organizationName, affiliation.department, affiliation.position].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
function getRoleLabel(role: string): string {
|
||||
const normalized = role.toUpperCase();
|
||||
if (normalized === 'LMS_ADMIN') return '관리자';
|
||||
if (normalized === 'INSTRUCTOR') return '강사';
|
||||
return role;
|
||||
}
|
||||
|
||||
function NotificationDrawer({
|
||||
filteredCount,
|
||||
notificationPage,
|
||||
notificationQuery,
|
||||
notificationTab,
|
||||
notificationTotalPages,
|
||||
notifications,
|
||||
open,
|
||||
readCount,
|
||||
unreadCount,
|
||||
onClose,
|
||||
onMarkAllRead,
|
||||
onMarkRead,
|
||||
onPageChange,
|
||||
onQueryChange,
|
||||
onTabChange,
|
||||
}: {
|
||||
filteredCount: number;
|
||||
notificationPage: number;
|
||||
notificationQuery: string;
|
||||
notificationTab: 'unread' | 'read';
|
||||
notificationTotalPages: number;
|
||||
notifications: NotificationMessage[];
|
||||
open: boolean;
|
||||
readCount: number;
|
||||
unreadCount: number;
|
||||
onClose: () => void;
|
||||
onMarkAllRead: () => void;
|
||||
onMarkRead: (notificationId: string) => void;
|
||||
onPageChange: React.Dispatch<React.SetStateAction<number>>;
|
||||
onQueryChange: (query: string) => void;
|
||||
onTabChange: (tab: 'unread' | 'read') => void;
|
||||
}) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<aside className="fixed bottom-0 right-0 top-[60px] z-30 flex w-full max-w-[420px] flex-col border-l border-[#cbd5e1] bg-white/94 shadow-[0_22px_70px_rgba(15,23,42,0.18)] backdrop-blur-md">
|
||||
<header className="flex h-[72px] shrink-0 items-center justify-between border-b border-[#cbd5e1] px-5">
|
||||
<div>
|
||||
<h2 className="text-[16px] font-black text-[#0f172a]">전체 알림</h2>
|
||||
<p className="mt-1 text-[12px] font-bold text-[#64748b]">최근 알림을 확인하고 읽음 처리합니다.</p>
|
||||
</div>
|
||||
<button className="grid h-9 w-9 place-items-center rounded-md text-[#64748b] hover:bg-[#f8fafc] hover:text-[#111827]" type="button" aria-label="알림 닫기" title="알림 닫기" onClick={onClose}>
|
||||
<X size={17} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="shrink-0 border-b border-[#cbd5e1] px-5 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[#94a3b8]" size={15} aria-hidden="true" />
|
||||
<input
|
||||
className="h-9 w-full rounded-md border border-[#dbe3ef] bg-white pl-9 pr-3 text-[13px] font-bold text-[#111827] outline-none placeholder:text-[#94a3b8] focus:border-[#a5b4fc]"
|
||||
value={notificationQuery}
|
||||
placeholder="알림 검색"
|
||||
onChange={(event) => onQueryChange(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="inline-flex h-9 shrink-0 cursor-pointer items-center gap-1.5 rounded-md border border-[#dbe3ef] px-3 text-[12px] font-bold text-[#64748b] hover:bg-[#f8fafc] hover:text-[#111827] disabled:cursor-default disabled:opacity-40"
|
||||
type="button"
|
||||
disabled={unreadCount === 0}
|
||||
onClick={onMarkAllRead}
|
||||
>
|
||||
<CheckCheck size={14} aria-hidden="true" />
|
||||
모두 읽음
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<div className="inline-flex rounded-md border border-[#dbe3ef] bg-[#f8fafc] p-1">
|
||||
{(['unread', 'read'] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
className={`h-8 cursor-pointer rounded px-3 text-[12px] font-black transition ${
|
||||
notificationTab === tab ? 'bg-white text-[#4f46e5] shadow-[0_6px_16px_rgba(15,23,42,0.08)]' : 'text-[#64748b] hover:text-[#111827]'
|
||||
}`}
|
||||
type="button"
|
||||
onClick={() => onTabChange(tab)}
|
||||
>
|
||||
{tab === 'unread' ? `안 읽음 ${unreadCount}` : `읽음 ${readCount}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[11px] font-bold text-[#94a3b8]">{filteredCount}건</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-6 pt-4">
|
||||
{notifications.length > 0 ? (
|
||||
<div className="overflow-hidden rounded-lg border border-[#c7d2e0] bg-white shadow-[0_10px_28px_rgba(15,23,42,0.05)]">
|
||||
{notifications.map((notification, index) => (
|
||||
<NotificationRow
|
||||
key={notification.id}
|
||||
notification={notification}
|
||||
bordered={index < notifications.length - 1}
|
||||
onClick={() => onMarkRead(notification.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center rounded-lg border border-dashed border-[#cbd5e1] bg-white/58 px-6 text-center">
|
||||
<p className="text-[13px] font-bold text-[#64748b]">표시할 알림이 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="flex h-[54px] shrink-0 items-center justify-between border-t border-[#cbd5e1] px-5">
|
||||
<span className="text-[12px] font-bold text-[#94a3b8]">
|
||||
{notificationPage}/{notificationTotalPages}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className="grid h-8 w-8 cursor-pointer place-items-center rounded-md border border-[#dbe3ef] text-[#64748b] hover:bg-[#f8fafc] hover:text-[#111827] disabled:cursor-default disabled:opacity-35 disabled:hover:bg-transparent"
|
||||
type="button"
|
||||
aria-label="이전 페이지"
|
||||
disabled={notificationPage <= 1}
|
||||
onClick={() => onPageChange((page) => Math.max(1, page - 1))}
|
||||
>
|
||||
<ChevronLeft size={16} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
className="grid h-8 w-8 cursor-pointer place-items-center rounded-md border border-[#dbe3ef] text-[#64748b] hover:bg-[#f8fafc] hover:text-[#111827] disabled:cursor-default disabled:opacity-35 disabled:hover:bg-transparent"
|
||||
type="button"
|
||||
aria-label="다음 페이지"
|
||||
disabled={notificationPage >= notificationTotalPages}
|
||||
onClick={() => onPageChange((page) => Math.min(notificationTotalPages, page + 1))}
|
||||
>
|
||||
<ChevronRight size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationRow({ notification, bordered, onClick }: { notification: NotificationMessage; bordered: boolean; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
className={`block w-full cursor-pointer px-4 py-3 text-left transition hover:bg-[#f8fafc] ${bordered ? 'border-b border-[#cbd5e1]' : ''} ${
|
||||
notification.read ? 'opacity-70' : ''
|
||||
}`}
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="mb-1.5 flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{!notification.read ? <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-[#4f46e5]" /> : null}
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] font-black uppercase tracking-[0.02em] ${
|
||||
notification.read ? 'bg-[#f1f5f9] text-[#94a3b8]' : 'bg-[#eef2ff] text-[#4f46e5]'
|
||||
}`}
|
||||
>
|
||||
{notification.categoryLabel ?? notification.category}
|
||||
</span>
|
||||
</div>
|
||||
<span className="shrink-0 text-[11px] font-bold text-[#94a3b8]">{notification.date}</span>
|
||||
</div>
|
||||
<p className={`text-[13px] leading-5 ${notification.read ? 'font-bold text-[#64748b]' : 'font-black text-[#111827]'}`}>{notification.title}</p>
|
||||
<p className={`mt-1 text-[12px] font-semibold leading-5 ${notification.read ? 'text-[#94a3b8]' : 'text-[#64748b]'}`}>{notification.body}</p>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileMeta({ icon: Icon, label }: { icon: LucideIcon; label: string }) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Icon className="shrink-0 text-[#94a3b8]" size={14} aria-hidden="true" />
|
||||
<span className="truncate">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileAction({ icon: Icon, label, danger = false, onClick }: { icon: LucideIcon; label: string; danger?: boolean; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
className={`flex h-9 w-full items-center gap-3 rounded-md px-3 text-left text-[13px] font-bold ${
|
||||
danger ? 'text-[#e11d48] hover:bg-[#fff1f2]' : 'text-[#111827] hover:bg-[#f8fafc]'
|
||||
}`}
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
>
|
||||
<Icon size={15} aria-hidden="true" />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
import type { AuthAppRole, AuthSession } from '../types/auth';
|
||||
|
||||
const AUTH_BASE_URL = (import.meta.env.VITE_ATNMY_AUTH_URL ?? import.meta.env.VITE_ADS_IDE_AUTH_URL ?? 'https://auth.autonomystudio.net').replace(/\/+$/, '');
|
||||
const ADMIN_SESSION_STORAGE_KEY = 'atnmy.lms.admin.auth.session';
|
||||
const INSTRUCTOR_ROLES = new Set(['INSTRUCTOR', 'LMS_ADMIN']);
|
||||
|
||||
export const isInstructorSession = (session: AuthSession): boolean => {
|
||||
const roles = new Set(getSessionRoles(session));
|
||||
return [...INSTRUCTOR_ROLES].some((role) => roles.has(role));
|
||||
};
|
||||
|
||||
const getSessionRoles = (session: AuthSession): string[] => {
|
||||
return [
|
||||
session.user.role,
|
||||
...(session.user.roles ?? []),
|
||||
...(session.roles ?? []),
|
||||
...getAppRoleCodes(session.user.appRoles),
|
||||
...getAppRoleCodes(session.user.app_roles),
|
||||
...getAppRoleCodes(session.appRoles),
|
||||
...getAppRoleCodes(session.app_roles),
|
||||
]
|
||||
.filter((role): role is string => Boolean(role))
|
||||
.map((role) => role.toUpperCase());
|
||||
};
|
||||
|
||||
const getAppRoleCodes = (roles: AuthAppRole[] | undefined): string[] => {
|
||||
if (!Array.isArray(roles)) return [];
|
||||
|
||||
return roles
|
||||
.filter((role) => {
|
||||
const appKey = role.appKey ?? role.app_key;
|
||||
return !appKey || appKey.toUpperCase() === 'LMS';
|
||||
})
|
||||
.map((role) => role.roleCode ?? role.role_code ?? role.role ?? role.code)
|
||||
.filter((role): role is string => Boolean(role));
|
||||
};
|
||||
|
||||
export const loadStoredSession = (): AuthSession | null => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(ADMIN_SESSION_STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
|
||||
const session = JSON.parse(raw) as AuthSession;
|
||||
if (!isInstructorSession(session) || isStoredSessionExpired(session)) {
|
||||
clearStoredSession();
|
||||
return null;
|
||||
}
|
||||
|
||||
return session;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const storeSession = (session: AuthSession): void => {
|
||||
window.localStorage.setItem(ADMIN_SESSION_STORAGE_KEY, JSON.stringify({
|
||||
...session,
|
||||
obtainedAt: session.obtainedAt ?? Date.now(),
|
||||
}));
|
||||
};
|
||||
|
||||
export const clearStoredSession = (): void => {
|
||||
window.localStorage.removeItem(ADMIN_SESSION_STORAGE_KEY);
|
||||
};
|
||||
|
||||
export const isStoredSessionExpired = (session: AuthSession): boolean => {
|
||||
const obtainedAt = session.obtainedAt ?? 0;
|
||||
if (!obtainedAt) return false;
|
||||
|
||||
if (session.refreshToken && session.refreshTokenExpiresIn) {
|
||||
return Date.now() >= obtainedAt + session.refreshTokenExpiresIn * 1000;
|
||||
}
|
||||
|
||||
return Date.now() >= obtainedAt + (session.accessTokenExpiresIn ?? 0) * 1000;
|
||||
};
|
||||
|
||||
export const loginInstructor = async (email: string, password: string): Promise<AuthSession> => {
|
||||
const response = await fetch(`${AUTH_BASE_URL}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(response.status === 429 ? '로그인 시도가 너무 많습니다. 잠시 후 다시 시도해주세요.' : '이메일 또는 비밀번호를 확인해주세요.');
|
||||
}
|
||||
|
||||
const session = {
|
||||
...(await response.json()) as AuthSession,
|
||||
obtainedAt: Date.now(),
|
||||
};
|
||||
if (!isInstructorSession(session)) {
|
||||
clearStoredSession();
|
||||
throw new Error('강사 계정만 로그인할 수 있습니다.');
|
||||
}
|
||||
|
||||
storeSession(session);
|
||||
return session;
|
||||
};
|
||||
|
||||
export const refreshInstructorSession = async (session: AuthSession): Promise<AuthSession> => {
|
||||
if (!session.refreshToken) {
|
||||
throw new Error('리프레시 토큰이 없습니다.');
|
||||
}
|
||||
|
||||
const response = await fetch(`${AUTH_BASE_URL}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ refreshToken: session.refreshToken })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('세션을 갱신하지 못했습니다.');
|
||||
}
|
||||
|
||||
const nextSession = {
|
||||
...(await response.json()) as AuthSession,
|
||||
obtainedAt: Date.now(),
|
||||
};
|
||||
|
||||
if (!isInstructorSession(nextSession)) {
|
||||
clearStoredSession();
|
||||
throw new Error('강사 계정만 로그인할 수 있습니다.');
|
||||
}
|
||||
|
||||
storeSession(nextSession);
|
||||
return nextSession;
|
||||
};
|
||||
|
||||
export const refreshCurrentUser = async (session: AuthSession): Promise<AuthSession> => {
|
||||
const response = await fetch(`${AUTH_BASE_URL}/auth/me`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.accessToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('사용자 정보를 다시 가져오지 못했습니다.');
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as { user?: AuthSession['user'] };
|
||||
const nextSession = {
|
||||
...session,
|
||||
user: payload.user ?? session.user
|
||||
};
|
||||
|
||||
if (!isInstructorSession(nextSession)) {
|
||||
clearStoredSession();
|
||||
throw new Error('강사 계정만 로그인할 수 있습니다.');
|
||||
}
|
||||
|
||||
storeSession(nextSession);
|
||||
return nextSession;
|
||||
};
|
||||
|
||||
export const logoutInstructor = async (): Promise<void> => {
|
||||
try {
|
||||
await fetch(`${AUTH_BASE_URL}/auth/logout`, {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
} finally {
|
||||
clearStoredSession();
|
||||
}
|
||||
};
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
export type ChatSenderType = 'STUDENT' | 'AI' | 'INSTRUCTOR' | 'SYSTEM' | string;
|
||||
|
||||
export type InstructorChatMessage = {
|
||||
id: string;
|
||||
senderType: ChatSenderType;
|
||||
senderName?: string;
|
||||
senderAvatarUrl?: string | null;
|
||||
content: string;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
export type InstructorChatThread = {
|
||||
id: string;
|
||||
title: string;
|
||||
studentName: string;
|
||||
studentAvatarUrl?: string | null;
|
||||
groupName?: string;
|
||||
week?: number | string;
|
||||
status: string;
|
||||
lastMessage: string;
|
||||
lastMessageAt?: string;
|
||||
messages: InstructorChatMessage[];
|
||||
};
|
||||
|
||||
const CHAT_API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? 'https://workspace.autonomystudio.net/lms-api/api').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(/\/+$/, '');
|
||||
export const CHAT_SOCKET_BASE_URL = CHAT_API_BASE_URL.replace(/\/api$/, '');
|
||||
const CHAT_SOCKET_URL = new URL(CHAT_SOCKET_BASE_URL || '/', window.location.origin);
|
||||
const CHAT_SOCKET_PREFIX = CHAT_SOCKET_URL.pathname.replace(/\/+$/, '');
|
||||
export const CHAT_SOCKET_ORIGIN = CHAT_SOCKET_URL.origin;
|
||||
export const CHAT_SOCKET_PATH = `${CHAT_SOCKET_PREFIX}/socket.io`;
|
||||
|
||||
function normalizeAvatarUrl(value: unknown): string | null {
|
||||
if (typeof value !== 'string' || !value.trim()) return null;
|
||||
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.hostname === 'studio-auth-be.studio-system.svc.cluster.local') {
|
||||
return `${AUTH_BASE_URL}${url.pathname}${url.search}`;
|
||||
}
|
||||
return value;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
type ChatThreadsPayload = {
|
||||
threads?: unknown[];
|
||||
data?: unknown[];
|
||||
};
|
||||
|
||||
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();
|
||||
|
||||
const response = await fetch(`${CHAT_API_BASE_URL}/chat/instructor/threads${queryString ? `?${queryString}` : ''}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`강사 채팅 목록을 불러오지 못했습니다. (${response.status})`);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as ChatThreadsPayload | unknown[];
|
||||
const items = Array.isArray(payload) ? payload : Array.isArray(payload?.threads) ? payload.threads : Array.isArray(payload?.data) ? payload.data : [];
|
||||
return items.map(normalizeThread).filter((thread): thread is InstructorChatThread => Boolean(thread));
|
||||
}
|
||||
|
||||
export async function getInstructorChatThread(accessToken: string, threadId: string): Promise<InstructorChatThread> {
|
||||
const response = await fetch(`${CHAT_API_BASE_URL}/chat/instructor/threads/${threadId}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`강사 채팅방을 불러오지 못했습니다. (${response.status})`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
const thread = normalizeThread(payload?.thread ?? payload);
|
||||
if (!thread) throw new Error('강사 채팅방 응답이 올바르지 않습니다.');
|
||||
|
||||
return thread;
|
||||
}
|
||||
|
||||
export async function createInstructorChatMessage(accessToken: string, threadId: string, content: string): Promise<InstructorChatThread> {
|
||||
const response = await fetch(`${CHAT_API_BASE_URL}/chat/instructor/threads/${threadId}/messages`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`강사 답변을 저장하지 못했습니다. (${response.status})`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
const thread = normalizeThread(payload?.thread ?? payload);
|
||||
if (!thread) throw new Error('강사 답변 응답이 올바르지 않습니다.');
|
||||
|
||||
return thread;
|
||||
}
|
||||
|
||||
function normalizeThread(raw: unknown): InstructorChatThread | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const item = raw as Record<string, any>;
|
||||
|
||||
const messages = Array.isArray(item.messages) ? item.messages.map(normalizeMessage).filter((message): message is InstructorChatMessage => Boolean(message)) : [];
|
||||
const lastMessageFromList = messages.length > 0 ? messages[messages.length - 1]?.content : undefined;
|
||||
const rawLastMessage = item.lastMessage?.content ?? item.lastMessageContent ?? item.lastMessage;
|
||||
const studentName = item.student?.name ?? item.studentName ?? item.student?.email ?? item.studentEmail ?? item.studentId ?? '수강생';
|
||||
const title = item.title ?? item.threadTitle ?? studentName;
|
||||
const weekValue = typeof item.week === 'object' && item.week ? item.week.week : item.week;
|
||||
|
||||
return {
|
||||
id: String(item.id ?? item.threadId ?? title),
|
||||
title: String(title),
|
||||
studentName: String(studentName),
|
||||
studentAvatarUrl: normalizeAvatarUrl(item.studentAvatarUrl),
|
||||
groupName: item.group?.name ?? item.groupName ?? item.metadata?.groupName,
|
||||
week: weekValue ?? item.weekId ?? item.metadata?.week,
|
||||
status: String(item.status ?? 'WAITING_INSTRUCTOR'),
|
||||
lastMessage: String(rawLastMessage ?? lastMessageFromList ?? '저장된 문의 내용을 확인해 주세요.'),
|
||||
lastMessageAt: item.lastMessageAt ?? item.updatedAt ?? item.createdAt,
|
||||
messages,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMessage(raw: unknown): InstructorChatMessage | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const item = raw as Record<string, any>;
|
||||
|
||||
return {
|
||||
id: String(item.id ?? `${item.senderType ?? 'MESSAGE'}-${item.createdAt ?? item.content ?? 'unknown'}`),
|
||||
senderType: String(item.senderType ?? 'STUDENT'),
|
||||
senderName: item.senderName ?? item.sender?.name,
|
||||
senderAvatarUrl: normalizeAvatarUrl(item.senderAvatarUrl),
|
||||
content: String(item.content ?? ''),
|
||||
createdAt: item.createdAt,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
export type InstructorCourse = {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
subtitle?: string | null;
|
||||
description?: string | null;
|
||||
organizationName: string;
|
||||
teacher?: {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
email: string;
|
||||
};
|
||||
};
|
||||
|
||||
const LMS_API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? 'https://workspace.autonomystudio.net/lms-api/api').replace(/\/+$/, '');
|
||||
|
||||
export async function listInstructorCourses(accessToken: string): Promise<{ courses: InstructorCourse[] }> {
|
||||
const response = await fetch(`${LMS_API_BASE_URL}/instructor/courses`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`강좌 목록을 불러오지 못했습니다. (${response.status})`);
|
||||
return response.json() as Promise<{ courses: InstructorCourse[] }>;
|
||||
}
|
||||
|
||||
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 }>;
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
export type CurriculumWeek = {
|
||||
canDelete?: boolean;
|
||||
week: number;
|
||||
title: string;
|
||||
tags: string[];
|
||||
progress: number;
|
||||
state: string;
|
||||
topics: string[];
|
||||
duration: string;
|
||||
github: string;
|
||||
detail: {
|
||||
coverImage?: CurriculumReference | null;
|
||||
camera?: { url: string };
|
||||
overview: string;
|
||||
learningSteps?: Array<{
|
||||
title: string;
|
||||
description: string;
|
||||
videoEnabled?: boolean;
|
||||
videoUrl?: string;
|
||||
concepts?: string[];
|
||||
}>;
|
||||
topicDetails: string[];
|
||||
topicVideoUrls?: string[];
|
||||
practiceContents?: Array<{
|
||||
title: string;
|
||||
description: string;
|
||||
}>;
|
||||
references?: CurriculumReference[];
|
||||
exampleCodes?: CurriculumReference[];
|
||||
deliverable: string;
|
||||
};
|
||||
modules?: CurriculumModule[];
|
||||
environmentProfiles?: CurriculumEnvironmentProfile[];
|
||||
visualizationPlatforms?: CurriculumVisualizationPlatform[];
|
||||
mapKeys?: string[];
|
||||
isTeamProject?: boolean;
|
||||
};
|
||||
|
||||
export type CurriculumMap = {
|
||||
key: string;
|
||||
platformKey: 'NAV2';
|
||||
name: string;
|
||||
description: string;
|
||||
worldFile: string;
|
||||
viewerModel: 'turtlebot3_world' | 'turtlebot3_house';
|
||||
custom?: boolean;
|
||||
};
|
||||
|
||||
export type CurriculumModule = {
|
||||
id: string;
|
||||
sortOrder: number;
|
||||
category: string;
|
||||
name: string;
|
||||
folderName: string;
|
||||
templateRepositoryUrl?: string | null;
|
||||
dockerImage?: string | null;
|
||||
defaultParameters?: string[];
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export type CurriculumEnvironmentProfile = {
|
||||
id: string;
|
||||
platformKey: string;
|
||||
profileType: string;
|
||||
name: string;
|
||||
config?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type CurriculumReference = {
|
||||
id: string;
|
||||
kind?: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
fileName: string;
|
||||
contentType?: string | null;
|
||||
fileSizeBytes?: number | string | null;
|
||||
week?: number | string | null;
|
||||
weekNumber?: number | string | null;
|
||||
downloadUrl: string;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
export type CurriculumVisualizationPlatform = {
|
||||
key: string;
|
||||
type: string;
|
||||
displayName: string;
|
||||
description?: string | null;
|
||||
config?: unknown;
|
||||
};
|
||||
|
||||
export type CurriculumTagOption = {
|
||||
key: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type InstructorCurriculumResponse = {
|
||||
course: {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle?: string | null;
|
||||
description?: string | null;
|
||||
organizationName: string;
|
||||
} | null;
|
||||
weeks: CurriculumWeek[];
|
||||
setting: {
|
||||
groupId?: string | null;
|
||||
activeWeek: number;
|
||||
activeLearningStep?: string | null;
|
||||
} | null;
|
||||
tagOptions?: CurriculumTagOption[];
|
||||
visualizationPlatforms?: CurriculumVisualizationPlatform[];
|
||||
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(/\/+$/, '');
|
||||
|
||||
function uploadErrorMessage(status: number, fallback: string) {
|
||||
if (status === 413) return '파일 용량이 업로드 제한을 초과했습니다. 관리자에게 업로드 허용 용량 설정을 확인해 주세요.';
|
||||
return `${fallback} (${status})`;
|
||||
}
|
||||
|
||||
export async function getInstructorCurriculum(accessToken: string, groupId?: string | null, courseId?: string | null): Promise<InstructorCurriculumResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (groupId) params.set('groupId', groupId);
|
||||
if (courseId) params.set('courseId', courseId);
|
||||
const query = params.toString() ? `?${params.toString()}` : '';
|
||||
const response = await fetch(`${LMS_API_BASE_URL}/instructor/curriculum${query}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`커리큘럼을 불러오지 못했습니다. (${response.status})`);
|
||||
return response.json() as Promise<InstructorCurriculumResponse>;
|
||||
}
|
||||
|
||||
export async function 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(
|
||||
accessToken: string,
|
||||
input: {
|
||||
courseId?: string | null;
|
||||
groupId?: string | null;
|
||||
activeWeek: number;
|
||||
activeLearningStep?: string | null;
|
||||
applyToStudents?: boolean;
|
||||
completed?: boolean;
|
||||
tagLabel?: string;
|
||||
title?: string;
|
||||
duration?: string;
|
||||
overview?: string;
|
||||
cameraViewUrl?: string;
|
||||
deliverable?: string;
|
||||
isTeamProject?: boolean;
|
||||
topics?: string[];
|
||||
learningSteps?: Array<{
|
||||
title?: string;
|
||||
description?: string;
|
||||
videoEnabled?: boolean;
|
||||
videoUrl?: string;
|
||||
concepts?: string[];
|
||||
}>;
|
||||
topicDetails?: string[];
|
||||
topicVideoUrls?: string[];
|
||||
practiceContents?: Array<{
|
||||
title?: string;
|
||||
description?: string;
|
||||
}>;
|
||||
disabledModuleIds?: string[];
|
||||
moduleDefaultParameters?: Array<{
|
||||
moduleId: string;
|
||||
parameters: string[];
|
||||
}>;
|
||||
environmentModuleSets?: Array<{
|
||||
platformKey: string;
|
||||
moduleIds: string[];
|
||||
modules?: Array<{
|
||||
id: string;
|
||||
defaultParameters: string[];
|
||||
dockerImage?: string | null;
|
||||
}>;
|
||||
}>;
|
||||
visualizationPlatformKeys?: string[];
|
||||
mapKeys?: string[];
|
||||
},
|
||||
): Promise<InstructorCurriculumResponse> {
|
||||
const response = await fetch(`${LMS_API_BASE_URL}/instructor/curriculum`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch((): null => null);
|
||||
const message =
|
||||
payload && typeof payload === 'object' && 'message' in payload
|
||||
? String(payload.message)
|
||||
: `커리큘럼을 적용하지 못했습니다. (${response.status})`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return response.json() as Promise<InstructorCurriculumResponse>;
|
||||
}
|
||||
|
||||
export async function createInstructorCurriculumMap(
|
||||
accessToken: string,
|
||||
input: { courseId?: string | null; name: string; description?: string },
|
||||
): Promise<CurriculumMap> {
|
||||
const response = await fetch(`${LMS_API_BASE_URL}/instructor/curriculum/maps`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch((): null => null);
|
||||
throw new Error(payload && typeof payload === 'object' && 'message' in payload
|
||||
? String(payload.message)
|
||||
: `학습 맵을 등록하지 못했습니다. (${response.status})`);
|
||||
}
|
||||
return response.json() as Promise<CurriculumMap>;
|
||||
}
|
||||
|
||||
export async function deleteInstructorCurriculumWeek(
|
||||
accessToken: string,
|
||||
week: number,
|
||||
courseId?: string | null,
|
||||
): Promise<InstructorCurriculumResponse> {
|
||||
const query = courseId ? `?courseId=${encodeURIComponent(courseId)}` : '';
|
||||
const response = await fetch(`${LMS_API_BASE_URL}/instructor/curriculum/weeks/${week}${query}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch((): null => null);
|
||||
const message =
|
||||
payload && typeof payload === 'object' && 'message' in payload
|
||||
? String(payload.message)
|
||||
: `주차를 삭제하지 못했습니다. (${response.status})`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return response.json() as Promise<InstructorCurriculumResponse>;
|
||||
}
|
||||
|
||||
export async function uploadCurriculumReference(
|
||||
accessToken: string,
|
||||
input: {
|
||||
courseId?: string | null;
|
||||
week: number;
|
||||
title?: string;
|
||||
file: File;
|
||||
kind?: 'cover-image';
|
||||
},
|
||||
): Promise<InstructorCurriculumResponse> {
|
||||
const formData = new FormData();
|
||||
formData.set('file', input.file);
|
||||
formData.set('courseId', input.courseId ?? '');
|
||||
formData.set('title', input.title?.trim() || input.file.name);
|
||||
|
||||
const response = await fetch(`${LMS_API_BASE_URL}/instructor/curriculum/weeks/${input.week}/${input.kind ?? 'references'}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch((): null => null);
|
||||
const message =
|
||||
payload && typeof payload === 'object' && 'message' in payload
|
||||
? String(payload.message)
|
||||
: uploadErrorMessage(response.status, '참고자료를 업로드하지 못했습니다.');
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return response.json() as Promise<InstructorCurriculumResponse>;
|
||||
}
|
||||
|
||||
export async function uploadCurriculumExampleCode(
|
||||
accessToken: string,
|
||||
input: {
|
||||
courseId?: string | null;
|
||||
week: number;
|
||||
title?: string;
|
||||
file: File;
|
||||
},
|
||||
): Promise<InstructorCurriculumResponse> {
|
||||
const formData = new FormData();
|
||||
formData.set('file', input.file);
|
||||
formData.set('courseId', input.courseId ?? '');
|
||||
formData.set('title', input.title?.trim() || input.file.name);
|
||||
|
||||
const response = await fetch(`${LMS_API_BASE_URL}/instructor/curriculum/weeks/${input.week}/example-codes`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch((): null => null);
|
||||
const message =
|
||||
payload && typeof payload === 'object' && 'message' in payload
|
||||
? String(payload.message)
|
||||
: uploadErrorMessage(response.status, '예제 코드를 업로드하지 못했습니다.');
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return response.json() as Promise<InstructorCurriculumResponse>;
|
||||
}
|
||||
|
||||
export async function deleteCurriculumReference(
|
||||
accessToken: string,
|
||||
referenceId: string,
|
||||
courseId?: string | null,
|
||||
): Promise<InstructorCurriculumResponse> {
|
||||
const query = courseId ? `?courseId=${encodeURIComponent(courseId)}` : '';
|
||||
const response = await fetch(`${LMS_API_BASE_URL}/instructor/curriculum/references/${referenceId}${query}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch((): null => null);
|
||||
const message =
|
||||
payload && typeof payload === 'object' && 'message' in payload
|
||||
? String(payload.message)
|
||||
: `참고자료를 삭제하지 못했습니다. (${response.status})`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return response.json() as Promise<InstructorCurriculumResponse>;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
export type GroupStudentOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
export type InstructorGroup = {
|
||||
id: string;
|
||||
name: string;
|
||||
week: string;
|
||||
status: string;
|
||||
members: string[];
|
||||
membersDetailed?: GroupStudentOption[];
|
||||
instructorPlatformId?: string | null;
|
||||
platform: string;
|
||||
progress: number;
|
||||
completedMissions: number;
|
||||
activeUsers: number;
|
||||
};
|
||||
|
||||
export type InstructorGroupsSummary = {
|
||||
totalGroups: number;
|
||||
assignedPlatforms: number;
|
||||
totalStudents: number;
|
||||
averageProgress: number;
|
||||
};
|
||||
|
||||
export type InstructorGroupsResponse = {
|
||||
summary: InstructorGroupsSummary;
|
||||
groups: InstructorGroup[];
|
||||
unassignedStudents?: GroupStudentOption[];
|
||||
platformOptions?: GroupPlatformOption[];
|
||||
};
|
||||
|
||||
export type GroupPlatformOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type SaveInstructorGroupInput = {
|
||||
courseId?: string | null;
|
||||
name: string;
|
||||
instructorPlatformId?: string | null;
|
||||
platformName?: string | null;
|
||||
memberIds: string[];
|
||||
};
|
||||
|
||||
const LMS_API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? 'https://workspace.autonomystudio.net/lms-api/api').replace(/\/+$/, '');
|
||||
|
||||
export async function listInstructorGroups(accessToken: string, courseId?: string | null): Promise<InstructorGroupsResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (courseId) params.set('courseId', courseId);
|
||||
const query = params.toString() ? `?${params.toString()}` : '';
|
||||
const response = await fetch(`${LMS_API_BASE_URL}/instructor/groups${query}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`그룹 현황을 불러오지 못했습니다. (${response.status})`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<InstructorGroupsResponse>;
|
||||
}
|
||||
|
||||
export async function createInstructorGroup(
|
||||
accessToken: string,
|
||||
input: SaveInstructorGroupInput,
|
||||
): Promise<InstructorGroupsResponse> {
|
||||
const response = await fetch(`${LMS_API_BASE_URL}/instructor/groups`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(await groupErrorMessage(response, '그룹을 추가하지 못했습니다.'));
|
||||
return response.json() as Promise<InstructorGroupsResponse>;
|
||||
}
|
||||
|
||||
export async function updateInstructorGroup(
|
||||
accessToken: string,
|
||||
groupId: string,
|
||||
input: SaveInstructorGroupInput,
|
||||
): Promise<InstructorGroupsResponse> {
|
||||
const response = await fetch(`${LMS_API_BASE_URL}/instructor/groups/${groupId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(await groupErrorMessage(response, '그룹을 수정하지 못했습니다.'));
|
||||
return response.json() as Promise<InstructorGroupsResponse>;
|
||||
}
|
||||
|
||||
export async function deleteInstructorGroup(
|
||||
accessToken: string,
|
||||
groupId: string,
|
||||
courseId?: string | null,
|
||||
): Promise<InstructorGroupsResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (courseId) params.set('courseId', courseId);
|
||||
const query = params.toString() ? `?${params.toString()}` : '';
|
||||
const response = await fetch(`${LMS_API_BASE_URL}/instructor/groups/${groupId}${query}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(await groupErrorMessage(response, '그룹을 삭제하지 못했습니다.'));
|
||||
return response.json() as Promise<InstructorGroupsResponse>;
|
||||
}
|
||||
|
||||
async function groupErrorMessage(response: Response, fallback: string) {
|
||||
const payload = await response.json().catch((): null => null);
|
||||
if (payload && typeof payload === 'object' && 'message' in payload) {
|
||||
return String(payload.message);
|
||||
}
|
||||
return `${fallback} (${response.status})`;
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
export type InstructorPlatform = {
|
||||
id: string;
|
||||
masterPlatformId: string;
|
||||
name: string;
|
||||
type: string;
|
||||
status: string;
|
||||
battery: number;
|
||||
uptime: string;
|
||||
speed: string;
|
||||
errors: number;
|
||||
group: string | null;
|
||||
user: string | null;
|
||||
emergencyStop: boolean;
|
||||
vehicleType: string;
|
||||
wheelbaseMeters: number;
|
||||
maxSpeedMetersPerSecond: number;
|
||||
maxSteeringAngleDegrees: number;
|
||||
sensors: PlatformSettingItem[];
|
||||
drivers: PlatformSettingItem[];
|
||||
algorithms: PlatformAlgorithms;
|
||||
controlDurationMinutes: number;
|
||||
controlWarningMinutes: number;
|
||||
agent: InstructorPlatformAgentBinding | null;
|
||||
};
|
||||
|
||||
export type InstructorPlatformAgentBinding = {
|
||||
nodeId: string | null;
|
||||
hostname: string | null;
|
||||
ownerEmail: string | null;
|
||||
ip: string | null;
|
||||
connected: boolean;
|
||||
lastSeenAt: string | null;
|
||||
};
|
||||
|
||||
export type PlatformInUseSession = {
|
||||
id: string;
|
||||
holderUserId: string;
|
||||
holderName: string | null;
|
||||
holderEmail: string | null;
|
||||
startedAt: string;
|
||||
endsAt: string;
|
||||
};
|
||||
|
||||
export class PlatformInUseError extends Error {
|
||||
activeSession: PlatformInUseSession;
|
||||
|
||||
constructor(message: string, activeSession: PlatformInUseSession) {
|
||||
super(message);
|
||||
this.name = 'PlatformInUseError';
|
||||
this.activeSession = activeSession;
|
||||
}
|
||||
}
|
||||
|
||||
export type TeachingAgent = {
|
||||
id: string;
|
||||
hostname: string;
|
||||
name: string;
|
||||
user: string;
|
||||
tags: string[];
|
||||
ip: string;
|
||||
sshUser?: string | null;
|
||||
workspaceRoot?: string | null;
|
||||
connected: boolean;
|
||||
expired: boolean;
|
||||
lastSeenAt: string | null;
|
||||
};
|
||||
|
||||
export type PlatformSettingItem = {
|
||||
title: string;
|
||||
description: string;
|
||||
enabled?: boolean;
|
||||
installed?: boolean;
|
||||
};
|
||||
|
||||
export type PlatformAlgorithms = {
|
||||
slam: string;
|
||||
planning: string;
|
||||
control: string;
|
||||
};
|
||||
|
||||
export type MasterPlatform = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
iconKey: string;
|
||||
vehicleType: string;
|
||||
};
|
||||
|
||||
export type InstructorPlatformsSummary = {
|
||||
totalPlatforms: number;
|
||||
activePlatforms: number;
|
||||
standbyPlatforms: number;
|
||||
errorPlatforms: number;
|
||||
};
|
||||
|
||||
export type InstructorPlatformsResponse = {
|
||||
summary: InstructorPlatformsSummary;
|
||||
platforms: InstructorPlatform[];
|
||||
masterPlatforms: MasterPlatform[];
|
||||
agents: TeachingAgent[];
|
||||
};
|
||||
|
||||
export type InstructorPlatformRegistrationResponse = {
|
||||
code: string;
|
||||
expiresAt: string | null;
|
||||
};
|
||||
|
||||
const LMS_API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? 'https://workspace.autonomystudio.net/lms-api/api').replace(/\/+$/, '');
|
||||
|
||||
export async function listInstructorPlatforms(accessToken: string): Promise<InstructorPlatformsResponse> {
|
||||
const response = await fetch(`${LMS_API_BASE_URL}/instructor/platforms`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`플랫폼 현황을 불러오지 못했습니다. (${response.status})`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<InstructorPlatformsResponse>;
|
||||
}
|
||||
|
||||
export async function listInstructorPlatformAgents(accessToken: string): Promise<{ agents: TeachingAgent[] }> {
|
||||
const response = await fetch(`${LMS_API_BASE_URL}/instructor/platforms/agents`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`에이전트 목록을 불러오지 못했습니다. (${response.status})`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<{ agents: TeachingAgent[] }>;
|
||||
}
|
||||
|
||||
export async function createInstructorPlatform(
|
||||
accessToken: string,
|
||||
platformId: string,
|
||||
displayName?: string,
|
||||
agent?: TeachingAgent | null,
|
||||
ssh?: { user?: string; port?: string },
|
||||
): Promise<InstructorPlatformsResponse> {
|
||||
const response = await fetch(`${LMS_API_BASE_URL}/instructor/platforms`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
platformId,
|
||||
displayName,
|
||||
agentNodeId: agent?.id,
|
||||
agentHostname: agent?.hostname || agent?.name,
|
||||
agentOwnerEmail: agent?.user,
|
||||
sshUser: ssh?.user ?? agent?.sshUser ?? undefined,
|
||||
sshPort: ssh?.port,
|
||||
workspaceRoot: agent?.workspaceRoot ?? undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(await platformErrorMessage(response, '플랫폼을 등록하지 못했습니다.'));
|
||||
return response.json() as Promise<InstructorPlatformsResponse>;
|
||||
}
|
||||
|
||||
export async function issueInstructorPlatformRegistration(
|
||||
accessToken: string,
|
||||
): Promise<InstructorPlatformRegistrationResponse> {
|
||||
const response = await fetch(`${LMS_API_BASE_URL}/instructor/platform-registration-code`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(await platformErrorMessage(response, '등록 키를 발급하지 못했습니다.'));
|
||||
return response.json() as Promise<InstructorPlatformRegistrationResponse>;
|
||||
}
|
||||
|
||||
export async function updateInstructorPlatform(
|
||||
accessToken: string,
|
||||
platformId: string,
|
||||
displayName: string,
|
||||
vehicleType: string,
|
||||
wheelbaseMeters: number,
|
||||
maxSpeedMetersPerSecond: number,
|
||||
maxSteeringAngleDegrees: number,
|
||||
sensors: PlatformSettingItem[],
|
||||
algorithms: PlatformAlgorithms,
|
||||
controlDurationMinutes: number,
|
||||
controlWarningMinutes: number,
|
||||
): Promise<InstructorPlatformsResponse> {
|
||||
const response = await fetch(`${LMS_API_BASE_URL}/instructor/platforms/${platformId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
displayName,
|
||||
vehicleType,
|
||||
wheelbaseMeters,
|
||||
maxSpeedMetersPerSecond,
|
||||
maxSteeringAngleDegrees,
|
||||
sensors,
|
||||
algorithms,
|
||||
controlDurationMinutes,
|
||||
controlWarningMinutes,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(await platformErrorMessage(response, '플랫폼 설정을 저장하지 못했습니다.'));
|
||||
return response.json() as Promise<InstructorPlatformsResponse>;
|
||||
}
|
||||
|
||||
export async function deleteInstructorPlatform(
|
||||
accessToken: string,
|
||||
platformId: string,
|
||||
options?: { force?: boolean },
|
||||
): Promise<InstructorPlatformsResponse> {
|
||||
const query = options?.force ? '?force=true' : '';
|
||||
const response = await fetch(`${LMS_API_BASE_URL}/instructor/platforms/${platformId}${query}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch((): null => null);
|
||||
if (response.status === 409 && payload && typeof payload === 'object' && (payload as { code?: unknown }).code === 'PLATFORM_IN_USE') {
|
||||
const activeSession = (payload as { activeSession?: PlatformInUseSession }).activeSession;
|
||||
if (activeSession) {
|
||||
throw new PlatformInUseError(String((payload as { message?: unknown }).message ?? '현재 학생이 플랫폼을 사용 중입니다.'), activeSession);
|
||||
}
|
||||
}
|
||||
throw new Error(platformErrorMessageFromPayload(response, payload, '플랫폼을 삭제하지 못했습니다.'));
|
||||
}
|
||||
return response.json() as Promise<InstructorPlatformsResponse>;
|
||||
}
|
||||
|
||||
export async function assignInstructorPlatform(
|
||||
accessToken: string,
|
||||
platformId: string,
|
||||
groupId: string,
|
||||
options?: { force?: boolean },
|
||||
): Promise<InstructorPlatformsResponse> {
|
||||
const query = options?.force ? '?force=true' : '';
|
||||
const response = await fetch(`${LMS_API_BASE_URL}/instructor/platforms/${platformId}/assignment${query}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ groupId }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch((): null => null);
|
||||
if (response.status === 409 && payload && typeof payload === 'object' && (payload as { code?: unknown }).code === 'PLATFORM_IN_USE') {
|
||||
const activeSession = (payload as { activeSession?: PlatformInUseSession }).activeSession;
|
||||
if (activeSession) {
|
||||
throw new PlatformInUseError(String((payload as { message?: unknown }).message ?? '현재 학생이 플랫폼을 사용 중입니다.'), activeSession);
|
||||
}
|
||||
}
|
||||
throw new Error(platformErrorMessageFromPayload(response, payload, '플랫폼을 배정하지 못했습니다.'));
|
||||
}
|
||||
return response.json() as Promise<InstructorPlatformsResponse>;
|
||||
}
|
||||
|
||||
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) {
|
||||
const payload = await response.json().catch((): null => null);
|
||||
return platformErrorMessageFromPayload(response, payload, fallback);
|
||||
}
|
||||
|
||||
function platformErrorMessageFromPayload(response: Response, payload: unknown, fallback: string) {
|
||||
if (payload && typeof payload === 'object' && 'message' in payload) {
|
||||
return String((payload as { message?: unknown }).message);
|
||||
}
|
||||
return `${fallback} (${response.status})`;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
const PROFILE_API_BASE_URL = (import.meta.env.VITE_LAUNCHER_API_BASE_URL ?? 'https://portal-api.autonomystudio.net/api').replace(/\/+$/, '');
|
||||
const AVATAR_MAX_BYTES = 80 * 1024;
|
||||
|
||||
export async function fetchProfileAvatar(accessToken: string, version: number): Promise<Blob | null> {
|
||||
const response = await fetch(`${PROFILE_API_BASE_URL}/auth/profile/avatar?v=${version}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (response.status === 404) return null;
|
||||
if (!response.ok) throw new Error('avatar_load_failed');
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
export async function uploadProfileAvatar(accessToken: string, imageBlob: Blob): Promise<void> {
|
||||
const response = await fetch(`${PROFILE_API_BASE_URL}/auth/profile/avatar`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': imageBlob.type,
|
||||
},
|
||||
credentials: 'include',
|
||||
body: imageBlob,
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('avatar_upload_failed');
|
||||
}
|
||||
|
||||
export async function deleteProfileAvatar(accessToken: string): Promise<void> {
|
||||
const response = await fetch(`${PROFILE_API_BASE_URL}/auth/profile/avatar`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('avatar_delete_failed');
|
||||
}
|
||||
|
||||
export async function resizeImageToWebp(file: File, size = 128): Promise<Blob> {
|
||||
if (!file.type.startsWith('image/')) throw new Error('이미지 파일만 등록할 수 있습니다.');
|
||||
|
||||
const dataUrl = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result));
|
||||
reader.onerror = () => reject(new Error('이미지를 읽을 수 없습니다.'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error('이미지를 불러올 수 없습니다.'));
|
||||
img.src = dataUrl;
|
||||
});
|
||||
|
||||
const sourceSize = Math.min(image.naturalWidth, image.naturalHeight);
|
||||
const sourceX = Math.max(0, (image.naturalWidth - sourceSize) / 2);
|
||||
const sourceY = Math.max(0, (image.naturalHeight - sourceSize) / 2);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) throw new Error('이미지를 처리할 수 없습니다.');
|
||||
|
||||
context.drawImage(image, sourceX, sourceY, sourceSize, sourceSize, 0, 0, size, size);
|
||||
|
||||
const blob = await new Promise<Blob | null>((resolve) => {
|
||||
canvas.toBlob(resolve, 'image/webp', 0.82);
|
||||
});
|
||||
|
||||
if (!blob) throw new Error('이미지를 압축할 수 없습니다.');
|
||||
if (blob.size > AVATAR_MAX_BYTES) throw new Error('이미지 용량이 너무 큽니다.');
|
||||
return blob;
|
||||
}
|
||||
|
||||
export function notifyProfileAvatarUpdated(): void {
|
||||
window.dispatchEvent(new Event('profile-avatar-updated'));
|
||||
}
|
||||
|
||||
export function notifyProfileAvatarDeleted(): void {
|
||||
window.dispatchEvent(new Event('profile-avatar-deleted'));
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
export type InstructorStudentIssue = {
|
||||
type: 'MISSING_SUBMISSION' | 'COMPLETED_WITHOUT_SUBMISSION' | 'LOW_AVERAGE_SCORE';
|
||||
title: string;
|
||||
detail: string;
|
||||
week?: number;
|
||||
};
|
||||
|
||||
export type InstructorStudent = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
group: string;
|
||||
week: string;
|
||||
progress: number;
|
||||
commits: number;
|
||||
issues: number;
|
||||
issueItems: InstructorStudentIssue[];
|
||||
lastActive: string;
|
||||
online: boolean;
|
||||
courseTitle?: string;
|
||||
};
|
||||
|
||||
export type InstructorStudentsSummary = {
|
||||
totalStudents: number;
|
||||
onlineStudents: number;
|
||||
averageProgress: number;
|
||||
unresolvedIssues: number;
|
||||
};
|
||||
|
||||
export type InstructorStudentsResponse = {
|
||||
organization: {
|
||||
id?: string;
|
||||
name: string;
|
||||
} | null;
|
||||
summary: InstructorStudentsSummary;
|
||||
students: InstructorStudent[];
|
||||
};
|
||||
|
||||
export type CreateInstructorStudentInput = {
|
||||
courseId?: string | null;
|
||||
email: string;
|
||||
name: string;
|
||||
groupId?: string | null;
|
||||
};
|
||||
|
||||
const LMS_API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? 'https://workspace.autonomystudio.net/lms-api/api').replace(/\/+$/, '');
|
||||
|
||||
export async function listInstructorStudents(accessToken: string, courseId?: string | null): Promise<InstructorStudentsResponse> {
|
||||
const query = courseId ? `?courseId=${encodeURIComponent(courseId)}` : '';
|
||||
const response = await fetch(`${LMS_API_BASE_URL}/instructor/students${query}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`수강생 현황을 불러오지 못했습니다. (${response.status})`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<InstructorStudentsResponse>;
|
||||
}
|
||||
|
||||
export async function createInstructorStudent(
|
||||
accessToken: string,
|
||||
input: CreateInstructorStudentInput,
|
||||
): Promise<InstructorStudentsResponse> {
|
||||
const response = await fetch(`${LMS_API_BASE_URL}/instructor/students`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch((): null => null);
|
||||
const message =
|
||||
payload && typeof payload === 'object' && 'message' in payload
|
||||
? String(payload.message)
|
||||
: `수강생을 등록하지 못했습니다. (${response.status})`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return response.json() as Promise<InstructorStudentsResponse>;
|
||||
}
|
||||
|
||||
export async function removeInstructorStudent(
|
||||
accessToken: string,
|
||||
studentId: string,
|
||||
): Promise<InstructorStudentsResponse> {
|
||||
const response = await fetch(`${LMS_API_BASE_URL}/instructor/students/${studentId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch((): null => null);
|
||||
const message =
|
||||
payload && typeof payload === 'object' && 'message' in payload
|
||||
? String(payload.message)
|
||||
: `수강생을 제외하지 못했습니다. (${response.status})`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return response.json() as Promise<InstructorStudentsResponse>;
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
export type InstructorSubmissionCommitFile = {
|
||||
path: string;
|
||||
status: string;
|
||||
patch: string;
|
||||
originalRef?: string | null;
|
||||
modifiedRef?: string | null;
|
||||
};
|
||||
|
||||
export type InstructorSubmissionCommit = {
|
||||
hash: string;
|
||||
shortHash: string;
|
||||
message: string;
|
||||
author: string;
|
||||
authorEmail: string;
|
||||
date: string | null;
|
||||
files: InstructorSubmissionCommitFile[];
|
||||
};
|
||||
|
||||
export type InstructorSubmissionItem = {
|
||||
id: string;
|
||||
status: 'NOT_INSTALLED' | 'PENDING' | 'SUBMITTED';
|
||||
submitted: boolean;
|
||||
submittedAt: string | null;
|
||||
commitCount: number;
|
||||
score: number;
|
||||
course: {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
};
|
||||
week: {
|
||||
id: string;
|
||||
week: number;
|
||||
title: string;
|
||||
isTeamProject: boolean;
|
||||
};
|
||||
module: {
|
||||
id: string;
|
||||
name: string;
|
||||
folderName: string;
|
||||
sortOrder: number;
|
||||
sourceType?: 'CUSTOM' | 'GIT';
|
||||
};
|
||||
student: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
group: string;
|
||||
};
|
||||
team?: {
|
||||
id: string;
|
||||
name: string;
|
||||
members: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
commitCount: number;
|
||||
lastCommittedAt: string | null;
|
||||
}>;
|
||||
} | null;
|
||||
repository: {
|
||||
id: string;
|
||||
owner: string;
|
||||
name: string;
|
||||
url: string;
|
||||
} | null;
|
||||
commits: InstructorSubmissionCommit[];
|
||||
finalFiles: InstructorSubmissionCommitFile[];
|
||||
};
|
||||
|
||||
export type InstructorSubmissionsResponse = {
|
||||
organization: {
|
||||
id?: string;
|
||||
name: string;
|
||||
} | null;
|
||||
summary: {
|
||||
totalItems: number;
|
||||
submittedItems: number;
|
||||
pendingItems: number;
|
||||
notInstalledItems: number;
|
||||
totalCommits: number;
|
||||
};
|
||||
courses: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
weeks: Array<{
|
||||
id: string;
|
||||
week: number;
|
||||
title: string;
|
||||
isTeamProject: boolean;
|
||||
modules: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
folderName: string;
|
||||
sortOrder: number;
|
||||
}>;
|
||||
}>;
|
||||
}>;
|
||||
students: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
group: string;
|
||||
}>;
|
||||
items: InstructorSubmissionItem[];
|
||||
teamItems: InstructorSubmissionItem[];
|
||||
};
|
||||
|
||||
const LMS_API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? 'https://workspace.autonomystudio.net/lms-api/api').replace(/\/+$/, '');
|
||||
|
||||
export async function listInstructorSubmissions(accessToken: string): Promise<InstructorSubmissionsResponse> {
|
||||
const response = await fetch(`${LMS_API_BASE_URL}/instructor/submissions`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`제출 현황을 불러오지 못했습니다. (${response.status})`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<InstructorSubmissionsResponse>;
|
||||
}
|
||||
|
||||
export async function getInstructorSubmissionFile(
|
||||
accessToken: string,
|
||||
input: {
|
||||
owner: string;
|
||||
repoName: string;
|
||||
path: string;
|
||||
baseRef?: string | null;
|
||||
headRef?: string | null;
|
||||
},
|
||||
): Promise<{
|
||||
original: string;
|
||||
modified: string;
|
||||
originalAvailable: boolean;
|
||||
modifiedAvailable: boolean;
|
||||
}> {
|
||||
const query = new URLSearchParams({
|
||||
owner: input.owner,
|
||||
repo: input.repoName,
|
||||
path: input.path,
|
||||
});
|
||||
if (input.baseRef) query.set('baseRef', input.baseRef);
|
||||
if (input.headRef) query.set('headRef', input.headRef);
|
||||
|
||||
const response = await fetch(`${LMS_API_BASE_URL}/instructor/submissions/file?${query.toString()}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`파일 내용을 불러오지 못했습니다. (${response.status})`);
|
||||
}
|
||||
return response.json() as Promise<{
|
||||
original: string;
|
||||
modified: string;
|
||||
originalAvailable: boolean;
|
||||
modifiedAvailable: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function saveInstructorSubmissionScore(
|
||||
accessToken: string,
|
||||
input: { studentId: string; weekId: string; score: number },
|
||||
): Promise<{ score: number }> {
|
||||
const response = await fetch(`${LMS_API_BASE_URL}/instructor/submissions/score`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`점수를 저장하지 못했습니다. (${response.status})`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<{ score: number }>;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
const routerBasename = import.meta.env.BASE_URL.replace(/\/+$/, '') || '/';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter basename={routerBasename}>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { AppRoute } from './routes';
|
||||
|
||||
type PageMeta = {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export const pageMeta: Record<AppRoute, PageMeta> = {
|
||||
dashboard: {
|
||||
title: '대시보드',
|
||||
description: '실시간 모니터링 및 통합 관리 시스템'
|
||||
},
|
||||
curriculum: {
|
||||
title: '커리큘럼 관리',
|
||||
description: '강사별 진행 주차 및 학습단계 적용'
|
||||
},
|
||||
groups: {
|
||||
title: '그룹 관리',
|
||||
description: '수강생 그룹 및 플랫폼 할당'
|
||||
},
|
||||
platforms: {
|
||||
title: '플랫폼 관리',
|
||||
description: '자율주행 하드웨어 및 시뮬레이션 상태 조회'
|
||||
},
|
||||
students: {
|
||||
title: '수강생 현황',
|
||||
description: '개별 학습 진행 상황'
|
||||
},
|
||||
submissions: {
|
||||
title: '제출 현황',
|
||||
description: '강좌별 수강생 제출 여부와 변경 파일 조회'
|
||||
},
|
||||
analytics: {
|
||||
title: '통계 분석',
|
||||
description: '그룹별 진도와 플랫폼 사용 데이터를 분석합니다.'
|
||||
},
|
||||
settings: {
|
||||
title: '설정',
|
||||
description: '강좌 및 서비스 환경 설정'
|
||||
},
|
||||
profile: {
|
||||
title: '프로필',
|
||||
description: '강사 계정과 소속 정보를 확인합니다.'
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { BarChart3 } from 'lucide-react';
|
||||
import { PlaceholderPage } from '../common/PlaceholderPage';
|
||||
|
||||
export function AnalyticsPage() {
|
||||
return <PlaceholderPage icon={BarChart3} route="analytics" items={['평균 진도', '플랫폼 사용률', '에스컬레이션 추이']} />;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { PageHeader } from '../../components/PageHeader';
|
||||
import { pageMeta } from '../../pageMeta';
|
||||
import type { AppRoute } from '../../routes';
|
||||
|
||||
type PlaceholderPageProps = {
|
||||
icon: LucideIcon;
|
||||
route: AppRoute;
|
||||
items: string[];
|
||||
};
|
||||
|
||||
export function PlaceholderPage({ icon: Icon, route, items }: PlaceholderPageProps) {
|
||||
const title = pageMeta[route].title;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[1220px]">
|
||||
<PageHeader route={route} />
|
||||
|
||||
<section className="rounded-lg border border-[#e5e7eb] bg-white p-5">
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<div className="grid h-10 w-10 place-items-center rounded-lg bg-[#eef2ff] text-[#4f46e5]">
|
||||
<Icon size={18} aria-hidden="true" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-[15px] font-black">{title} 준비 영역</h2>
|
||||
<p className="mt-1 text-[12px] font-bold text-[#64748b]">메뉴별 라우팅과 페이지 경계가 분리되어 있습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
{items.map((item) => (
|
||||
<article className="rounded-lg border border-[#e5e7eb] bg-[#fbfcfe] p-4" key={item}>
|
||||
<p className="text-[13px] font-black text-[#111827]">{item}</p>
|
||||
<p className="mt-2 text-[12px] font-bold leading-5 text-[#64748b]">후속 API와 상세 UI를 이 페이지 폴더 안에서 확장합니다.</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,348 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Activity, BarChart3, Car, ChevronRight, CircleAlert, Loader2, Users, Wifi, type LucideIcon } from 'lucide-react';
|
||||
import { PageHeader } from '../../components/PageHeader';
|
||||
import { useToast } from '../../components/ToastProvider';
|
||||
import { listInstructorChatThreads, type InstructorChatThread } from '../../lib/chat';
|
||||
import { listInstructorGroups, type InstructorGroup, type InstructorGroupsSummary } from '../../lib/groups';
|
||||
import { listInstructorPlatforms, type InstructorPlatform, type InstructorPlatformsSummary } from '../../lib/platforms';
|
||||
import type { AppRoute } from '../../routes';
|
||||
import type { AuthSession } from '../../types/auth';
|
||||
|
||||
const emptyPlatformSummary: InstructorPlatformsSummary = {
|
||||
totalPlatforms: 0,
|
||||
activePlatforms: 0,
|
||||
standbyPlatforms: 0,
|
||||
errorPlatforms: 0,
|
||||
};
|
||||
|
||||
const emptyGroupSummary: InstructorGroupsSummary = {
|
||||
totalGroups: 0,
|
||||
assignedPlatforms: 0,
|
||||
totalStudents: 0,
|
||||
averageProgress: 0,
|
||||
};
|
||||
|
||||
export function DashboardPage({ session, selectedCourseId, onNavigate, onOpenChatThread }: { session: AuthSession; selectedCourseId: string; onNavigate?: (route: AppRoute) => void; onOpenChatThread?: (threadId: string) => void }) {
|
||||
const { showToast } = useToast();
|
||||
const [platforms, setPlatforms] = useState<InstructorPlatform[]>([]);
|
||||
const [platformSummary, setPlatformSummary] = useState<InstructorPlatformsSummary>(emptyPlatformSummary);
|
||||
const [groups, setGroups] = useState<InstructorGroup[]>([]);
|
||||
const [groupSummary, setGroupSummary] = useState<InstructorGroupsSummary>(emptyGroupSummary);
|
||||
const [threads, setThreads] = useState<InstructorChatThread[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
Promise.allSettled([
|
||||
listInstructorPlatforms(session.accessToken),
|
||||
listInstructorGroups(session.accessToken, selectedCourseId || null),
|
||||
listInstructorChatThreads(session.accessToken, selectedCourseId),
|
||||
])
|
||||
.then(([platformResult, groupResult, chatResult]) => {
|
||||
if (cancelled) return;
|
||||
|
||||
if (platformResult.status === 'fulfilled') {
|
||||
setPlatforms(platformResult.value.platforms);
|
||||
setPlatformSummary(platformResult.value.summary);
|
||||
} else {
|
||||
setPlatforms([]);
|
||||
setPlatformSummary(emptyPlatformSummary);
|
||||
}
|
||||
|
||||
if (groupResult.status === 'fulfilled') {
|
||||
setGroups(groupResult.value.groups);
|
||||
setGroupSummary(groupResult.value.summary);
|
||||
} else {
|
||||
setGroups([]);
|
||||
setGroupSummary(emptyGroupSummary);
|
||||
}
|
||||
|
||||
if (chatResult.status === 'fulfilled') {
|
||||
setThreads(chatResult.value);
|
||||
} else {
|
||||
setThreads([]);
|
||||
}
|
||||
|
||||
const failed = [platformResult, groupResult].filter((result) => result.status === 'rejected');
|
||||
setError(failed.length > 0 ? '대시보드 일부 데이터를 불러오지 못했습니다.' : null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [session.accessToken, selectedCourseId]);
|
||||
|
||||
const waitingThreads = useMemo(() => threads.filter((thread) => thread.status === 'WAITING_INSTRUCTOR'), [threads]);
|
||||
const activeUsers = useMemo(() => {
|
||||
const groupActiveUsers = groups.reduce((sum, group) => sum + group.activeUsers, 0);
|
||||
if (groupActiveUsers > 0) return groupActiveUsers;
|
||||
return platforms.filter((platform) => platform.user).length;
|
||||
}, [groups, platforms]);
|
||||
|
||||
const metrics = [
|
||||
{ label: '활성 플랫폼', value: `${platformSummary.activePlatforms}/${platformSummary.totalPlatforms}`, icon: Activity, tone: 'bg-[#f0efff] text-[#4f46e5]' },
|
||||
{ label: '총 수강생', value: `${groupSummary.totalStudents}명`, icon: Users, tone: 'bg-[#eff6ff] text-[#2563eb]' },
|
||||
{ label: '현재 접속', value: `${activeUsers}명`, icon: Wifi, tone: 'bg-[#f8fafc] text-[#64748b]' },
|
||||
{ label: '평균 진도', value: `${groupSummary.averageProgress}%`, icon: BarChart3, tone: 'bg-[#fff7ed] text-[#f97316]' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[1220px]">
|
||||
<PageHeader route="dashboard" />
|
||||
|
||||
{error ? <p className="mb-4 rounded-md border border-[#fecdd3] bg-[#fff1f2] px-4 py-3 text-[13px] font-bold text-[#e11d48]">{error}</p> : null}
|
||||
|
||||
<section className="mb-4 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
{metrics.map((metric) => (
|
||||
<MetricCard key={metric.label} {...metric} loading={loading} />
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||
<div className="rounded-lg border border-[#c7d2e0] bg-white">
|
||||
<div className="flex items-start justify-between gap-3 border-b border-[#cbd5e1] px-4 py-4">
|
||||
<div>
|
||||
<h2 className="text-[15px] font-black text-[#111827]">플랫폼 실시간 상태</h2>
|
||||
<p className="mt-1 text-[12px] font-bold text-[#64748b]">하드웨어 모니터링</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-8 w-8 shrink-0 place-items-center rounded-md border border-[#dbe3ef] bg-white text-[#64748b] transition hover:border-[#bfdbfe] hover:bg-[#eff6ff] hover:text-[#2563eb]"
|
||||
onClick={() => onNavigate?.('platforms')}
|
||||
aria-label="플랫폼 관리로 이동"
|
||||
title="플랫폼 관리로 이동"
|
||||
>
|
||||
<ChevronRight size={17} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[490px] space-y-3 overflow-y-auto p-4">
|
||||
{loading ? (
|
||||
<LoadingState message="플랫폼 상태를 불러오는 중입니다." />
|
||||
) : platforms.length > 0 ? (
|
||||
platforms.map((platform) => (
|
||||
<PlatformStatusCard
|
||||
key={platform.id}
|
||||
platform={platform}
|
||||
onEmergencyStop={() => {
|
||||
showToast({
|
||||
title: '긴급 정지 요청이 전송되었습니다.',
|
||||
message: platform.name,
|
||||
variant: 'error',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<EmptyState message="등록된 플랫폼이 없습니다." />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-[#c7d2e0] bg-white">
|
||||
<div className="flex items-start justify-between gap-3 border-b border-[#cbd5e1] px-4 py-4">
|
||||
<div>
|
||||
<h2 className="text-[15px] font-black text-[#111827]">그룹 진행 요약</h2>
|
||||
<p className="mt-1 text-[12px] font-bold text-[#64748b]">그룹 구성, 플랫폼 배정, 진행률 조회</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-8 w-8 shrink-0 place-items-center rounded-md border border-[#dbe3ef] bg-white text-[#64748b] transition hover:border-[#bfdbfe] hover:bg-[#eff6ff] hover:text-[#2563eb]"
|
||||
onClick={() => onNavigate?.('groups')}
|
||||
aria-label="그룹 관리로 이동"
|
||||
title="그룹 관리로 이동"
|
||||
>
|
||||
<ChevronRight size={17} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[490px] space-y-3 overflow-y-auto p-4">
|
||||
{loading ? (
|
||||
<LoadingState message="그룹 진행 정보를 불러오는 중입니다." />
|
||||
) : groups.length > 0 ? (
|
||||
groups.map((group) => (
|
||||
<GroupSummaryCard key={group.id} group={group} />
|
||||
))
|
||||
) : (
|
||||
<EmptyState message="등록된 그룹이 없습니다." />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-4 rounded-lg border border-[#c7d2e0] bg-white">
|
||||
<div className="flex items-start justify-between gap-4 border-b border-[#cbd5e1] px-4 py-4">
|
||||
<div>
|
||||
<h2 className="text-[15px] font-black text-[#111827]">질의응답</h2>
|
||||
<p className="mt-1 text-[12px] font-bold text-[#64748b]">강사 지원이 필요한 질문</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-[#fff1f2] px-2.5 py-1 text-[13px] font-bold text-[#e11d48]">{waitingThreads.length} 건</span>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-[#e2e8f0]">
|
||||
{loading ? (
|
||||
<LoadingState message="문의 목록을 불러오는 중입니다." />
|
||||
) : waitingThreads.length > 0 ? (
|
||||
waitingThreads.slice(0, 5).map((thread) => (
|
||||
<EscalationRow key={thread.id} thread={thread} onRespond={() => onOpenChatThread?.(thread.id)} />
|
||||
))
|
||||
) : (
|
||||
<EmptyState message="강사 지원이 필요한 질문이 없습니다." />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlatformStatusCard({ platform, onEmergencyStop }: { platform: InstructorPlatform; onEmergencyStop: () => void }) {
|
||||
return (
|
||||
<article className="flex min-h-[72px] items-center gap-3 rounded-lg border border-[#c7d2e0] bg-white px-3 py-3">
|
||||
<div className={`grid h-10 w-10 shrink-0 place-items-center rounded-md ${platform.status === '오류' ? 'bg-[#fff1f2] text-[#e11d48]' : 'bg-[#f0efff] text-[#4f46e5]'}`}>
|
||||
<Car size={18} aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="truncate text-[14px] font-bold text-[#111827]">{platform.name}</h3>
|
||||
<span className={`inline-flex h-6 items-center rounded-full border border-current/20 px-2 text-[11px] font-bold ${statusTone(platform.status)}`}>{platform.status}</span>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-[12px] font-bold text-[#64748b]">
|
||||
{platform.user ? `사용자: ${platform.user} 배터리: ${platform.battery}% 속도: ${platform.speed}` : `배터리: ${platform.battery}%`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{platform.emergencyStop ? (
|
||||
<button className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md bg-[#e11d48] px-3 text-[12px] font-bold text-white hover:bg-[#be123c]" type="button" onClick={onEmergencyStop}>
|
||||
<CircleAlert size={14} aria-hidden="true" />
|
||||
긴급 정지
|
||||
</button>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupSummaryCard({ group }: { group: InstructorGroup }) {
|
||||
return (
|
||||
<article className="rounded-lg border border-[#c7d2e0] bg-white px-4 py-3">
|
||||
<div className="mb-3 flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-[14px] font-black text-[#111827]">{group.name}</h3>
|
||||
<span className="inline-flex h-6 items-center rounded-full border border-[#c7d2e0] bg-[#f8fafc] px-2 text-[11px] font-bold text-[#64748b]">{group.status}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[12px] font-bold text-[#64748b]">{group.members.length}명 | {group.week}</p>
|
||||
</div>
|
||||
<span className={`inline-flex h-7 shrink-0 items-center rounded-full border px-2.5 text-[11px] font-black ${group.platform === '미배정' ? 'border-[#d1d9e6] bg-[#f8fafc] text-[#94a3b8]' : 'border-[#c7d2fe] bg-[#eef2ff] text-[#4f46e5]'}`}>{group.platform}</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 flex items-center justify-between text-[12px] font-bold text-[#475569]">
|
||||
<span>진행률</span>
|
||||
<span className="text-[#4f46e5]">{group.progress}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-[#dbe3ef]">
|
||||
<div className="h-full rounded-full bg-[#4f46e5]" style={{ width: `${group.progress}%` }} />
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center gap-4 text-[11px] font-bold text-[#94a3b8]">
|
||||
<span>
|
||||
완료 <strong className="text-[#64748b]">{group.completedMissions}</strong>
|
||||
</span>
|
||||
<span>
|
||||
활동 <strong className={group.activeUsers > 0 ? 'text-[#4f46e5]' : 'text-[#94a3b8]'}>{group.activeUsers}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function EscalationRow({ thread, onRespond }: { thread: InstructorChatThread; onRespond?: () => void }) {
|
||||
return (
|
||||
<article className="grid gap-3 px-4 py-4 lg:grid-cols-[minmax(150px,180px)_minmax(0,1fr)_auto] lg:items-center">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-md bg-[#fff1f2] text-[#e11d48]">
|
||||
<CircleAlert size={16} aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="truncate text-[14px] font-bold text-[#111827]">{thread.studentName}</h3>
|
||||
<span className="rounded border border-[#d1d9e6] px-1.5 py-0.5 text-[11px] font-bold text-[#475569]">{thread.groupName ?? (thread.week ? `${thread.week}주차` : '미배정')}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[12px] font-medium text-[#94a3b8]">{formatRelativeTime(thread.lastMessageAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="min-w-0 text-[13px] font-semibold leading-6 text-[#111827]">{thread.lastMessage}</p>
|
||||
|
||||
<button
|
||||
className="h-8 cursor-pointer justify-self-start rounded-md border border-[#c7d2fe] bg-[#eef2ff] px-3 text-[12px] font-bold text-[#4f46e5] hover:bg-[#e0e7ff] lg:justify-self-end"
|
||||
type="button"
|
||||
onClick={onRespond}
|
||||
>
|
||||
응답하기
|
||||
</button>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({ label, value, icon: Icon, tone, loading = false }: { label: string; value: string; icon: LucideIcon; tone: string; loading?: boolean }) {
|
||||
return (
|
||||
<article className="rounded-lg border border-[#c7d2e0] bg-white px-5 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-[12px] font-semibold text-[#64748b]">{label}</p>
|
||||
<p className="mt-2 text-[20px] font-black leading-none text-[#111827]">{loading ? '-' : value}</p>
|
||||
</div>
|
||||
<span className={`grid h-11 w-11 shrink-0 place-items-center rounded-md ${tone}`}>
|
||||
<Icon size={28} aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingState({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="flex h-[180px] items-center justify-center text-[#64748b]">
|
||||
<Loader2 className="mr-2 animate-spin" size={18} aria-hidden="true" />
|
||||
<span className="text-[13px] font-bold">{message}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="flex h-[180px] items-center justify-center px-4 text-center">
|
||||
<p className="text-[13px] font-bold text-[#64748b]">{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function statusTone(status: string) {
|
||||
if (status === '활성') return 'bg-[#f0efff] text-[#4f46e5]';
|
||||
if (status === '오류') return 'bg-[#fff1f2] text-[#e11d48]';
|
||||
return 'bg-[#f1f5f9] text-[#64748b]';
|
||||
}
|
||||
|
||||
function formatRelativeTime(value: string | undefined) {
|
||||
if (!value) return '-';
|
||||
const time = new Date(value).getTime();
|
||||
if (Number.isNaN(time)) return '-';
|
||||
|
||||
const diffMinutes = Math.max(0, Math.floor((Date.now() - time) / 60000));
|
||||
if (diffMinutes < 1) return '방금 전';
|
||||
if (diffMinutes < 60) return `${diffMinutes}분 전`;
|
||||
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
if (diffHours < 24) return `${diffHours}시간 전`;
|
||||
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
return `${diffDays}일 전`;
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Activity, BarChart3, Car, Edit3, Loader2, Plus, Search, Trash2, Users, X, type LucideIcon } from 'lucide-react';
|
||||
import { PageHeader } from '../../components/PageHeader';
|
||||
import { PaginationBar } from '../../components/PaginationBar';
|
||||
import { useToast } from '../../components/ToastProvider';
|
||||
import {
|
||||
createInstructorGroup,
|
||||
deleteInstructorGroup,
|
||||
listInstructorGroups,
|
||||
updateInstructorGroup,
|
||||
type GroupStudentOption,
|
||||
type GroupPlatformOption,
|
||||
type InstructorGroup,
|
||||
type InstructorGroupsResponse,
|
||||
type InstructorGroupsSummary,
|
||||
} from '../../lib/groups';
|
||||
import type { AuthSession } from '../../types/auth';
|
||||
|
||||
const emptySummary: InstructorGroupsSummary = {
|
||||
totalGroups: 0,
|
||||
assignedPlatforms: 0,
|
||||
totalStudents: 0,
|
||||
averageProgress: 0,
|
||||
};
|
||||
|
||||
export function GroupsPage({ session, selectedCourseId }: { session: AuthSession; selectedCourseId: string }) {
|
||||
const { showToast } = useToast();
|
||||
const [groups, setGroups] = useState<InstructorGroup[]>([]);
|
||||
const [summary, setSummary] = useState<InstructorGroupsSummary>(emptySummary);
|
||||
const [unassignedStudents, setUnassignedStudents] = useState<GroupStudentOption[]>([]);
|
||||
const [platformOptions, setPlatformOptions] = useState<GroupPlatformOption[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [modalMode, setModalMode] = useState<'create' | 'edit' | null>(null);
|
||||
const [editingGroup, setEditingGroup] = useState<InstructorGroup | null>(null);
|
||||
const [groupName, setGroupName] = useState('');
|
||||
const [selectedPlatform, setSelectedPlatform] = useState('unassigned');
|
||||
const [selectedMemberIds, setSelectedMemberIds] = useState<string[]>([]);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<InstructorGroup | null>(null);
|
||||
const [deletingGroupId, setDeletingGroupId] = useState<string | null>(null);
|
||||
const filteredGroups = useMemo(() => {
|
||||
const keyword = query.trim().toLowerCase();
|
||||
if (!keyword) return groups;
|
||||
return groups.filter((group) =>
|
||||
[group.name, group.platform, ...group.members]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(keyword),
|
||||
);
|
||||
}, [groups, query]);
|
||||
const pagedGroups = useMemo(() => filteredGroups.slice((page - 1) * pageSize, page * pageSize), [filteredGroups, page, pageSize]);
|
||||
const summaryItems = [
|
||||
{ label: '전체 그룹', value: `${summary.totalGroups}개`, icon: Users, tone: 'bg-[#f0efff] text-[#4f46e5]' },
|
||||
{ label: '배정 플랫폼', value: `${summary.assignedPlatforms}대`, icon: Car, tone: 'bg-[#eff6ff] text-[#2563eb]' },
|
||||
{ label: '수강생', value: `${summary.totalStudents}명`, icon: Activity, tone: 'bg-[#fff7ed] text-[#f97316]' },
|
||||
{ label: '평균 진도', value: `${summary.averageProgress}%`, icon: BarChart3, tone: 'bg-[#f8fafc] text-[#64748b]' }
|
||||
];
|
||||
|
||||
const applyGroupsPayload = (payload: InstructorGroupsResponse) => {
|
||||
setError(null);
|
||||
setGroups(payload.groups);
|
||||
setSummary(payload.summary);
|
||||
setUnassignedStudents(payload.unassignedStudents ?? []);
|
||||
setPlatformOptions(payload.platformOptions ?? []);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
listInstructorGroups(session.accessToken, selectedCourseId || null)
|
||||
.then((payload) => {
|
||||
if (cancelled) return;
|
||||
applyGroupsPayload(payload);
|
||||
})
|
||||
.catch((reason: Error) => {
|
||||
if (cancelled) return;
|
||||
setGroups([]);
|
||||
setSummary(emptySummary);
|
||||
setError(reason.message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [session.accessToken, selectedCourseId]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
const totalPages = Math.max(1, Math.ceil(filteredGroups.length / pageSize));
|
||||
if (page > totalPages) setPage(totalPages);
|
||||
}, [filteredGroups.length, page, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!modalMode && !deleteTarget) return;
|
||||
|
||||
const originalOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = originalOverflow;
|
||||
};
|
||||
}, [deleteTarget, modalMode]);
|
||||
|
||||
const handlePageSizeChange = (nextPageSize: number) => {
|
||||
setPageSize(nextPageSize);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const openCreateModal = () => {
|
||||
setModalMode('create');
|
||||
setEditingGroup(null);
|
||||
setGroupName('');
|
||||
setSelectedPlatform('unassigned');
|
||||
setSelectedMemberIds([]);
|
||||
setFormError(null);
|
||||
};
|
||||
|
||||
const openEditModal = (group: InstructorGroup) => {
|
||||
setModalMode('edit');
|
||||
setEditingGroup(group);
|
||||
setGroupName(group.name);
|
||||
setSelectedPlatform(group.instructorPlatformId ?? 'unassigned');
|
||||
setSelectedMemberIds((group.membersDetailed ?? []).map((member) => member.id));
|
||||
setFormError(null);
|
||||
};
|
||||
|
||||
const closeGroupModal = () => {
|
||||
if (saving) return;
|
||||
setModalMode(null);
|
||||
setEditingGroup(null);
|
||||
};
|
||||
|
||||
const availableStudents = useMemo(() => {
|
||||
const currentMembers = editingGroup?.membersDetailed ?? [];
|
||||
const byId = new Map<string, GroupStudentOption>();
|
||||
[...currentMembers, ...unassignedStudents].forEach((student) => byId.set(student.id, student));
|
||||
return [...byId.values()].sort((left, right) => left.name.localeCompare(right.name, 'ko'));
|
||||
}, [editingGroup, unassignedStudents]);
|
||||
|
||||
const availablePlatforms = useMemo(() => {
|
||||
const byId = new Map<string, GroupPlatformOption>();
|
||||
if (editingGroup?.instructorPlatformId && editingGroup.platform !== '미배정') {
|
||||
byId.set(editingGroup.instructorPlatformId, {
|
||||
id: editingGroup.instructorPlatformId,
|
||||
name: editingGroup.platform,
|
||||
});
|
||||
}
|
||||
platformOptions.forEach((platform) => byId.set(platform.id, platform));
|
||||
return [...byId.values()].sort((left, right) => left.name.localeCompare(right.name, 'ko'));
|
||||
}, [editingGroup, platformOptions]);
|
||||
|
||||
const toggleMember = (studentId: string) => {
|
||||
setSelectedMemberIds((current) =>
|
||||
current.includes(studentId)
|
||||
? current.filter((id) => id !== studentId)
|
||||
: [...current, studentId],
|
||||
);
|
||||
};
|
||||
|
||||
const handleSaveGroup = async () => {
|
||||
const name = groupName.trim();
|
||||
if (!name) {
|
||||
setFormError('그룹명을 입력해 주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setFormError(null);
|
||||
try {
|
||||
const input = {
|
||||
courseId: selectedCourseId || null,
|
||||
name,
|
||||
instructorPlatformId: selectedPlatform === 'unassigned' ? null : selectedPlatform,
|
||||
platformName: availablePlatforms.find((platform) => platform.id === selectedPlatform)?.name ?? null,
|
||||
memberIds: selectedMemberIds,
|
||||
};
|
||||
const payload = modalMode === 'edit' && editingGroup
|
||||
? await updateInstructorGroup(session.accessToken, editingGroup.id, input)
|
||||
: await createInstructorGroup(session.accessToken, input);
|
||||
applyGroupsPayload(payload);
|
||||
showToast({
|
||||
title: modalMode === 'edit' ? '그룹이 수정되었습니다.' : '그룹이 추가되었습니다.',
|
||||
message: name,
|
||||
});
|
||||
setModalMode(null);
|
||||
setEditingGroup(null);
|
||||
setPage(1);
|
||||
} catch (reason) {
|
||||
setFormError(reason instanceof Error ? reason.message : '그룹을 저장하지 못했습니다.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteGroup = async (group: InstructorGroup) => {
|
||||
setDeletingGroupId(group.id);
|
||||
setError(null);
|
||||
try {
|
||||
const payload = await deleteInstructorGroup(session.accessToken, group.id, selectedCourseId || null);
|
||||
applyGroupsPayload(payload);
|
||||
showToast({
|
||||
title: '그룹이 삭제되었습니다.',
|
||||
message: group.name,
|
||||
});
|
||||
setDeleteTarget(null);
|
||||
setPage(1);
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : '그룹을 삭제하지 못했습니다.');
|
||||
} finally {
|
||||
setDeletingGroupId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[1220px]">
|
||||
<PageHeader
|
||||
route="groups"
|
||||
action={
|
||||
<button className="inline-flex h-9 items-center gap-2 rounded-md bg-[#2563eb] px-3 text-[13px] font-bold text-white hover:bg-[#1d4ed8]" type="button" onClick={openCreateModal}>
|
||||
<Plus size={15} aria-hidden="true" />
|
||||
그룹 추가
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
<section className="mb-4 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
{summaryItems.map((item) => (
|
||||
<SummaryCard key={item.label} {...item} />
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-[#c7d2e0] bg-white">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[#cbd5e1] px-4 py-3">
|
||||
<div>
|
||||
<h2 className="text-[15px] font-black text-[#111827]">그룹 리스트</h2>
|
||||
<p className="mt-1 text-[12px] font-bold text-[#64748b]">그룹 구성, 플랫폼 배정, 진행률 조회</p>
|
||||
</div>
|
||||
|
||||
<label className="flex h-9 w-full max-w-[260px] items-center gap-2 rounded-md border border-[#d1d9e6] bg-white px-3 text-[#94a3b8]">
|
||||
<Search size={15} aria-hidden="true" />
|
||||
<input className="min-w-0 flex-1 bg-transparent text-[13px] font-bold text-[#111827] outline-none placeholder:text-[#94a3b8]" placeholder="그룹 검색" value={query} onChange={(event) => setQuery(event.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="hidden gap-4 grid-cols-[44px_190px_minmax(220px,1fr)_170px_190px_74px] items-center border-b border-[#cbd5e1] bg-[#f8fafc] px-4 py-3 text-center text-[11px] font-bold text-[#64748b] xl:grid">
|
||||
<span>No.</span>
|
||||
<span>그룹</span>
|
||||
<span>그룹원</span>
|
||||
<span>플랫폼</span>
|
||||
<span>진행률</span>
|
||||
<span>관리</span>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-[#e2e8f0]">
|
||||
{loading ? (
|
||||
<div className="flex h-[220px] items-center justify-center gap-2 px-4 text-center text-[#64748b]">
|
||||
<Loader2 className="animate-spin" size={18} aria-hidden="true" />
|
||||
<span className="text-[13px] font-bold">그룹 목록을 불러오는 중입니다.</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex h-[220px] items-center justify-center px-4 text-center">
|
||||
<p className="text-[13px] font-bold text-[#64748b]">{error}</p>
|
||||
</div>
|
||||
) : pagedGroups.length > 0 ? (
|
||||
pagedGroups.map((group, index) => (
|
||||
<article className="grid gap-4 px-4 py-4 xl:grid-cols-[44px_190px_minmax(220px,1fr)_170px_190px_74px] xl:items-center" key={group.name}>
|
||||
<div className="text-center">
|
||||
<p className="mb-2 text-[11px] font-bold text-[#94a3b8] xl:hidden">번호</p>
|
||||
<span className="text-[13px] font-bold text-[#64748b]">{filteredGroups.length - ((page - 1) * pageSize + index)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-md bg-[#f0efff] text-[#4f46e5]">
|
||||
<Users size={17} aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-[14px] font-black text-[#111827]">{group.name}</h3>
|
||||
<span className="rounded bg-[#f1f5f9] px-1.5 py-0.5 text-[11px] font-bold text-[#64748b]">{group.status}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[12px] font-bold text-[#64748b]">
|
||||
{group.members.length}명 | {group.week}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<p className="mb-2 text-[11px] font-bold text-[#94a3b8]">그룹원</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{group.members.map((member) => (
|
||||
<span className="rounded bg-[#f8fafc] px-2 py-1 text-[12px] font-bold text-[#334155]" key={member}>
|
||||
{member}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="mb-2 text-[11px] font-bold text-[#94a3b8]">플랫폼</p>
|
||||
<span className="inline-flex h-7 items-center rounded-md bg-[#f1f5f9] px-2.5 text-[12px] font-bold text-[#111827]">{group.platform}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between text-[12px] font-bold text-[#475569]">
|
||||
<span>진행률</span>
|
||||
<span className="text-[#4f46e5]">{group.progress}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-[#dbe3ef]">
|
||||
<div className="h-full rounded-full bg-[#4f46e5]" style={{ width: `${group.progress}%` }} />
|
||||
</div>
|
||||
<p className="mt-2 text-center text-[11px] font-bold text-[#94a3b8]">
|
||||
완료 {group.completedMissions} · 활동 {group.activeUsers}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<button className="grid h-8 w-8 place-items-center rounded-md border border-[#c7d2fe] bg-[#eef2ff] text-[#4f46e5] hover:bg-[#e0e7ff]" type="button" aria-label={`${group.name} 수정`} title="수정" onClick={() => openEditModal(group)}>
|
||||
<Edit3 size={15} aria-hidden="true" />
|
||||
</button>
|
||||
<button className="grid h-8 w-8 place-items-center rounded-md border border-[#fecdd3] bg-[#fff1f2] text-[#e11d48] hover:bg-[#ffe4e6] disabled:cursor-not-allowed disabled:opacity-60" type="button" aria-label={`${group.name} 삭제`} title="삭제" onClick={() => setDeleteTarget(group)} disabled={deletingGroupId === group.id}>
|
||||
{deletingGroupId === group.id ? <Loader2 className="animate-spin" size={15} aria-hidden="true" /> : <Trash2 size={15} aria-hidden="true" />}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
) : (
|
||||
<div className="flex h-[220px] items-center justify-center px-4 text-center">
|
||||
<p className="text-[13px] font-bold text-[#64748b]">조회된 그룹이 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PaginationBar
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
totalItems={filteredGroups.length}
|
||||
totalLabel={`전체 ${filteredGroups.length}개`}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{modalMode ? createPortal((
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#0f172a]/25 px-4">
|
||||
<div className="flex max-h-[86vh] w-full max-w-[620px] flex-col rounded-lg border border-[#c7d2e0] bg-white shadow-[0_24px_70px_rgba(15,23,42,0.18)]">
|
||||
<div className="flex items-center justify-between border-b border-[#cbd5e1] px-5 py-4">
|
||||
<div>
|
||||
<h2 className="text-[16px] font-black text-[#111827]">{modalMode === 'edit' ? '그룹 수정' : '그룹 추가'}</h2>
|
||||
<p className="mt-1 text-[12px] font-bold text-[#64748b]">그룹 정보와 배정 인원을 설정해 주세요.</p>
|
||||
</div>
|
||||
<button className="grid h-8 w-8 place-items-center rounded-md text-[#64748b] hover:bg-[#f1f5f9] hover:text-[#111827]" type="button" onClick={closeGroupModal} disabled={saving} aria-label="닫기" title="닫기">
|
||||
<X size={17} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto px-5 py-5">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-[12px] font-black text-[#334155]">그룹명</span>
|
||||
<input
|
||||
className="h-10 w-full rounded-md border border-[#d1d9e6] bg-white px-3 text-[13px] font-semibold text-[#111827] outline-none placeholder:text-[#94a3b8] focus:border-[#2563eb] focus:ring-2 focus:ring-[#dbeafe]"
|
||||
placeholder="그룹명 입력"
|
||||
value={groupName}
|
||||
onChange={(event) => setGroupName(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-[12px] font-black text-[#334155]">플랫폼</span>
|
||||
<select
|
||||
className="h-10 w-full rounded-md border border-[#d1d9e6] bg-white px-3 text-[13px] font-semibold text-[#111827] outline-none focus:border-[#2563eb] focus:ring-2 focus:ring-[#dbeafe]"
|
||||
value={selectedPlatform}
|
||||
onChange={(event) => setSelectedPlatform(event.target.value)}
|
||||
>
|
||||
<option value="unassigned">미배정</option>
|
||||
{availablePlatforms.map((platform) => (
|
||||
<option key={platform.id} value={platform.id}>{platform.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-[12px] font-black text-[#334155]">그룹원</span>
|
||||
<span className="text-[12px] font-bold text-[#64748b]">선택 {selectedMemberIds.length}명</span>
|
||||
</div>
|
||||
<div className="max-h-[260px] space-y-2 overflow-y-auto rounded-lg border border-[#d1d9e6] bg-[#f8fafc] p-2">
|
||||
{availableStudents.length > 0 ? availableStudents.map((student) => (
|
||||
<label className="flex cursor-pointer items-center gap-3 rounded-md bg-white px-3 py-2 hover:bg-[#f8faff]" key={student.id}>
|
||||
<input
|
||||
className="h-4 w-4 accent-[#4f46e5]"
|
||||
type="checkbox"
|
||||
checked={selectedMemberIds.includes(student.id)}
|
||||
onChange={() => toggleMember(student.id)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[13px] font-black text-[#111827]">{student.name}</span>
|
||||
<span className="mt-0.5 block truncate text-[12px] font-semibold text-[#64748b]">{student.email}</span>
|
||||
</span>
|
||||
</label>
|
||||
)) : (
|
||||
<div className="flex h-[120px] items-center justify-center text-[13px] font-bold text-[#64748b]">배정 가능한 수강생이 없습니다.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formError ? <p className="rounded-md bg-[#fff1f2] px-3 py-2 text-[12px] font-bold text-[#e11d48]">{formError}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 border-t border-[#cbd5e1] px-5 py-4">
|
||||
<button className="h-9 rounded-md border border-[#d1d9e6] bg-white px-4 text-[13px] font-bold text-[#475569] hover:bg-[#f8fafc]" type="button" onClick={closeGroupModal} disabled={saving}>취소</button>
|
||||
<button className="inline-flex h-9 items-center gap-2 rounded-md bg-[#2563eb] px-4 text-[13px] font-bold text-white hover:bg-[#1d4ed8] disabled:cursor-not-allowed disabled:opacity-60" type="button" onClick={handleSaveGroup} disabled={saving}>
|
||||
{saving ? <Loader2 className="animate-spin" size={15} aria-hidden="true" /> : <Plus size={15} aria-hidden="true" />}
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
), document.body) : null}
|
||||
|
||||
{deleteTarget ? createPortal((
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#0f172a]/25 px-4">
|
||||
<div className="w-full max-w-[420px] rounded-lg border border-[#c7d2e0] bg-white shadow-[0_24px_70px_rgba(15,23,42,0.18)]">
|
||||
<div className="flex items-center justify-between border-b border-[#cbd5e1] px-5 py-4">
|
||||
<div>
|
||||
<h2 className="text-[16px] font-black text-[#111827]">그룹 삭제</h2>
|
||||
<p className="mt-1 text-[12px] font-bold text-[#64748b]">그룹원은 모두 미배정 상태가 됩니다.</p>
|
||||
</div>
|
||||
<button className="grid h-8 w-8 place-items-center rounded-md text-[#64748b] hover:bg-[#f1f5f9] hover:text-[#111827]" type="button" onClick={() => setDeleteTarget(null)} disabled={deletingGroupId === deleteTarget.id} aria-label="닫기" title="닫기">
|
||||
<X size={17} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-5 py-5">
|
||||
<div className="rounded-lg border border-[#cbd5e1] bg-[#f8fafc] px-4 py-3">
|
||||
<p className="text-[14px] font-black text-[#111827]">{deleteTarget.name}</p>
|
||||
<p className="mt-1 text-[12px] font-semibold text-[#64748b]">{deleteTarget.members.length}명 | {deleteTarget.platform}</p>
|
||||
</div>
|
||||
<p className="mt-4 text-[13px] font-bold leading-relaxed text-[#475569]">그룹만 삭제되고 수강생 계정과 수강 등록은 유지됩니다.</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 border-t border-[#cbd5e1] px-5 py-4">
|
||||
<button className="h-9 rounded-md border border-[#d1d9e6] bg-white px-4 text-[13px] font-bold text-[#475569] hover:bg-[#f8fafc] disabled:opacity-60" type="button" onClick={() => setDeleteTarget(null)} disabled={deletingGroupId === deleteTarget.id}>취소</button>
|
||||
<button className="inline-flex h-9 items-center gap-2 rounded-md border border-[#fecdd3] bg-[#fff1f2] px-4 text-[13px] font-bold text-[#e11d48] hover:bg-[#ffe4e6] disabled:opacity-60" type="button" onClick={() => handleDeleteGroup(deleteTarget)} disabled={deletingGroupId === deleteTarget.id}>
|
||||
{deletingGroupId === deleteTarget.id ? <Loader2 className="animate-spin" size={15} aria-hidden="true" /> : <Trash2 size={15} aria-hidden="true" />}
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
), document.body) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryCard({ label, value, icon: Icon, tone }: { label: string; value: string; icon: LucideIcon; tone: string }) {
|
||||
return (
|
||||
<article className="rounded-lg border border-[#c7d2e0] bg-white px-5 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[12px] font-semibold text-[#64748b]">{label}</p>
|
||||
<p className="mt-2 text-[20px] font-black leading-none text-[#111827]">{value}</p>
|
||||
</div>
|
||||
<span className={`grid h-11 w-11 shrink-0 place-items-center rounded-md ${tone}`}>
|
||||
<Icon size={28} aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { CheckCircle2, Eye, EyeOff } from 'lucide-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { BrandMark } from '../../components/BrandMark';
|
||||
import { loginInstructor } from '../../lib/auth';
|
||||
import type { AuthSession } from '../../types/auth';
|
||||
|
||||
type LoginPageProps = {
|
||||
onLogin: (session: AuthSession) => void;
|
||||
};
|
||||
|
||||
const REMEMBERED_EMAIL_STORAGE_KEY = 'atnmy.lms.admin.login.rememberedEmail';
|
||||
const DEFAULT_EMAIL = '';
|
||||
const DEFAULT_PASSWORD = '';
|
||||
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 EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
export function LoginPage({ onLogin }: LoginPageProps) {
|
||||
const rememberedEmail = window.localStorage.getItem(REMEMBERED_EMAIL_STORAGE_KEY);
|
||||
const [email, setEmail] = useState(rememberedEmail ?? DEFAULT_EMAIL);
|
||||
const [password, setPassword] = useState(DEFAULT_PASSWORD);
|
||||
const [rememberEmail, setRememberEmail] = useState(Boolean(rememberedEmail));
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [showSignup, setShowSignup] = useState(false);
|
||||
const [signupNotice, setSignupNotice] = useState('');
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const session = await loginInstructor(email, password);
|
||||
if (rememberEmail) {
|
||||
window.localStorage.setItem(REMEMBERED_EMAIL_STORAGE_KEY, email);
|
||||
} else {
|
||||
window.localStorage.removeItem(REMEMBERED_EMAIL_STORAGE_KEY);
|
||||
}
|
||||
onLogin(session);
|
||||
} catch (loginError) {
|
||||
setError(loginError instanceof Error ? loginError.message : '로그인에 실패했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="grid min-h-screen bg-white text-[#111827] lg:grid-cols-[minmax(420px,58%)_minmax(360px,42%)]">
|
||||
<section className="relative flex min-h-[42vh] overflow-hidden bg-[linear-gradient(145deg,#3b82f6_0%,#7048e8_54%,#4f46e5_100%)] px-7 py-8 text-white sm:px-12 lg:min-h-screen lg:px-[60px]">
|
||||
<div className="absolute inset-0 opacity-25">
|
||||
<div className="absolute left-[14%] top-[18%] h-40 w-40 rounded-full bg-white blur-3xl" />
|
||||
<div className="absolute bottom-[18%] right-[12%] h-56 w-56 rounded-full bg-[#93c5fd] blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 flex w-full max-w-[580px] flex-col">
|
||||
<BrandMark />
|
||||
|
||||
<div className="my-auto py-12 lg:py-0">
|
||||
<p className="mb-4 text-[11px] font-extrabold uppercase tracking-[0.16em] text-white/70">Learning Management System</p>
|
||||
<h1 className="max-w-[520px] text-[34px] font-black leading-[1.12] tracking-[0] sm:text-[44px]">
|
||||
<span className="text-[#67e8f9]">자율주행</span> 교육 플랫폼
|
||||
</h1>
|
||||
<p className="mt-5 max-w-[410px] text-[14px] font-bold leading-6 text-white/82">
|
||||
실제 하드웨어와 시뮬레이션을 활용한 차세대 학습 관리 시스템
|
||||
</p>
|
||||
|
||||
<div className="mt-8 grid max-w-[410px] gap-2.5 rounded-xl bg-white/12 p-4 ring-1 ring-white/18 backdrop-blur">
|
||||
{['실시간 모니터링 및 통합 관리', '하드웨어 플랫폼 상태 추적', 'AI 가이드 에스컬레이션'].map((item) => (
|
||||
<div className="flex items-center gap-3 text-[12px] font-bold text-white/88" key={item}>
|
||||
<CheckCircle2 size={16} aria-hidden="true" />
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[12px] font-bold text-white/55">© 2026. All rights reserved.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex min-h-[58vh] items-center justify-center px-7 py-10 sm:px-12 lg:min-h-screen">
|
||||
<div className="w-full max-w-[420px]">
|
||||
{showSignup ? (
|
||||
<InstructorSignupForm
|
||||
onCancel={() => setShowSignup(false)}
|
||||
onComplete={(registeredEmail) => {
|
||||
setEmail(registeredEmail);
|
||||
setShowSignup(false);
|
||||
setSignupNotice('회원가입이 완료되었습니다. 강사 계정으로 로그인할 수 있습니다.');
|
||||
}}
|
||||
/>
|
||||
) : <>
|
||||
<div className="mb-8">
|
||||
<h2 className="text-[26px] font-black tracking-[0] text-[#4f46e5]">강사 로그인</h2>
|
||||
<p className="mt-2.5 text-[13px] font-bold text-[#6b7280]">이메일과 비밀번호를 입력해주세요.</p>
|
||||
</div>
|
||||
|
||||
<form className="grid gap-4" onSubmit={handleSubmit}>
|
||||
<label className="grid gap-2 text-[13px] font-extrabold text-[#303747]">
|
||||
이메일
|
||||
<input
|
||||
className="h-11 rounded-lg border border-[#d8dde8] bg-white px-4 text-[14px] font-bold text-[#111827] outline-none transition placeholder:text-[#a0a7b7] focus:border-[#4f46e5] focus:ring-4 focus:ring-[#4f46e5]/12"
|
||||
name="email"
|
||||
placeholder="이메일을 입력하세요"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-2 text-[13px] font-extrabold text-[#303747]">
|
||||
비밀번호
|
||||
<input
|
||||
className="h-11 rounded-lg border border-[#d8dde8] bg-white px-4 text-[14px] font-bold text-[#111827] outline-none transition placeholder:text-[#a0a7b7] focus:border-[#4f46e5] focus:ring-4 focus:ring-[#4f46e5]/12"
|
||||
name="password"
|
||||
placeholder="비밀번호를 입력하세요"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label className="flex items-center gap-2 text-[12px] font-bold text-[#64748b]">
|
||||
<input
|
||||
className="h-4 w-4 rounded border-[#d8dde8] accent-[#4f46e5]"
|
||||
type="checkbox"
|
||||
checked={rememberEmail}
|
||||
onChange={(event) => setRememberEmail(event.target.checked)}
|
||||
/>
|
||||
아이디 기억
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="h-12 rounded-lg bg-[#4f46e5] text-[14px] font-extrabold text-white shadow-[0_10px_24px_rgba(79,70,229,0.22)] transition hover:bg-[#4338ca] focus:outline-none focus:ring-4 focus:ring-[#4f46e5]/20 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '로그인 중...' : '로그인'}
|
||||
</button>
|
||||
|
||||
{error ? <p className="text-center text-[12px] font-bold text-[#dc2626]">{error}</p> : null}
|
||||
{signupNotice ? <p className="text-center text-[12px] font-bold text-[#047857]">{signupNotice}</p> : null}
|
||||
</form>
|
||||
|
||||
<div className="my-7 h-px bg-[#e3e7ef]" />
|
||||
<p className="text-center text-[12px] font-bold text-[#64748b]">
|
||||
강사 계정이 없으신가요?{' '}
|
||||
<button
|
||||
className="font-extrabold text-[#4f46e5] hover:text-[#4338ca] hover:underline"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setError('');
|
||||
setSignupNotice('');
|
||||
setShowSignup(true);
|
||||
}}
|
||||
>
|
||||
회원가입
|
||||
</button>
|
||||
</p>
|
||||
<p className="mt-5 text-center text-[11px] font-bold leading-5 text-[#8b93a3]">접근 권한이 없는 계정은 관리자에게 문의하세요.</p>
|
||||
</>}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
type InstructorSignupFormProps = {
|
||||
onCancel: () => void;
|
||||
onComplete: (email: string) => void;
|
||||
};
|
||||
|
||||
function InstructorSignupForm({ onCancel, onComplete }: InstructorSignupFormProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [passwordConfirm, setPasswordConfirm] = useState('');
|
||||
const [instructorInviteCode, setInstructorInviteCode] = useState('');
|
||||
const [verificationCode, setVerificationCode] = useState('');
|
||||
const [verificationEmail, setVerificationEmail] = useState('');
|
||||
const [codeSent, setCodeSent] = useState(false);
|
||||
const [codeTimer, setCodeTimer] = useState(0);
|
||||
const [sendingCode, setSendingCode] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showPasswordConfirm, setShowPasswordConfirm] = useState(false);
|
||||
const [agreeTerms, setAgreeTerms] = useState(false);
|
||||
const [agreePrivacy, setAgreePrivacy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [termModal, setTermModal] = useState<{ title: string; body: string; loading: boolean } | null>(null);
|
||||
const normalizedEmail = email.trim().toLowerCase();
|
||||
const emailError = email.length > 0 && !EMAIL_PATTERN.test(email) ? '올바른 이메일 형식으로 입력해주세요.' : '';
|
||||
|
||||
useEffect(() => {
|
||||
if (codeTimer <= 0) return;
|
||||
const timer = window.setTimeout(() => setCodeTimer((value) => value - 1), 1000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [codeTimer]);
|
||||
|
||||
const handleEmailChange = (value: string) => {
|
||||
setEmail(value);
|
||||
if (verificationEmail && value.trim().toLowerCase() !== verificationEmail) {
|
||||
setCodeSent(false);
|
||||
setVerificationCode('');
|
||||
setCodeTimer(0);
|
||||
}
|
||||
};
|
||||
|
||||
const sendVerificationCode = async () => {
|
||||
if (!normalizedEmail || emailError) return;
|
||||
setSendingCode(true);
|
||||
setError('');
|
||||
try {
|
||||
const response = await fetch(`${AUTH_BASE_URL}/auth/email-verification/send`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ email: normalizedEmail }),
|
||||
});
|
||||
if (response.status === 409) throw new Error('이미 가입된 이메일입니다.');
|
||||
if (!response.ok) throw new Error('인증코드 발송에 실패했습니다.');
|
||||
|
||||
setCodeSent(true);
|
||||
setVerificationEmail(normalizedEmail);
|
||||
setVerificationCode('');
|
||||
setCodeTimer(60);
|
||||
} catch (sendError) {
|
||||
setCodeSent(false);
|
||||
setError(sendError instanceof Error ? sendError.message : '인증코드 발송에 실패했습니다.');
|
||||
} finally {
|
||||
setSendingCode(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setError('');
|
||||
if (!name.trim()) return setError('이름을 입력해주세요.');
|
||||
if (emailError) return setError(emailError);
|
||||
if (password.length < 8) return setError('비밀번호는 8자 이상이어야 합니다.');
|
||||
if (password !== passwordConfirm) return setError('비밀번호가 일치하지 않습니다.');
|
||||
if (!agreeTerms || !agreePrivacy) return setError('필수 약관에 동의해주세요.');
|
||||
if (!/^[A-Za-z0-9]{6}$/.test(instructorInviteCode.trim())) return setError('강사 가입 코드 6자리를 입력해주세요.');
|
||||
if (!/^\d{6}$/.test(verificationCode)) return setError('이메일 인증코드 6자리를 입력해주세요.');
|
||||
if (!codeSent || verificationEmail !== normalizedEmail) return setError('현재 이메일로 인증코드를 다시 발송해주세요.');
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const response = await fetch(`${AUTH_BASE_URL}/auth/instructor-signup`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ name: name.trim(), email: normalizedEmail, password, verificationCode, instructorInviteCode: instructorInviteCode.trim().toUpperCase() }),
|
||||
});
|
||||
if (response.status === 409) throw new Error('이미 가입된 이메일입니다.');
|
||||
if (response.status === 400) throw new Error('인증코드 또는 강사 가입 코드가 올바르지 않습니다.');
|
||||
if (!response.ok) throw new Error('회원가입에 실패했습니다.');
|
||||
onComplete(normalizedEmail);
|
||||
} catch (signupError) {
|
||||
setError(signupError instanceof Error ? signupError.message : '회원가입에 실패했습니다.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openTermModal = async (key: 'terms' | 'privacy') => {
|
||||
const title = key === 'terms' ? '이용약관' : '개인정보 처리방침';
|
||||
setTermModal({ title, body: '', loading: true });
|
||||
try {
|
||||
const response = await fetch(`${LAUNCHER_API_BASE_URL}/terms/${key}`);
|
||||
if (!response.ok) throw new Error('약관을 불러오지 못했습니다.');
|
||||
const payload = await response.json() as { term?: { body?: string } };
|
||||
setTermModal({ title, body: payload.term?.body || '등록된 약관 내용이 없습니다.', loading: false });
|
||||
} catch (termError) {
|
||||
setTermModal({
|
||||
title,
|
||||
body: termError instanceof Error ? termError.message : '약관을 불러오지 못했습니다.',
|
||||
loading: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<button className="text-[12px] font-extrabold text-[#64748b] hover:text-[#4f46e5]" type="button" onClick={onCancel}>로그인</button>
|
||||
<span className="h-3 w-px bg-[#d8dde8]" />
|
||||
<h2 className="text-[26px] font-black tracking-[0] text-[#4f46e5]">강사 회원가입</h2>
|
||||
</div>
|
||||
<p className="mt-2.5 text-[13px] font-bold text-[#6b7280]">강사 가입 코드를 입력해주세요.</p>
|
||||
</div>
|
||||
<form className="grid gap-3" onSubmit={handleSubmit}>
|
||||
<SignupInput label="이름" value={name} onChange={setName} placeholder="이름을 입력하세요" />
|
||||
<div className="grid gap-2 text-[13px] font-extrabold text-[#303747]">
|
||||
이메일
|
||||
<div className="flex gap-2">
|
||||
<input className="h-11 min-w-0 flex-1 rounded-lg border border-[#d8dde8] bg-white px-4 text-[14px] font-bold text-[#111827] outline-none transition placeholder:text-[#a0a7b7] focus:border-[#4f46e5] focus:ring-4 focus:ring-[#4f46e5]/12" type="email" value={email} onChange={(event) => handleEmailChange(event.target.value)} placeholder="이메일을 입력하세요" required />
|
||||
<button className="shrink-0 rounded-lg border border-[#c7d2fe] px-3 text-[12px] font-extrabold text-[#4f46e5] hover:bg-[#eef2ff] disabled:cursor-not-allowed disabled:opacity-60" type="button" onClick={() => void sendVerificationCode()} disabled={sendingCode || codeTimer > 0 || !normalizedEmail || Boolean(emailError)}>
|
||||
{sendingCode ? '발송 중' : codeTimer > 0 ? `${codeTimer}초` : codeSent ? '재발송' : '인증 발송'}
|
||||
</button>
|
||||
</div>
|
||||
{emailError ? <p className="text-[11px] text-[#dc2626]">{emailError}</p> : null}
|
||||
</div>
|
||||
<SignupInput label="인증코드" value={verificationCode} onChange={(value) => setVerificationCode(value.replace(/\D/g, '').slice(0, 6))} placeholder="이메일 인증코드 6자리를 입력하세요" inputMode="numeric" />
|
||||
{codeSent ? <p className="-mt-1 text-[11px] font-bold text-[#047857]">인증코드가 이메일로 발송되었습니다.</p> : null}
|
||||
<SignupInput label="강사 가입 코드" value={instructorInviteCode} onChange={(value) => setInstructorInviteCode(value.replace(/[^a-z0-9]/gi, '').toUpperCase().slice(0, 6))} placeholder="조직에서 받은 6자리 코드를 입력하세요" />
|
||||
<PasswordInput label="비밀번호" value={password} onChange={setPassword} placeholder="8자 이상 입력하세요" visible={showPassword} onToggle={() => setShowPassword((value) => !value)} />
|
||||
<PasswordInput label="비밀번호 확인" value={passwordConfirm} onChange={setPasswordConfirm} placeholder="비밀번호를 다시 입력하세요" visible={showPasswordConfirm} onToggle={() => setShowPasswordConfirm((value) => !value)} />
|
||||
<div className="mt-1 grid gap-2 text-[12px] font-bold text-[#64748b]">
|
||||
<label className="flex items-center gap-2"><input className="h-4 w-4 rounded border-[#d8dde8] accent-[#4f46e5]" type="checkbox" checked={agreeTerms} onChange={(event) => setAgreeTerms(event.target.checked)} /><button className="font-extrabold text-[#4f46e5] hover:underline" type="button" onClick={() => void openTermModal('terms')}>이용약관</button> 동의 (필수)</label>
|
||||
<label className="flex items-center gap-2"><input className="h-4 w-4 rounded border-[#d8dde8] accent-[#4f46e5]" type="checkbox" checked={agreePrivacy} onChange={(event) => setAgreePrivacy(event.target.checked)} /><button className="font-extrabold text-[#4f46e5] hover:underline" type="button" onClick={() => void openTermModal('privacy')}>개인정보 처리방침</button> 동의 (필수)</label>
|
||||
</div>
|
||||
{error ? <p className="text-center text-[12px] font-bold text-[#dc2626]">{error}</p> : null}
|
||||
<button className="mt-1 h-12 rounded-lg bg-[#4f46e5] text-[14px] font-extrabold text-white shadow-[0_10px_24px_rgba(79,70,229,0.22)] transition hover:bg-[#4338ca] disabled:cursor-not-allowed disabled:opacity-70" type="submit" disabled={submitting}>{submitting ? '가입 중...' : '회원가입'}</button>
|
||||
</form>
|
||||
{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 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]">
|
||||
{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> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type SignupInputProps = {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
inputMode?: 'numeric';
|
||||
};
|
||||
|
||||
function SignupInput({ label, value, onChange, placeholder, inputMode }: SignupInputProps) {
|
||||
return <label className="grid gap-2 text-[13px] font-extrabold text-[#303747]">{label}<input className="h-11 rounded-lg border border-[#d8dde8] bg-white px-4 text-[14px] font-bold text-[#111827] outline-none transition placeholder:text-[#a0a7b7] focus:border-[#4f46e5] focus:ring-4 focus:ring-[#4f46e5]/12" value={value} onChange={(event) => onChange(event.target.value)} placeholder={placeholder} inputMode={inputMode} required /></label>;
|
||||
}
|
||||
|
||||
type PasswordInputProps = Omit<SignupInputProps, 'inputMode'> & { visible: boolean; onToggle: () => void };
|
||||
|
||||
function PasswordInput({ label, value, onChange, placeholder, visible, onToggle }: PasswordInputProps) {
|
||||
return <label className="grid gap-2 text-[13px] font-extrabold text-[#303747]">{label}<span className="relative"><input className="h-11 w-full rounded-lg border border-[#d8dde8] bg-white px-4 pr-11 text-[14px] font-bold text-[#111827] outline-none transition placeholder:text-[#a0a7b7] focus:border-[#4f46e5] focus:ring-4 focus:ring-[#4f46e5]/12" type={visible ? 'text' : 'password'} value={value} onChange={(event) => onChange(event.target.value)} placeholder={placeholder} required /><button className="absolute right-3 top-1/2 -translate-y-1/2 text-[#8b93a3] hover:text-[#4f46e5]" type="button" onClick={onToggle} aria-label={visible ? '비밀번호 숨기기' : '비밀번호 보기'}>{visible ? <EyeOff size={17} /> : <Eye size={17} />}</button></span></label>;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,199 @@
|
||||
import { useRef, useState, type ChangeEvent } from 'react';
|
||||
import { Building2, Camera, Loader2, Mail, ShieldCheck, Trash2, type LucideIcon } from 'lucide-react';
|
||||
import { ConfirmDialog } from '../../components/ConfirmDialog';
|
||||
import { PageHeader } from '../../components/PageHeader';
|
||||
import { useToast } from '../../components/ToastProvider';
|
||||
import { useProfileAvatar } from '../../hooks/useProfileAvatar';
|
||||
import { deleteProfileAvatar, notifyProfileAvatarDeleted, notifyProfileAvatarUpdated, resizeImageToWebp, uploadProfileAvatar } from '../../lib/profileAvatar';
|
||||
import type { AuthSession } from '../../types/auth';
|
||||
|
||||
type ProfilePageProps = {
|
||||
session: AuthSession;
|
||||
};
|
||||
|
||||
export function ProfilePage({ session }: ProfilePageProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const { showToast } = useToast();
|
||||
const { avatarObjectUrl, avatarMissing, avatarLoading, refreshAvatar, setAvatarMissing } = useProfileAvatar(session.accessToken);
|
||||
const [avatarUploading, setAvatarUploading] = useState(false);
|
||||
const [avatarDeleting, setAvatarDeleting] = useState(false);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const displayName = getDisplayName(session);
|
||||
const affiliation = getAffiliation(session);
|
||||
const roleLabel = getRoleLabel(session.user.role);
|
||||
const organizationName = session.user.primaryAffiliation?.organizationName ?? '소속 미지정';
|
||||
const department = session.user.primaryAffiliation?.department ?? '부서 미지정';
|
||||
const position = session.user.primaryAffiliation?.position ?? '직책 미지정';
|
||||
const hasAvatar = !avatarMissing && Boolean(avatarObjectUrl);
|
||||
const avatarBusy = avatarLoading || avatarUploading || avatarDeleting;
|
||||
|
||||
const handleAvatarSelect = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
if (!file) return;
|
||||
|
||||
setAvatarUploading(true);
|
||||
|
||||
try {
|
||||
const imageBlob = await resizeImageToWebp(file, 128);
|
||||
await uploadProfileAvatar(session.accessToken, imageBlob);
|
||||
refreshAvatar();
|
||||
notifyProfileAvatarUpdated();
|
||||
showToast({ title: '프로필 이미지가 변경되었습니다.', variant: 'success' });
|
||||
} catch (error) {
|
||||
showToast({
|
||||
title: '프로필 이미지 변경 실패',
|
||||
message: error instanceof Error ? error.message : '이미지를 다시 확인해주세요.',
|
||||
variant: 'error',
|
||||
});
|
||||
} finally {
|
||||
setAvatarUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAvatarDelete = async () => {
|
||||
setAvatarDeleting(true);
|
||||
|
||||
try {
|
||||
await deleteProfileAvatar(session.accessToken);
|
||||
setAvatarMissing(true);
|
||||
notifyProfileAvatarDeleted();
|
||||
setDeleteConfirmOpen(false);
|
||||
showToast({ title: '프로필 이미지가 삭제되었습니다.', variant: 'success' });
|
||||
} catch {
|
||||
showToast({ title: '프로필 이미지 삭제 실패', message: '잠시 후 다시 시도해주세요.', variant: 'error' });
|
||||
} finally {
|
||||
setAvatarDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[1220px]">
|
||||
<PageHeader route="profile" />
|
||||
|
||||
<section className="grid gap-4 xl:grid-cols-[360px_minmax(0,1fr)]">
|
||||
<div className="rounded-lg border border-[#c7d2e0] bg-white p-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative h-16 w-16 shrink-0">
|
||||
<div className="grid h-full w-full place-items-center overflow-hidden rounded-full border border-[#dbe3ef] bg-[#2563eb] text-[22px] font-black text-white shadow-[0_12px_24px_rgba(37,99,235,0.18)]">
|
||||
{hasAvatar ? (
|
||||
<img
|
||||
src={avatarObjectUrl ?? undefined}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
onError={() => setAvatarMissing(true)}
|
||||
/>
|
||||
) : (
|
||||
displayName.slice(0, 1)
|
||||
)}
|
||||
</div>
|
||||
{avatarBusy ? (
|
||||
<span className="absolute inset-0 grid place-items-center rounded-full bg-white/65 text-[#2563eb] backdrop-blur-[2px]">
|
||||
<Loader2 size={18} className="animate-spin" aria-hidden="true" />
|
||||
</span>
|
||||
) : null}
|
||||
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleAvatarSelect} />
|
||||
<button
|
||||
type="button"
|
||||
className="absolute bottom-0 right-0 z-10 grid h-7 w-7 translate-x-0.5 translate-y-0.5 cursor-pointer place-items-center rounded-full border border-white bg-[#2563eb] text-white shadow-[0_8px_18px_rgba(37,99,235,0.28)] transition hover:bg-[#1d4ed8] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={avatarBusy}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
aria-label="프로필 이미지 등록"
|
||||
>
|
||||
<Camera size={14} aria-hidden="true" />
|
||||
</button>
|
||||
{hasAvatar ? (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-0 top-0 z-10 grid h-7 w-7 translate-x-0.5 -translate-y-0.5 cursor-pointer place-items-center rounded-full border border-white bg-[#fff1f2] text-[#e11d48] shadow-[0_8px_18px_rgba(225,29,72,0.18)] transition hover:bg-[#ffe4e6] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={avatarBusy}
|
||||
onClick={() => setDeleteConfirmOpen(true)}
|
||||
aria-label="프로필 이미지 삭제"
|
||||
>
|
||||
<Trash2 size={13} aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-[18px] font-black text-[#111827]">{displayName}</h2>
|
||||
<p className="mt-1 truncate text-[12px] font-bold text-[#64748b]">{roleLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-3">
|
||||
<ProfileRow icon={Building2} label="소속" value={affiliation} />
|
||||
<ProfileRow icon={Mail} label="이메일" value={session.user.email} />
|
||||
<ProfileRow icon={ShieldCheck} label="권한" value={roleLabel} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-[#c7d2e0] bg-white">
|
||||
<div className="border-b border-[#cbd5e1] px-5 py-4">
|
||||
<h2 className="text-[15px] font-black text-[#111827]">계정 정보</h2>
|
||||
<p className="mt-1 text-[12px] font-bold text-[#64748b]">공통 인증 서버에서 전달된 로그인 정보입니다.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 p-5 md:grid-cols-2">
|
||||
<InfoCard label="소속 기관" value={organizationName} />
|
||||
<InfoCard label="부서" value={department} />
|
||||
<InfoCard label="직책" value={position} />
|
||||
<InfoCard label="계정 상태" value="활성" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteConfirmOpen}
|
||||
title="프로필 이미지 삭제"
|
||||
message="등록된 썸네일 이미지를 삭제하시겠습니까?"
|
||||
confirmLabel="삭제"
|
||||
loading={avatarDeleting}
|
||||
danger
|
||||
icon={Trash2}
|
||||
onCancel={() => setDeleteConfirmOpen(false)}
|
||||
onConfirm={() => void handleAvatarDelete()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileRow({ icon: Icon, label, value }: { icon: LucideIcon; label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 rounded-lg bg-[#f8fafc] px-3 py-3">
|
||||
<Icon className="mt-0.5 shrink-0 text-[#64748b]" size={16} aria-hidden="true" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-bold text-[#94a3b8]">{label}</p>
|
||||
<p className="mt-1 break-words text-[13px] font-bold text-[#111827]">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoCard({ label, value, wide = false }: { label: string; value: string; wide?: boolean }) {
|
||||
return (
|
||||
<article className={`rounded-lg border border-[#c7d2e0] bg-white p-4 ${wide ? 'md:col-span-2' : ''}`}>
|
||||
<p className="text-[12px] font-semibold text-[#64748b]">{label}</p>
|
||||
<p className="mt-2 text-[14px] font-bold text-[#111827]">{value}</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function getDisplayName(session: AuthSession): string {
|
||||
const name = session.user.name?.trim();
|
||||
if (name && name !== 'ATNMY Studio Admin') return name;
|
||||
return session.user.email.split('@')[0] || '강사';
|
||||
}
|
||||
|
||||
function getAffiliation(session: AuthSession): string {
|
||||
const affiliation = session.user.primaryAffiliation;
|
||||
if (!affiliation) return '소속 미지정';
|
||||
|
||||
return [affiliation.organizationName, affiliation.department, affiliation.position].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
function getRoleLabel(role: string): string {
|
||||
const normalized = role.toUpperCase();
|
||||
if (normalized === 'LMS_ADMIN') return '관리자';
|
||||
if (normalized === 'INSTRUCTOR') return '강사';
|
||||
return role;
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Bot, KeyRound, Loader2, RefreshCw, Save } from 'lucide-react';
|
||||
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> = {
|
||||
A_COPILOT: 'a-copilot',
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,611 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type FormEvent } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { BarChart3, CircleAlert, GitBranch, Loader2, Plus, Search, Trash2, UserRound, Wifi, X, type LucideIcon } from 'lucide-react';
|
||||
import { PageHeader } from '../../components/PageHeader';
|
||||
import { PaginationBar } from '../../components/PaginationBar';
|
||||
import { useToast } from '../../components/ToastProvider';
|
||||
import { listInstructorGroups, type InstructorGroup } from '../../lib/groups';
|
||||
import { createInstructorStudent, listInstructorStudents, removeInstructorStudent, type InstructorStudent, type InstructorStudentsSummary } from '../../lib/students';
|
||||
import type { AuthSession } from '../../types/auth';
|
||||
|
||||
const emptySummary: InstructorStudentsSummary = {
|
||||
totalStudents: 0,
|
||||
onlineStudents: 0,
|
||||
averageProgress: 0,
|
||||
unresolvedIssues: 0,
|
||||
};
|
||||
|
||||
export function StudentsPage({ session, selectedCourseId }: { session: AuthSession; selectedCourseId: string }) {
|
||||
const { showToast } = useToast();
|
||||
const [students, setStudents] = useState<InstructorStudent[]>([]);
|
||||
const [summary, setSummary] = useState<InstructorStudentsSummary>(emptySummary);
|
||||
const [query, setQuery] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [groups, setGroups] = useState<InstructorGroup[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [studentModalOpen, setStudentModalOpen] = useState(false);
|
||||
const [newStudentEmail, setNewStudentEmail] = useState('');
|
||||
const [newStudentName, setNewStudentName] = useState('');
|
||||
const [newStudentGroupId, setNewStudentGroupId] = useState('unassigned');
|
||||
const [studentSaving, setStudentSaving] = useState(false);
|
||||
const [studentFormError, setStudentFormError] = useState<string | null>(null);
|
||||
const [removingStudentId, setRemovingStudentId] = useState<string | null>(null);
|
||||
const [removeTarget, setRemoveTarget] = useState<InstructorStudent | null>(null);
|
||||
const [issueTarget, setIssueTarget] = useState<InstructorStudent | null>(null);
|
||||
|
||||
const loadStudents = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const payload = await listInstructorStudents(session.accessToken, selectedCourseId || null);
|
||||
setStudents(payload.students);
|
||||
setSummary(payload.summary);
|
||||
} catch (reason) {
|
||||
setStudents([]);
|
||||
setSummary(emptySummary);
|
||||
setError(reason instanceof Error ? reason.message : '수강생 현황을 불러오지 못했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [session.accessToken, selectedCourseId]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
loadStudents();
|
||||
listInstructorGroups(session.accessToken, selectedCourseId || null)
|
||||
.then((payload) => {
|
||||
if (!cancelled) setGroups(payload.groups);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setGroups([]);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [loadStudents, session.accessToken, selectedCourseId]);
|
||||
|
||||
const filteredStudents = useMemo(() => {
|
||||
const keyword = query.trim().toLowerCase();
|
||||
if (!keyword) return students;
|
||||
|
||||
return students.filter((student) =>
|
||||
[student.name, student.email, student.group, student.week, student.courseTitle]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(keyword),
|
||||
);
|
||||
}, [query, students]);
|
||||
|
||||
const pagedStudents = useMemo(() => filteredStudents.slice((page - 1) * pageSize, page * pageSize), [filteredStudents, page, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
const totalPages = Math.max(1, Math.ceil(filteredStudents.length / pageSize));
|
||||
if (page > totalPages) setPage(totalPages);
|
||||
}, [filteredStudents.length, page, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!studentModalOpen && !removeTarget && !issueTarget) return;
|
||||
|
||||
const originalOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = originalOverflow;
|
||||
};
|
||||
}, [issueTarget, removeTarget, studentModalOpen]);
|
||||
|
||||
const handlePageSizeChange = (nextPageSize: number) => {
|
||||
setPageSize(nextPageSize);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const openStudentModal = () => {
|
||||
setNewStudentEmail('');
|
||||
setNewStudentName('');
|
||||
setNewStudentGroupId('unassigned');
|
||||
setStudentFormError(null);
|
||||
setStudentModalOpen(true);
|
||||
};
|
||||
|
||||
const closeStudentModal = () => {
|
||||
if (studentSaving) return;
|
||||
setStudentModalOpen(false);
|
||||
};
|
||||
|
||||
const handleCreateStudent = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const email = newStudentEmail.trim();
|
||||
const name = newStudentName.trim();
|
||||
|
||||
if (!email || !name) {
|
||||
setStudentFormError('이메일과 이름을 입력해 주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setStudentSaving(true);
|
||||
setStudentFormError(null);
|
||||
|
||||
try {
|
||||
const payload = await createInstructorStudent(session.accessToken, {
|
||||
courseId: selectedCourseId || null,
|
||||
email,
|
||||
name,
|
||||
groupId: newStudentGroupId === 'unassigned' ? null : newStudentGroupId,
|
||||
});
|
||||
setStudents(payload.students);
|
||||
setSummary(payload.summary);
|
||||
setStudentModalOpen(false);
|
||||
setPage(1);
|
||||
showToast({
|
||||
title: '수강생이 추가되었습니다.',
|
||||
message: name,
|
||||
});
|
||||
} catch (reason) {
|
||||
const message = reason instanceof Error ? reason.message : '수강생을 등록하지 못했습니다.';
|
||||
setStudentFormError(message);
|
||||
showToast({
|
||||
title: '수강생 추가에 실패했습니다.',
|
||||
message,
|
||||
variant: 'error',
|
||||
});
|
||||
} finally {
|
||||
setStudentSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveStudent = async (student: InstructorStudent) => {
|
||||
setRemovingStudentId(student.id);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const payload = await removeInstructorStudent(session.accessToken, student.id);
|
||||
setStudents(payload.students);
|
||||
setSummary(payload.summary);
|
||||
showToast({
|
||||
title: '수강생이 제외되었습니다.',
|
||||
message: student.name,
|
||||
});
|
||||
} catch (reason) {
|
||||
const message = reason instanceof Error ? reason.message : '수강생을 제외하지 못했습니다.';
|
||||
setError(message);
|
||||
showToast({
|
||||
title: '수강생 제외에 실패했습니다.',
|
||||
message,
|
||||
variant: 'error',
|
||||
});
|
||||
} finally {
|
||||
setRemovingStudentId(null);
|
||||
setRemoveTarget(null);
|
||||
}
|
||||
};
|
||||
|
||||
const summaryItems = [
|
||||
{ label: '전체 수강생', value: `${summary.totalStudents}명`, icon: UserRound, tone: 'bg-[#f0efff] text-[#4f46e5]' },
|
||||
{ label: '현재 접속', value: `${summary.onlineStudents}명`, icon: Wifi, tone: 'bg-[#eff6ff] text-[#2563eb]' },
|
||||
{ label: '평균 진도', value: `${summary.averageProgress}%`, icon: BarChart3, tone: 'bg-[#fff7ed] text-[#f97316]' },
|
||||
{ label: '이슈', value: `${summary.unresolvedIssues}건`, icon: CircleAlert, tone: 'bg-[#fff1f2] text-[#e11d48]' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[1220px]">
|
||||
<PageHeader
|
||||
route="students"
|
||||
action={
|
||||
<button className="inline-flex h-9 items-center gap-2 rounded-md bg-[#2563eb] px-3 text-[13px] font-bold text-white hover:bg-[#1d4ed8]" type="button" onClick={openStudentModal}>
|
||||
<Plus size={15} aria-hidden="true" />
|
||||
수강생 추가
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
<section className="mb-4 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
{summaryItems.map((item) => (
|
||||
<SummaryCard key={item.label} {...item} />
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-[#c7d2e0] bg-white">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[#cbd5e1] px-4 py-3">
|
||||
<div>
|
||||
<h2 className="text-[15px] font-black text-[#111827]">수강생 리스트</h2>
|
||||
<p className="mt-1 text-[12px] font-bold text-[#64748b]">그룹, 주차, 진도, 커밋, 이슈, 최근 활동 조회</p>
|
||||
</div>
|
||||
|
||||
<label className="flex h-9 w-full max-w-[260px] items-center gap-2 rounded-md border border-[#d1d9e6] bg-white px-3 text-[#94a3b8]">
|
||||
<Search size={15} aria-hidden="true" />
|
||||
<input
|
||||
className="min-w-0 flex-1 bg-transparent text-[13px] font-bold text-[#111827] outline-none placeholder:text-[#94a3b8]"
|
||||
placeholder="수강생 검색"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<div className="min-w-[1180px]">
|
||||
<div className="grid grid-cols-[44px_108px_minmax(160px,1fr)_minmax(116px,0.7fr)_78px_minmax(150px,1fr)_58px_58px_118px_70px_42px] items-center gap-4 border-b border-[#cbd5e1] bg-[#f8fafc] px-4 py-3 text-center text-[11px] font-bold text-[#64748b]">
|
||||
<span>No.</span>
|
||||
<span>이름</span>
|
||||
<span>이메일</span>
|
||||
<span>그룹</span>
|
||||
<span>주차</span>
|
||||
<span>진행률</span>
|
||||
<span>커밋</span>
|
||||
<span>이슈</span>
|
||||
<span>최근 활동</span>
|
||||
<span>상태</span>
|
||||
<span>제외</span>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-[#e2e8f0]">
|
||||
{loading ? (
|
||||
<div className="flex h-[220px] items-center justify-center text-[#64748b]">
|
||||
<Loader2 className="mr-2 animate-spin" size={18} aria-hidden="true" />
|
||||
<span className="text-[13px] font-bold">수강생 현황을 불러오는 중입니다.</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<EmptyState message={error} />
|
||||
) : filteredStudents.length > 0 ? (
|
||||
pagedStudents.map((student, index) => (
|
||||
<article className="grid grid-cols-[44px_108px_minmax(160px,1fr)_minmax(116px,0.7fr)_78px_minmax(150px,1fr)_58px_58px_118px_70px_42px] items-center gap-4 px-4 py-4" key={student.id}>
|
||||
<Cell label="번호">
|
||||
<span className="text-[13px] font-bold text-[#64748b]">{filteredStudents.length - ((page - 1) * pageSize + index)}</span>
|
||||
</Cell>
|
||||
|
||||
<div className="min-w-0 text-center">
|
||||
<p className="hidden">이름</p>
|
||||
<h3 className="truncate text-[14px] font-black text-[#111827]">{student.name}</h3>
|
||||
</div>
|
||||
|
||||
<Cell label="이메일">
|
||||
<span className="block truncate text-[12px] font-semibold text-[#64748b]">{student.email}</span>
|
||||
</Cell>
|
||||
|
||||
<Cell label="그룹">
|
||||
<span className={`inline-flex h-7 items-center rounded-md px-2.5 text-[12px] font-semibold ${
|
||||
student.group === '미배정' ? 'bg-[#f1f5f9] text-[#64748b]' : 'bg-[#f0efff] text-[#4f46e5]'
|
||||
}`}>{student.group}</span>
|
||||
</Cell>
|
||||
|
||||
<Cell label="주차">
|
||||
<span className="text-[13px] font-semibold text-[#111827]">{student.week}</span>
|
||||
</Cell>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between text-[12px] font-bold text-[#475569]">
|
||||
<span className="hidden">진행률</span>
|
||||
<span className="font-semibold text-[#4f46e5]">{student.progress}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-[#dbe3ef]">
|
||||
<div className="h-full rounded-full bg-[#4f46e5]" style={{ width: `${student.progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StudentMetric icon={GitBranch} label="커밋" value={student.commits} />
|
||||
<StudentIssueMetric student={student} onOpen={setIssueTarget} />
|
||||
|
||||
<Cell label="최근 활동">
|
||||
<span className="text-[13px] font-semibold text-[#64748b]">{student.lastActive}</span>
|
||||
</Cell>
|
||||
|
||||
<Cell label="상태">
|
||||
<span className={`inline-flex h-7 items-center justify-center rounded-md px-2.5 text-[12px] font-bold ${student.online ? 'bg-[#eef2ff] text-[#4f46e5]' : 'bg-[#f1f5f9] text-[#64748b]'}`}>
|
||||
{student.online ? '온라인' : '오프라인'}
|
||||
</span>
|
||||
</Cell>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
className="grid h-8 w-8 place-items-center rounded-md border border-[#fecdd3] bg-[#fff1f2] text-[#e11d48] hover:bg-[#ffe4e6]"
|
||||
type="button"
|
||||
aria-label={`${student.name} 수강 제외`}
|
||||
title="수강 제외"
|
||||
onClick={() => setRemoveTarget(student)}
|
||||
disabled={removingStudentId === student.id}
|
||||
>
|
||||
{removingStudentId === student.id ? <Loader2 className="animate-spin" size={15} aria-hidden="true" /> : <Trash2 size={15} aria-hidden="true" />}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
) : (
|
||||
<EmptyState message="조회된 수강생이 없습니다." />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PaginationBar
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
totalItems={filteredStudents.length}
|
||||
totalLabel={`전체 ${filteredStudents.length}명`}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{studentModalOpen ? createPortal((
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#0f172a]/25 px-4">
|
||||
<form
|
||||
className="w-full max-w-[440px] rounded-lg border border-[#c7d2e0] bg-white shadow-[0_24px_70px_rgba(15,23,42,0.18)]"
|
||||
onSubmit={handleCreateStudent}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-[#cbd5e1] px-5 py-4">
|
||||
<div>
|
||||
<h2 className="text-[16px] font-black text-[#111827]">수강생 추가</h2>
|
||||
<p className="mt-1 text-[12px] font-bold text-[#64748b]">수강생 정보를 입력해 주세요.</p>
|
||||
</div>
|
||||
<button
|
||||
className="grid h-8 w-8 place-items-center rounded-md text-[#64748b] hover:bg-[#f1f5f9] hover:text-[#111827]"
|
||||
type="button"
|
||||
onClick={closeStudentModal}
|
||||
aria-label="닫기"
|
||||
title="닫기"
|
||||
>
|
||||
<X size={17} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 px-5 py-5">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-[12px] font-black text-[#334155]">이메일</span>
|
||||
<input
|
||||
className="h-10 w-full rounded-md border border-[#d1d9e6] bg-white px-3 text-[13px] font-semibold text-[#111827] outline-none placeholder:text-[#94a3b8] focus:border-[#2563eb] focus:ring-2 focus:ring-[#dbeafe]"
|
||||
type="email"
|
||||
placeholder="student@futuredrive.net"
|
||||
value={newStudentEmail}
|
||||
onChange={(event) => setNewStudentEmail(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-[12px] font-black text-[#334155]">이름</span>
|
||||
<input
|
||||
className="h-10 w-full rounded-md border border-[#d1d9e6] bg-white px-3 text-[13px] font-semibold text-[#111827] outline-none placeholder:text-[#94a3b8] focus:border-[#2563eb] focus:ring-2 focus:ring-[#dbeafe]"
|
||||
placeholder="홍길동"
|
||||
value={newStudentName}
|
||||
onChange={(event) => setNewStudentName(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-[12px] font-black text-[#334155]">그룹</span>
|
||||
<select
|
||||
className="h-10 w-full rounded-md border border-[#d1d9e6] bg-white px-3 text-[13px] font-semibold text-[#111827] outline-none focus:border-[#2563eb] focus:ring-2 focus:ring-[#dbeafe]"
|
||||
value={newStudentGroupId}
|
||||
onChange={(event) => setNewStudentGroupId(event.target.value)}
|
||||
>
|
||||
<option value="unassigned">미배정</option>
|
||||
{groups.map((group) => (
|
||||
<option key={group.id} value={group.id}>
|
||||
{group.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{studentFormError ? (
|
||||
<p className="rounded-md bg-[#fff1f2] px-3 py-2 text-[12px] font-bold text-[#e11d48]">{studentFormError}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 border-t border-[#cbd5e1] px-5 py-4">
|
||||
<button
|
||||
className="h-9 rounded-md border border-[#d1d9e6] bg-white px-4 text-[13px] font-bold text-[#475569] hover:bg-[#f8fafc]"
|
||||
type="button"
|
||||
onClick={closeStudentModal}
|
||||
disabled={studentSaving}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
className="inline-flex h-9 items-center gap-2 rounded-md bg-[#2563eb] px-4 text-[13px] font-bold text-white hover:bg-[#1d4ed8] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="submit"
|
||||
disabled={studentSaving}
|
||||
>
|
||||
{studentSaving ? <Loader2 className="animate-spin" size={15} aria-hidden="true" /> : <Plus size={15} aria-hidden="true" />}
|
||||
등록
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
), document.body) : null}
|
||||
|
||||
{removeTarget ? createPortal((
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#0f172a]/25 px-4">
|
||||
<div className="w-full max-w-[420px] rounded-lg border border-[#c7d2e0] bg-white shadow-[0_24px_70px_rgba(15,23,42,0.18)]">
|
||||
<div className="flex items-center justify-between border-b border-[#cbd5e1] px-5 py-4">
|
||||
<div>
|
||||
<h2 className="text-[16px] font-black text-[#111827]">수강생 제외</h2>
|
||||
<p className="mt-1 text-[12px] font-bold text-[#64748b]">현재 소속의 수강 목록에서 제외합니다.</p>
|
||||
</div>
|
||||
<button
|
||||
className="grid h-8 w-8 place-items-center rounded-md text-[#64748b] hover:bg-[#f1f5f9] hover:text-[#111827]"
|
||||
type="button"
|
||||
onClick={() => setRemoveTarget(null)}
|
||||
aria-label="닫기"
|
||||
title="닫기"
|
||||
disabled={removingStudentId === removeTarget.id}
|
||||
>
|
||||
<X size={17} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-5">
|
||||
<div className="rounded-lg border border-[#cbd5e1] bg-[#f8fafc] px-4 py-3">
|
||||
<p className="truncate text-[14px] font-black text-[#111827]">{removeTarget.name}</p>
|
||||
<p className="mt-1 truncate text-[12px] font-semibold text-[#64748b]">{removeTarget.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 border-t border-[#cbd5e1] px-5 py-4">
|
||||
<button
|
||||
className="h-9 rounded-md border border-[#d1d9e6] bg-white px-4 text-[13px] font-bold text-[#475569] hover:bg-[#f8fafc] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="button"
|
||||
onClick={() => setRemoveTarget(null)}
|
||||
disabled={removingStudentId === removeTarget.id}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
className="inline-flex h-9 items-center gap-2 rounded-md border border-[#fecdd3] bg-[#fff1f2] px-4 text-[13px] font-bold text-[#e11d48] hover:bg-[#ffe4e6] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="button"
|
||||
onClick={() => handleRemoveStudent(removeTarget)}
|
||||
disabled={removingStudentId === removeTarget.id}
|
||||
>
|
||||
{removingStudentId === removeTarget.id ? <Loader2 className="animate-spin" size={15} aria-hidden="true" /> : <Trash2 size={15} aria-hidden="true" />}
|
||||
제외
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
), document.body) : null}
|
||||
|
||||
{issueTarget ? createPortal((
|
||||
<IssueModal student={issueTarget} onClose={() => setIssueTarget(null)} />
|
||||
), document.body) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="flex h-[220px] items-center justify-center px-4 text-center">
|
||||
<p className="text-[13px] font-bold text-[#64748b]">{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Cell({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-w-0 text-center">
|
||||
<p className="hidden">{label}</p>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StudentMetric({ icon: Icon, label, value, danger = false }: { icon: typeof GitBranch; label: string; value: number; danger?: boolean }) {
|
||||
return (
|
||||
<div className="min-w-0 text-center">
|
||||
<div className="hidden">
|
||||
<Icon size={13} aria-hidden="true" />
|
||||
{label}
|
||||
</div>
|
||||
<p className={`mt-0 text-[13px] font-semibold ${danger ? 'text-[#e11d48]' : 'text-[#111827]'}`}>{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StudentIssueMetric({ student, onOpen }: { student: InstructorStudent; onOpen: (student: InstructorStudent) => void }) {
|
||||
const hasIssues = student.issues > 0;
|
||||
|
||||
return (
|
||||
<div className="min-w-0 text-center">
|
||||
<div className="hidden">
|
||||
<CircleAlert size={13} aria-hidden="true" />
|
||||
이슈
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (hasIssues) onOpen(student);
|
||||
}}
|
||||
disabled={!hasIssues}
|
||||
className={[
|
||||
'mt-0 inline-flex h-7 min-w-7 items-center justify-center rounded-md px-2 text-[13px] font-black',
|
||||
hasIssues
|
||||
? 'cursor-pointer bg-[#fff1f2] text-[#e11d48] transition-colors hover:bg-[#ffe4e6]'
|
||||
: 'cursor-default bg-transparent text-[#111827]',
|
||||
].join(' ')}
|
||||
title={hasIssues ? '이슈 상세 보기' : undefined}
|
||||
>
|
||||
{student.issues}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IssueModal({ student, onClose }: { student: InstructorStudent; onClose: () => void }) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#0f172a]/25 px-4">
|
||||
<section className="w-full max-w-[520px] overflow-hidden rounded-lg border border-[#c7d2e0] bg-white shadow-[0_24px_70px_rgba(15,23,42,0.18)]">
|
||||
<header className="flex items-center justify-between border-b border-[#cbd5e1] px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-[16px] font-black text-[#111827]">이슈 상세</h2>
|
||||
<p className="mt-1 truncate text-[12px] font-bold text-[#64748b]">{student.name} · {student.email}</p>
|
||||
</div>
|
||||
<button
|
||||
className="grid h-8 w-8 place-items-center rounded-md text-[#64748b] hover:bg-[#f1f5f9] hover:text-[#111827]"
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="닫기"
|
||||
title="닫기"
|
||||
>
|
||||
<X size={17} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="max-h-[520px] overflow-y-auto px-5 py-4">
|
||||
{student.issueItems.length ? (
|
||||
<div className="grid gap-2">
|
||||
{student.issueItems.map((issue, index) => (
|
||||
<article key={`${issue.type}-${issue.week ?? 'score'}-${index}`} className="rounded-lg border border-[#cbd5e1] bg-[#f8fafc] px-4 py-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-0.5 grid h-7 w-7 shrink-0 place-items-center rounded-md bg-[#fff1f2] text-[#e11d48]">
|
||||
<CircleAlert size={15} aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[13px] font-black text-[#111827]">{issue.title}</p>
|
||||
<p className="mt-1 text-[12px] font-semibold leading-5 text-[#64748b]">{issue.detail}</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid h-[180px] place-items-center text-[13px] font-bold text-[#94a3b8]">표시할 이슈가 없습니다.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="flex justify-end border-t border-[#cbd5e1] px-5 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="h-9 rounded-md bg-[#2563eb] px-4 text-[13px] font-bold text-white hover:bg-[#1d4ed8]"
|
||||
>
|
||||
확인
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryCard({ label, value, icon: Icon, tone }: { label: string; value: string; icon: LucideIcon; tone: string }) {
|
||||
return (
|
||||
<article className="rounded-lg border border-[#c7d2e0] bg-white px-5 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[12px] font-semibold text-[#64748b]">{label}</p>
|
||||
<p className="mt-2 text-[20px] font-black leading-none text-[#111827]">{value}</p>
|
||||
</div>
|
||||
<span className={`grid h-11 w-11 shrink-0 place-items-center rounded-md ${tone}`}>
|
||||
<Icon size={28} aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,65 @@
|
||||
import { Activity, BookOpen, Car, ClipboardList, GitPullRequest, Settings, Users, type LucideIcon } from 'lucide-react';
|
||||
|
||||
export type AppRoute = 'dashboard' | 'curriculum' | 'groups' | 'platforms' | 'students' | 'submissions' | 'analytics' | 'settings' | 'profile';
|
||||
|
||||
export type NavItem = {
|
||||
id: AppRoute;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
// children?: string[];
|
||||
};
|
||||
|
||||
export const defaultRoute: AppRoute = 'dashboard';
|
||||
|
||||
export const routePaths: Record<AppRoute, string> = {
|
||||
dashboard: '/dashboard',
|
||||
curriculum: '/curriculum',
|
||||
groups: '/groups',
|
||||
platforms: '/platforms',
|
||||
students: '/students',
|
||||
submissions: '/submissions',
|
||||
analytics: '/analytics',
|
||||
settings: '/settings',
|
||||
profile: '/profile'
|
||||
};
|
||||
|
||||
export const navItems: NavItem[] = [
|
||||
{ id: 'dashboard', label: '대시보드', icon: Activity },
|
||||
{ id: 'curriculum', label: '커리큘럼 관리', icon: ClipboardList },
|
||||
{ id: 'groups', label: '그룹 관리', icon: Users },
|
||||
{ id: 'platforms', label: '플랫폼 관리', icon: Car },
|
||||
{ id: 'students', label: '수강생 현황', icon: BookOpen },
|
||||
{ id: 'submissions', label: '제출 현황', icon: GitPullRequest },
|
||||
{ id: 'settings', label: '설정', icon: Settings }
|
||||
];
|
||||
|
||||
// 2depth menu candidates. Keep this until the left menu structure is finalized.
|
||||
// export const navItemChildren: Record<Exclude<AppRoute, 'profile'>, string[]> = {
|
||||
// dashboard: ['운영 요약', '오늘 할 일', '빠른 설정'],
|
||||
// groups: ['그룹 목록', '플랫폼 배정', '실습 주차'],
|
||||
// platforms: ['실시간 상태', '긴급 정지 로그', '장비 설정'],
|
||||
// students: ['수강생 목록', '접속 현황', '과제 제출'],
|
||||
// analytics: ['진도 분석', '플랫폼 사용률', '질문 추이'],
|
||||
// settings: ['권한 정책', '알림 설정', '서비스 연결']
|
||||
// };
|
||||
|
||||
const appRoutes = Object.keys(routePaths) as AppRoute[];
|
||||
const publicBasePath = import.meta.env.BASE_URL.replace(/\/+$/, '');
|
||||
|
||||
const stripPublicBasePath = (pathname: string): string => {
|
||||
if (!publicBasePath || publicBasePath === '/') return pathname;
|
||||
if (pathname === publicBasePath) return '/';
|
||||
if (pathname.startsWith(`${publicBasePath}/`)) return pathname.slice(publicBasePath.length) || '/';
|
||||
return pathname;
|
||||
};
|
||||
|
||||
export const isAppRoute = (value: string): value is AppRoute => appRoutes.includes(value as AppRoute);
|
||||
|
||||
export const getRoutePath = (route: AppRoute) => routePaths[route];
|
||||
|
||||
export const getRouteFromPathname = (pathname: string): AppRoute | null => {
|
||||
const routePathname = stripPublicBasePath(pathname);
|
||||
const normalizedPath = routePathname === '/' ? routePaths[defaultRoute] : routePathname.replace(/\/+$/, '');
|
||||
const entry = Object.entries(routePaths).find(([, path]) => path === normalizedPath);
|
||||
return entry ? (entry[0] as AppRoute) : null;
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
export type AuthAppRole = {
|
||||
appKey?: string;
|
||||
app_key?: string;
|
||||
roleCode?: string;
|
||||
role_code?: string;
|
||||
role?: string;
|
||||
code?: string;
|
||||
};
|
||||
|
||||
export type AuthUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
role: string;
|
||||
roles?: string[];
|
||||
appRoles?: AuthAppRole[];
|
||||
app_roles?: AuthAppRole[];
|
||||
primaryAffiliation?: {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
organizationName: string;
|
||||
organizationType: string;
|
||||
department: string | null;
|
||||
position: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type AuthSession = {
|
||||
accessToken: string;
|
||||
obtainedAt?: number;
|
||||
accessTokenExpiresIn?: number;
|
||||
refreshToken?: string;
|
||||
refreshTokenExpiresIn?: number;
|
||||
roles?: string[];
|
||||
appRoles?: AuthAppRole[];
|
||||
app_roles?: AuthAppRole[];
|
||||
user: AuthUser;
|
||||
};
|
||||
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL?: string;
|
||||
readonly VITE_LAUNCHER_API_BASE_URL?: string;
|
||||
readonly VITE_ATNMY_AUTH_URL?: string;
|
||||
readonly VITE_ADS_IDE_AUTH_URL?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, '.', '');
|
||||
const base = env.VITE_PUBLIC_BASE_PATH ?? '/lms-adm/';
|
||||
|
||||
return {
|
||||
base,
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5174,
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user