first commit
This commit is contained in:
+296
@@ -0,0 +1,296 @@
|
||||
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())
|
||||
);
|
||||
Reference in New Issue
Block a user