Files
cogmind/public/main.js
T
2026-08-10 21:35:17 +09:00

297 lines
9.3 KiB
JavaScript

const CONFIDENCE = [
{ id: 'sure', label: '확실해요' },
{ id: 'unsure', label: '애매해요' },
{ id: 'guess', label: '찍었어요' },
];
const CONFIDENCE_LABEL = (id) => CONFIDENCE.find((c) => c.id === id)?.label ?? '미응답';
const DIFFICULTY_LABEL = { easy: '쉬움', medium: '보통', hard: '어려움' };
const state = {
config: {},
questions: [],
answers: [],
confidences: [],
current: 0,
record: null,
recordKey: null,
};
const REQUEST_TIMEOUT = 60000;
const request = async (url, options = {}) => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT);
let res;
try {
res = await fetch(url, { ...options, signal: controller.signal });
} catch (err) {
throw new Error(
err.name === 'AbortError'
? '응답이 너무 오래 걸려 중단했습니다. 잠시 후 다시 시도해 주세요.'
: '서버에 연결하지 못했습니다. 서버가 실행 중인지 확인해 주세요.'
);
} finally {
clearTimeout(timer);
}
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `요청에 실패했습니다 (${res.status})`);
return data;
};
const post = (url, body) =>
request(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body ?? {}),
});
const api = {
chapters: () => request('/api/chapters'),
questions: (config, amount) => post('/api/questions', { ...config, amount }),
saveTest: (payload) => post('/api/tests', payload),
tests: () => request('/api/tests'),
test: (id) => request(`/api/tests/${id}`),
explain: (testId, index) => post(`/api/tests/${testId}/questions/${index}/explain`),
twin: (testId, index) => post(`/api/tests/${testId}/questions/${index}/twin`),
ask: (testId, index, message) =>
post(`/api/tests/${testId}/questions/${index}/ask`, { message }),
feedback: (testId) => post(`/api/tests/${testId}/feedback`),
};
const showScreen = (element) => {
document.getElementById('app').replaceChildren(element);
window.scrollTo({ top: 0 });
};
const showLoading = (message) => {
const box = document.createElement('div');
box.className = 'screen loading';
box.setAttribute('role', 'status');
box.innerHTML = `<div class="spinner"></div><p class="dots"></p>`;
box.querySelector('p').textContent = message;
showScreen(box);
};
const showError = (message, onBack) => {
const box = document.createElement('div');
box.className = 'screen';
box.innerHTML = `<div class="card"><p class="error-box"></p></div>`;
box.querySelector('.error-box').textContent = message;
const back = document.createElement('button');
back.className = 'block';
back.style.marginTop = '16px';
back.textContent = '처음으로';
back.onclick = onBack ?? (() => showStartScreen());
box.querySelector('.card').appendChild(back);
showScreen(box);
};
const createChipGroup = (items, { selected, onSelect } = {}) => {
const group = document.createElement('div');
group.className = 'chips';
group.setAttribute('role', 'group');
const buttons = items.map((item) => {
const chip = document.createElement('button');
chip.type = 'button';
chip.className = 'chip';
chip.textContent = item.label;
chip.setAttribute('aria-pressed', String(item.id === selected));
chip.onclick = () => {
buttons.forEach((b) => b.setAttribute('aria-pressed', 'false'));
chip.setAttribute('aria-pressed', 'true');
onSelect?.(item.id);
};
group.appendChild(chip);
return chip;
});
return group;
};
const createTopbar = (rightButtons = []) => {
const bar = document.createElement('div');
bar.className = 'topbar';
const brand = document.createElement('button');
brand.type = 'button';
brand.className = 'brand';
brand.textContent = 'CogMind';
brand.onclick = () => showStartScreen();
bar.appendChild(brand);
const right = document.createElement('div');
right.className = 'topbar-actions';
rightButtons.forEach((button) => right.appendChild(button));
bar.appendChild(right);
return bar;
};
const createButton = (label, onClick, className = '') => {
const button = document.createElement('button');
button.type = 'button';
button.textContent = label;
if (className) button.className = className;
button.onclick = onClick;
return button;
};
const formatDate = (iso) =>
new Date(iso).toLocaleString('ko-KR', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
const shuffle = (items) => {
const copy = [...items];
for (let i = copy.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[copy[i], copy[j]] = [copy[j], copy[i]];
}
return copy;
};
const ROUTES = {
start: { url: () => '/', title: 'CogMind - 메타인지 학습 진단' },
quiz: { url: (r) => `/quiz/${r.index + 1}`, title: '문제 풀이 - CogMind' },
test: { url: (r) => `/tests/${r.id}`, title: '학습 진단 결과 - CogMind' },
history: { url: () => '/history', title: '지난 기록 - CogMind' },
};
const parseLocation = () => {
const [first, second] = location.pathname.split('/').filter(Boolean);
if (first === 'history') return { name: 'history' };
if (first === 'tests' && second) return { name: 'test', id: second };
if (first === 'quiz') return { name: 'quiz', index: Math.max(0, Number(second || 1) - 1) };
return { name: 'start' };
};
let pendingError = null;
const navigate = (route, { replace = false } = {}) => {
history[replace ? 'replaceState' : 'pushState'](route, '', ROUTES[route.name].url(route));
renderRoute(route);
};
const renderRoute = async (route) => {
document.title = ROUTES[route.name]?.title ?? ROUTES.start.title;
if (route.name === 'history') {
showScreen(document.createElement('history-page'));
return;
}
if (route.name === 'test') {
if (state.record?.id === route.id) {
showReport(state.record);
return;
}
showLoading('기록을 불러오는 중이에요');
try {
showReport(await api.test(route.id));
} catch (err) {
showError(err.message, () => navigate({ name: 'history' }, { replace: true }));
}
return;
}
if (route.name === 'quiz') {
if (!state.questions.length) {
navigate({ name: 'start' }, { replace: true });
return;
}
state.current = Math.min(route.index, state.questions.length - 1);
showScreen(document.createElement('test-page'));
return;
}
const page = document.createElement('option-page');
if (pendingError) {
page.dataset.error = pendingError;
pendingError = null;
}
showScreen(page);
};
window.addEventListener('popstate', (event) => renderRoute(event.state ?? parseLocation()));
const showStartScreen = (errorMessage) => {
pendingError = errorMessage ?? null;
navigate({ name: 'start' });
};
const showHistoryScreen = () => navigate({ name: 'history' });
const showHistoryDetail = (id) => navigate({ name: 'test', id });
const goToQuestion = (index, options) => navigate({ name: 'quiz', index }, options);
const startQuiz = async (config, amount) => {
showLoading('AI가 문제를 만들고 있어요');
try {
const { results } = await api.questions(config, amount);
state.config = config;
state.questions = results.map((q) => ({
...q,
options: shuffle([q.correct_answer, ...q.incorrect_answers]),
}));
state.answers = [];
state.confidences = [];
state.record = null;
state.recordKey = null;
goToQuestion(0);
} catch (err) {
showStartScreen(err.message);
}
};
const finishQuiz = async () => {
const answers = state.questions.map((q, i) => ({
question: q.question,
correct_answer: q.correct_answer,
incorrect_answers: q.incorrect_answers,
user_answer: state.answers[i] ?? null,
confidence: state.confidences[i] ?? null,
}));
const key = JSON.stringify(answers.map((a) => [a.user_answer, a.confidence]));
if (state.record && state.recordKey === key) {
navigate({ name: 'test', id: state.record.id });
return;
}
showLoading('결과를 정리하고 있어요');
try {
state.record = await api.saveTest({ ...state.config, questions: answers });
state.recordKey = key;
navigate({ name: 'test', id: state.record.id });
} catch (err) {
showError(err.message, () => goToQuestion(state.questions.length - 1, { replace: true }));
}
};
const showReport = (record) => {
const screen = document.createElement('div');
screen.className = 'screen';
screen.appendChild(
createTopbar([
createButton('지난 기록', () => showHistoryScreen()),
createButton('새 시험', () => showStartScreen()),
])
);
screen.appendChild(renderReport(record));
showScreen(screen);
};
window.addEventListener('DOMContentLoaded', () =>
renderRoute(history.state ?? parseLocation())
);