first commit

This commit is contained in:
2026-08-10 21:35:17 +09:00
commit 02d9d0785b
22 changed files with 3853 additions and 0 deletions
+125
View File
@@ -0,0 +1,125 @@
class HistoryPage extends HTMLElement {
async connectedCallback() {
this.className = 'screen';
this.replaceChildren(createTopbar([createButton('새 시험 시작', () => showStartScreen())]));
const heading = document.createElement('h1');
heading.className = 'page-title';
heading.textContent = '지난 기록';
this.appendChild(heading);
const body = document.createElement('div');
body.innerHTML = `<div class="inline-loading"><span class="spinner small"></span>
<span>기록을 불러오는 중이에요…</span></div>`;
this.appendChild(body);
let tests;
try {
({ tests } = await api.tests());
} catch (err) {
body.replaceChildren();
const error = document.createElement('p');
error.className = 'error-box';
error.textContent = err.message;
body.appendChild(error);
return;
}
if (!tests.length) {
body.replaceChildren();
const empty = document.createElement('div');
empty.className = 'card empty';
empty.innerHTML = `<p>아직 저장된 기록이 없습니다.</p>`;
empty.appendChild(
createButton('첫 시험 시작하기', () => showStartScreen(), 'primary')
);
body.appendChild(empty);
return;
}
body.replaceChildren(this.renderStats(tests), this.renderList(tests));
}
renderStats(tests) {
const totals = tests.reduce(
(acc, test) => ({
questions: acc.questions + test.score.total,
correct: acc.correct + test.score.correct,
overconfident: acc.overconfident + test.score.overconfident,
}),
{ questions: 0, correct: 0, overconfident: 0 }
);
const card = document.createElement('div');
card.className = 'card';
card.innerHTML = `
<div class="insight">
<div><strong></strong>본 시험</div>
<div><strong></strong>푼 문제</div>
<div><strong></strong>평균 정답률</div>
<div class="${totals.overconfident ? 'danger' : ''}">
<strong></strong>확신했지만 틀림
</div>
</div>
`;
const tiles = card.querySelectorAll('.insight strong');
tiles[0].textContent = tests.length;
tiles[1].textContent = totals.questions;
tiles[2].textContent = `${Math.round((totals.correct / totals.questions) * 100)}%`;
tiles[3].textContent = totals.overconfident;
card.querySelector('.insight').classList.toggle('risk', totals.overconfident > 0);
return card;
}
renderList(tests) {
const list = document.createElement('div');
list.className = 'result-list';
tests.forEach((test) => {
const item = document.createElement('button');
item.type = 'button';
item.className = 'card history-item';
item.onclick = () => showHistoryDetail(test.id);
const top = document.createElement('div');
top.className = 'history-top';
const range = document.createElement('span');
range.className = 'history-range';
range.textContent = `${test.school} ${test.subject} · ${test.chapter}`;
const percent = document.createElement('span');
percent.className = 'history-score';
percent.textContent = `${test.score.percent}`;
top.append(range, percent);
const meta = document.createElement('p');
meta.className = 'muted';
meta.textContent =
`${test.student} · ${formatDate(test.createdAt)} · ` +
`난이도 ${DIFFICULTY_LABEL[test.difficulty] ?? test.difficulty} · ` +
`${test.score.total}문제 중 ${test.score.correct}문제 정답`;
const tags = document.createElement('p');
tags.className = 'history-tags';
const badges = [];
if (test.score.overconfident) {
badges.push(['bad', `확신했지만 틀림 ${test.score.overconfident}`]);
}
if (test.explainedCount) badges.push(['', `해설 ${test.explainedCount}`]);
if (test.twinCount) badges.push(['', `쌍둥이 문제 ${test.twinCount}`]);
if (test.askedCount) badges.push(['', `후속 질문 ${test.askedCount}`]);
badges.forEach(([tone, label]) => {
const badge = document.createElement('span');
badge.className = `badge ${tone}`.trim();
badge.textContent = label;
tags.appendChild(badge);
});
item.append(top, meta, tags);
list.appendChild(item);
});
return list;
}
}
customElements.define('history-page', HistoryPage);
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+25
View File
@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="CogMind — 문제를 풀고 확신도를 기록하면 AI가 메타인지 상태를 분석해 주는 학습 서비스">
<meta name="color-scheme" content="light dark">
<title>CogMind - 메타인지 학습 진단</title>
<link rel="icon" type="image/png" href="/public/images/favicon.png">
<link rel="apple-touch-icon" href="/public/images/favicon.png">
<link rel="stylesheet" href="/public/main.css">
</head>
<body>
<main id="app"></main>
<script src="/public/main.js"></script>
<script src="/public/report.js"></script>
<script src="/public/optionPage.js"></script>
<script src="/public/testPage.js"></script>
<script src="/public/historyPage.js"></script>
</body>
</html>
+728
View File
@@ -0,0 +1,728 @@
:root {
--bg: #f4f6fb;
--surface: #ffffff;
--surface-2: #eef2f9;
--border: #dbe2ef;
--ink: #112d4e;
--ink-2: #5b6b84;
--primary: #3f72af;
--primary-ink: #ffffff;
--primary-soft: #e6eef8;
--ok: #158a5b;
--ok-soft: #e3f5ec;
--bad: #c0392b;
--bad-soft: #fdecea;
--warn: #b7791f;
--warn-soft: #fdf3e0;
--shadow: 0 1px 2px rgb(17 45 78 / 6%), 0 8px 24px rgb(17 45 78 / 8%);
--radius: 14px;
--radius-sm: 10px;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0e1526;
--surface: #161f33;
--surface-2: #1d2842;
--border: #2a3752;
--ink: #e8eef9;
--ink-2: #9aabc6;
--primary: #6ea8fe;
--primary-ink: #0e1526;
--primary-soft: #1d2b47;
--ok: #4ade80;
--ok-soft: #16301f;
--bad: #f87171;
--bad-soft: #34191a;
--warn: #fbbf24;
--warn-soft: #33270e;
--shadow: 0 1px 2px rgb(0 0 0 / 30%), 0 8px 24px rgb(0 0 0 / 35%);
}
}
* {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
min-height: 100dvh;
background: var(--bg);
color: var(--ink);
font-family: 'Pretendard', -apple-system, BlinkMacSystemFont, 'Apple SD Gothic Neo',
'Segoe UI', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
font-size: 16px;
line-height: 1.6;
word-break: keep-all;
}
.screen {
display: block;
width: 100%;
max-width: 760px;
margin: 0 auto;
padding: 24px 20px 64px;
animation: fade-in 0.25s ease;
}
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(6px);
}
}
@media (prefers-reduced-motion: reduce) {
* {
animation: none !important;
transition: none !important;
}
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 20px;
}
h1,
h2,
h3 {
line-height: 1.3;
margin: 0;
}
.muted {
color: var(--ink-2);
font-size: 0.9rem;
}
button {
font: inherit;
font-weight: 600;
color: var(--ink);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 16px;
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease, transform 0.05s ease;
}
button:hover:not(:disabled) {
border-color: var(--primary);
}
button:active:not(:disabled) {
transform: translateY(1px);
}
button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
button.primary {
background: var(--primary);
border-color: var(--primary);
color: var(--primary-ink);
}
button.primary:hover:not(:disabled) {
filter: brightness(1.08);
}
button.block {
width: 100%;
padding: 14px;
font-size: 1.05rem;
}
:focus-visible {
outline: 3px solid var(--primary);
outline-offset: 2px;
}
select,
input[type='number'],
input[type='text'] {
font: inherit;
width: 100%;
padding: 11px 12px;
color: var(--ink);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
label {
display: block;
font-size: 0.85rem;
font-weight: 700;
color: var(--ink-2);
margin-bottom: 6px;
}
.field + .field {
margin-top: 16px;
}
.row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
@media (max-width: 520px) {
.row {
grid-template-columns: 1fr;
}
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.chip {
padding: 7px 13px;
font-size: 0.9rem;
border-radius: 999px;
background: var(--surface-2);
border: 1px solid transparent;
}
.chip[aria-pressed='true'] {
background: var(--primary-soft);
border-color: var(--primary);
color: var(--primary);
}
.hero {
text-align: center;
padding: 32px 0 28px;
}
.hero img {
width: min(240px, 70%);
height: auto;
}
.hero h1 {
font-size: 1.5rem;
margin-top: 12px;
}
.hero p {
color: var(--ink-2);
margin: 8px 0 0;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.brand {
font-weight: 800;
color: var(--primary);
background: none;
border: 0;
padding: 0;
}
.topbar-actions {
display: flex;
gap: 8px;
align-items: center;
}
.topbar-actions button {
font-size: 0.85rem;
padding: 7px 12px;
}
.page-title {
font-size: 1.4rem;
margin: 4px 0 16px;
}
.progress-track {
height: 8px;
background: var(--surface-2);
border-radius: 999px;
overflow: hidden;
margin-bottom: 24px;
}
.progress-fill {
height: 100%;
width: 0;
background: var(--primary);
border-radius: 999px;
transition: width 0.3s ease;
}
.question-text {
font-size: 1.3rem;
font-weight: 700;
margin: 0 0 20px;
}
.options {
display: flex;
flex-direction: column;
gap: 10px;
margin: 0;
padding: 0;
list-style: none;
}
.option {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
text-align: left;
font-weight: 500;
padding: 14px 16px;
background: var(--surface);
}
.option .key {
flex: 0 0 28px;
height: 28px;
display: grid;
place-items: center;
font-size: 0.85rem;
font-weight: 700;
color: var(--ink-2);
background: var(--surface-2);
border-radius: 8px;
}
.option[aria-pressed='true'] {
border-color: var(--primary);
background: var(--primary-soft);
}
.option[aria-pressed='true'] .key {
background: var(--primary);
color: var(--primary-ink);
}
.confidence {
margin-top: 24px;
padding-top: 20px;
border-top: 1px dashed var(--border);
}
.actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 24px;
}
button.ghost {
background: transparent;
color: var(--ink-2);
}
.score {
text-align: center;
padding: 28px 20px;
}
.score .big {
font-size: 3rem;
font-weight: 800;
line-height: 1;
color: var(--primary);
}
.insight {
margin-top: 16px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 10px;
}
.insight div {
padding: 12px;
border-radius: var(--radius-sm);
background: var(--surface-2);
}
.insight strong {
display: block;
font-size: 1.4rem;
}
.insight.risk div.danger {
background: var(--bad-soft);
color: var(--bad);
}
.insight div.lucky {
background: var(--warn-soft);
color: var(--warn);
}
.insight div.solid {
background: var(--ok-soft);
color: var(--ok);
}
.result-list {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 24px;
}
.result-item {
padding: 16px;
}
.result-head {
display: flex;
align-items: flex-start;
gap: 10px;
font-weight: 700;
}
.badge {
flex: 0 0 auto;
font-size: 0.78rem;
font-weight: 700;
padding: 3px 9px;
border-radius: 999px;
}
.badge.ok {
background: var(--ok-soft);
color: var(--ok);
}
.badge.bad {
background: var(--bad-soft);
color: var(--bad);
}
.answers {
margin: 10px 0 0;
font-size: 0.92rem;
color: var(--ink-2);
}
.answers span {
font-weight: 600;
color: var(--ink);
}
.result-actions {
display: flex;
gap: 8px;
margin-top: 14px;
}
.result-actions button {
font-size: 0.9rem;
padding: 8px 12px;
}
.ai-box {
margin-top: 12px;
padding: 14px;
border-radius: var(--radius-sm);
background: var(--surface-2);
border-left: 3px solid var(--primary);
white-space: pre-wrap;
font-size: 0.95rem;
}
.ai-box.error {
border-left-color: var(--bad);
color: var(--bad);
}
.badge:not(.ok):not(.bad) {
background: var(--surface-2);
color: var(--ink-2);
}
.feedback {
margin-top: 16px;
}
.feedback h2 {
font-size: 1.05rem;
color: var(--primary);
margin-bottom: 12px;
}
.feedback h3 {
font-size: 0.95rem;
margin: 18px 0 6px;
}
.feedback-summary {
font-size: 1.15rem;
font-weight: 700;
margin: 0 0 10px;
}
.feedback-detail {
margin: 0;
white-space: pre-wrap;
}
.twin {
margin-top: 12px;
padding: 14px;
border-radius: var(--radius-sm);
background: var(--surface-2);
border-left: 3px solid var(--ok);
}
.twin-title {
font-weight: 700;
margin: 0 0 10px;
}
.twin-options {
margin: 0 0 10px;
padding-left: 22px;
}
.twin-options li {
margin-bottom: 4px;
}
.twin details summary {
cursor: pointer;
font-size: 0.9rem;
color: var(--ink-2);
display: inline;
}
.twin details[open] summary {
font-weight: 700;
color: var(--ok);
}
.history-item {
display: block;
width: 100%;
text-align: left;
font-weight: 400;
padding: 16px;
}
.history-top {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
}
.history-range {
font-weight: 700;
}
.history-score {
flex: 0 0 auto;
font-size: 1.25rem;
font-weight: 800;
color: var(--primary);
}
.history-item .muted {
margin: 6px 0 0;
}
.history-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 10px 0 0;
}
.history-tags:empty {
display: none;
}
.empty {
text-align: center;
}
.empty p {
margin-top: 0;
color: var(--ink-2);
}
.explanation:empty {
display: none;
}
button.ask-toggle {
margin-top: 10px;
font-size: 0.9rem;
padding: 8px 12px;
}
.chat {
margin-top: 12px;
padding: 14px;
border-radius: var(--radius-sm);
background: var(--surface-2);
}
.chat[hidden] {
display: none;
}
.chat-heading {
margin: 0 0 12px;
font-size: 0.85rem;
font-weight: 700;
color: var(--ink-2);
}
.chat-log {
display: flex;
flex-direction: column;
gap: 8px;
}
.chat-log:empty {
display: none;
}
.chat-message {
max-width: 85%;
padding: 10px 14px;
border-radius: var(--radius-sm);
font-size: 0.95rem;
white-space: pre-wrap;
}
.chat-message.user {
align-self: flex-end;
background: var(--primary);
color: var(--primary-ink);
border-bottom-right-radius: 4px;
}
.chat-message.assistant {
align-self: flex-start;
background: var(--surface);
border: 1px solid var(--border);
border-bottom-left-radius: 4px;
}
.chat-message.pending {
color: var(--ink-2);
font-style: italic;
}
.chat-message.error {
background: var(--bad-soft);
color: var(--bad);
}
.chat-form {
display: flex;
align-items: flex-end;
gap: 8px;
margin-top: 12px;
}
.chat-form textarea {
font: inherit;
flex: 1;
min-height: 44px;
max-height: 160px;
padding: 11px 12px;
color: var(--ink);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
resize: vertical;
}
.chat-form button {
flex: 0 0 auto;
height: 44px;
}
.inline-loading {
display: flex;
align-items: center;
gap: 10px;
color: var(--ink-2);
font-size: 0.95rem;
}
.spinner.small {
width: 18px;
height: 18px;
border-width: 2px;
flex: 0 0 auto;
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
padding: 80px 20px;
text-align: center;
color: var(--ink-2);
}
.spinner {
width: 34px;
height: 34px;
border: 3px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.dots::after {
content: '';
animation: dots 1.4s steps(4, end) infinite;
}
@keyframes dots {
to {
content: '...';
}
}
.error-box {
margin-top: 16px;
padding: 12px 14px;
border-radius: var(--radius-sm);
background: var(--bad-soft);
color: var(--bad);
font-size: 0.92rem;
}
+296
View File
@@ -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())
);
+144
View File
@@ -0,0 +1,144 @@
const DIFFICULTIES = [
{ id: 'easy', label: '쉬움' },
{ id: 'medium', label: '보통' },
{ id: 'hard', label: '어려움' },
];
const fillSelect = (select, values) => {
select.replaceChildren(
...values.map((value) => {
const option = document.createElement('option');
option.value = value;
option.textContent = value;
return option;
})
);
select.disabled = values.length === 0;
};
class OptionPage extends HTMLElement {
async connectedCallback() {
this.className = 'screen';
this.innerHTML = `
<div class="hero">
<picture>
<source srcset="/public/images/logo-dark.png"
media="(prefers-color-scheme: dark)">
<img src="/public/images/logo-light.png" alt="CogMind">
</picture>
<h1>공부의 시작은 나를 아는것</h1>
<p>문제를 풀면 AI가 여러분을 메타인지하고 피드백 해줘요.</p>
</div>
<div class="card">
<div class="field">
<label for="student">이름</label>
<input id="student" type="text" placeholder="예: 이승준" autocomplete="off"
maxlength="30">
</div>
<div class="field">
<label for="school">학교 종류</label>
<select id="school"></select>
</div>
<div class="row" style="margin-top:16px">
<div class="field" style="margin:0">
<label for="subject">과목</label>
<select id="subject"></select>
</div>
<div class="field" style="margin:0">
<label for="chapter">단원</label>
<select id="chapter"></select>
</div>
</div>
<div class="row" style="margin-top:16px">
<div class="field" style="margin:0">
<label>난이도</label>
<div id="difficulty"></div>
</div>
<div class="field" style="margin:0">
<label for="amount">문항 수 (120)</label>
<input id="amount" type="number" min="1" max="20" value="5">
</div>
</div>
<div id="error"></div>
<button class="primary block" id="start" style="margin-top:24px">
학습 진단 시작하기
</button>
<button class="block" id="history" style="margin-top:10px">
지난 기록 보기
</button>
</div>
`;
const errorBox = this.querySelector('#error');
const showMessage = (message) => {
errorBox.className = message ? 'error-box' : '';
errorBox.textContent = message ?? '';
};
if (this.dataset.error) showMessage(this.dataset.error);
this.querySelector('#history').onclick = () => showHistoryScreen();
let difficulty = 'medium';
this.querySelector('#difficulty').appendChild(
createChipGroup(DIFFICULTIES, {
selected: difficulty,
onSelect: (value) => {
difficulty = value;
},
})
);
const school = this.querySelector('#school');
const subject = this.querySelector('#subject');
const chapter = this.querySelector('#chapter');
const student = this.querySelector('#student');
const amount = this.querySelector('#amount');
const startButton = this.querySelector('#start');
let chapters = {};
const onSchoolChange = () => {
fillSelect(subject, Object.keys(chapters[school.value] ?? {}));
onSubjectChange();
};
const onSubjectChange = () => {
fillSelect(chapter, chapters[school.value]?.[subject.value] ?? []);
};
school.onchange = onSchoolChange;
subject.onchange = onSubjectChange;
startButton.disabled = true;
try {
chapters = await api.chapters();
fillSelect(school, Object.keys(chapters));
onSchoolChange();
startButton.disabled = false;
} catch (err) {
showMessage(`단원 목록을 불러오지 못했습니다. ${err.message}`);
return;
}
const submit = () => {
if (!chapter.value) {
showMessage('과목과 단원을 선택해 주세요.');
return;
}
startQuiz(
{
student: student.value.trim() || '학생',
school: school.value,
subject: subject.value,
chapter: chapter.value,
difficulty,
},
Math.min(Math.max(Number(amount.value) || 5, 1), 20)
);
};
startButton.onclick = submit;
student.addEventListener('keydown', (event) => {
if (event.key === 'Enter') submit();
});
}
}
customElements.define('option-page', OptionPage);
+336
View File
@@ -0,0 +1,336 @@
const createAiButton = (label, container, request, onSuccess) => {
const button = createButton(label, async () => {
button.disabled = true;
button.textContent = '생성 중…';
const box = document.createElement('div');
box.className = 'ai-box';
box.textContent = 'AI가 답변을 작성하고 있어요…';
container.appendChild(box);
try {
const result = await request();
box.remove();
onSuccess(result);
} catch (err) {
box.classList.add('error');
box.textContent = err.message;
}
button.textContent = label;
button.disabled = false;
});
return button;
};
const renderTwin = (twin, order) => {
const box = document.createElement('div');
box.className = 'twin';
const title = document.createElement('p');
title.className = 'twin-title';
title.textContent = `쌍둥이 문제 ${order}. ${twin.question}`;
box.appendChild(title);
const options = document.createElement('ol');
options.className = 'twin-options';
shuffle([twin.correct_answer, ...twin.incorrect_answers]).forEach((option) => {
const item = document.createElement('li');
item.textContent = option;
options.appendChild(item);
});
box.appendChild(options);
const answer = document.createElement('details');
const summary = document.createElement('summary');
summary.textContent = '정답 보기';
const value = document.createElement('span');
value.textContent = ` ${twin.correct_answer}`;
answer.append(summary, value);
box.appendChild(answer);
return box;
};
const renderMessage = (role, content) => {
const bubble = document.createElement('div');
bubble.className = `chat-message ${role}`;
bubble.textContent = content;
return bubble;
};
const renderChat = (record, question, index) => {
const chat = document.createElement('div');
chat.className = 'chat';
const heading = document.createElement('p');
heading.className = 'chat-heading';
heading.textContent = '이어서 질문하기';
chat.appendChild(heading);
const log = document.createElement('div');
log.className = 'chat-log';
question.conversation.forEach((m) => log.appendChild(renderMessage(m.role, m.content)));
chat.appendChild(log);
const form = document.createElement('form');
form.className = 'chat-form';
const input = document.createElement('textarea');
input.rows = 1;
input.placeholder = '이 문제에 대해 더 궁금한 점을 물어보세요';
input.setAttribute('aria-label', '후속 질문');
const sendButton = document.createElement('button');
sendButton.type = 'submit';
sendButton.className = 'primary';
sendButton.textContent = '질문';
form.append(input, sendButton);
chat.appendChild(form);
let pending = false;
async function send(message) {
const text = message.trim();
if (!text || pending) return;
pending = true;
sendButton.disabled = true;
input.value = '';
log.appendChild(renderMessage('user', text));
const answering = renderMessage('assistant', 'AI가 답변을 작성하고 있어요…');
answering.classList.add('pending');
log.appendChild(answering);
answering.scrollIntoView({ block: 'nearest' });
try {
const { messages } = await api.ask(record.id, index, text);
question.conversation.push(...messages);
answering.classList.remove('pending');
answering.textContent = messages.find((m) => m.role === 'assistant')?.content ?? '';
} catch (err) {
answering.classList.remove('pending');
answering.classList.add('error');
answering.textContent = err.message;
input.value = text;
}
pending = false;
sendButton.disabled = false;
}
form.onsubmit = (event) => {
event.preventDefault();
send(input.value);
};
input.onkeydown = (event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
send(input.value);
}
};
chat.focusInput = () => input.focus();
return chat;
};
const renderFeedbackCard = (record) => {
const card = document.createElement('div');
card.className = 'card feedback';
const heading = document.createElement('h2');
heading.textContent = '메타인지 평가';
card.appendChild(heading);
const body = document.createElement('div');
card.appendChild(body);
const paint = (feedback) => {
body.replaceChildren();
const summary = document.createElement('p');
summary.className = 'feedback-summary';
summary.textContent = feedback.summary;
const detail = document.createElement('p');
detail.className = 'feedback-detail';
detail.textContent = feedback.detail;
const recommendHeading = document.createElement('h3');
recommendHeading.textContent = '학습 추천';
const recommend = document.createElement('p');
recommend.className = 'feedback-detail';
recommend.textContent = feedback.recommendation;
body.append(summary, detail, recommendHeading, recommend);
};
if (record.feedback) {
paint(record.feedback);
return card;
}
body.innerHTML = `<div class="inline-loading"><span class="spinner small"></span>
<span>AI가 이번 시험을 분석하고 있어요…</span></div>`;
api.feedback(record.id)
.then(({ feedback }) => {
record.feedback = feedback;
paint(feedback);
})
.catch((err) => {
body.replaceChildren();
const error = document.createElement('p');
error.className = 'error-box';
error.textContent = err.message;
body.append(
error,
createAiButton('다시 시도', body, () => api.feedback(record.id), ({ feedback }) => {
record.feedback = feedback;
paint(feedback);
})
);
});
return card;
};
const renderQuestionCard = (record, question, index) => {
const item = document.createElement('div');
item.className = 'card result-item';
const head = document.createElement('div');
head.className = 'result-head';
const badge = document.createElement('span');
badge.className = `badge ${question.is_correct ? 'ok' : 'bad'}`;
badge.textContent = question.is_correct ? '정답' : '오답';
const text = document.createElement('span');
text.textContent = `${index + 1}. ${question.question}`;
head.append(badge, text);
item.appendChild(head);
const answers = document.createElement('p');
answers.className = 'answers';
answers.innerHTML =
'내 답: <span></span> · 정답: <span></span><br>내가 밝힌 확신도: <span></span>';
const spans = answers.querySelectorAll('span');
spans[0].textContent = question.user_answer ?? '무응답';
spans[1].textContent = question.correct_answer;
spans[2].textContent = CONFIDENCE_LABEL(question.confidence);
item.appendChild(answers);
const detail = document.createElement('div');
detail.className = 'result-detail';
item.appendChild(detail);
const explanationSection = document.createElement('div');
explanationSection.className = 'explanation';
detail.appendChild(explanationSection);
question.conversation ??= [];
const paintExplanation = (explanation) => {
const box = document.createElement('div');
box.className = 'ai-box';
box.textContent = explanation;
explanationSection.appendChild(box);
const chat = renderChat(record, question, index);
if (!question.conversation.length) {
chat.hidden = true;
const askButton = createButton(
'이어서 질문하기',
() => {
chat.hidden = false;
askButton.remove();
chat.focusInput();
},
'ask-toggle'
);
explanationSection.appendChild(askButton);
}
explanationSection.appendChild(chat);
};
let twinCount = 0;
const paintTwin = (twin) => {
twinCount += 1;
detail.appendChild(renderTwin(twin, twinCount));
};
if (question.explanation) paintExplanation(question.explanation);
(question.children ?? []).forEach(paintTwin);
const actions = document.createElement('div');
actions.className = 'result-actions';
const explainButton = createAiButton(
'AI 해설 보기',
explanationSection,
() => api.explain(record.id, index),
({ explanation }) => {
question.explanation = explanation;
paintExplanation(explanation);
explainButton.remove();
}
);
if (!question.explanation) actions.appendChild(explainButton);
actions.appendChild(
createAiButton(
'쌍둥이 문제 만들기',
detail,
() => api.twin(record.id, index),
({ twin }) => {
question.children = [...(question.children ?? []), twin];
paintTwin(twin);
}
)
);
item.appendChild(actions);
return item;
};
const renderReport = (record) => {
const fragment = document.createDocumentFragment();
const { score } = record;
const summary = document.createElement('div');
summary.className = 'card score';
summary.innerHTML = `
<p class="muted range"></p>
<div class="big"></div>
<p class="muted count"></p>
<div class="insight">
<div class="${score.overconfident ? 'danger' : ''}">
<strong></strong>확신했지만 틀림
</div>
<div class="${score.lucky ? 'lucky' : ''}"><strong></strong>운으로 맞힘</div>
<div class="${score.solid ? 'solid' : ''}"><strong></strong>확실히 아는 것</div>
</div>
<p class="muted note"></p>
`;
summary.querySelector('.range').textContent =
`${record.student} · ${record.school} ${record.subject} · ${record.chapter} · ` +
`난이도 ${DIFFICULTY_LABEL[record.difficulty] ?? record.difficulty} · ${formatDate(record.createdAt)}`;
summary.querySelector('.big').textContent = `${score.percent}`;
summary.querySelector('.count').textContent =
`${score.total}문제 중 ${score.correct}문제 정답`;
const tiles = summary.querySelectorAll('.insight strong');
tiles[0].textContent = score.overconfident;
tiles[1].textContent = score.lucky;
tiles[2].textContent = score.solid;
summary.querySelector('.note').textContent = score.overconfident
? '확신했지만 틀린 문제가 메타인지의 빈틈입니다. 이 개념부터 다시 확인해 보세요.'
: '안다고 착각한 문제가 없습니다. 자신의 이해도를 잘 파악하고 있어요.';
fragment.appendChild(summary);
fragment.appendChild(renderFeedbackCard(record));
const list = document.createElement('div');
list.className = 'result-list';
record.questions.forEach((question, index) =>
list.appendChild(renderQuestionCard(record, question, index))
);
fragment.appendChild(list);
return fragment;
};
+136
View File
@@ -0,0 +1,136 @@
class TestPage extends HTMLElement {
connectedCallback() {
this.className = 'screen';
this.renderQuestion();
}
renderHeader(step) {
const wrapper = document.createElement('div');
const counter = document.createElement('span');
counter.className = 'muted';
counter.textContent = `${step} / ${state.questions.length}`;
const topbar = createTopbar([counter]);
topbar.querySelector('.brand').onclick = () => {
if (confirm('처음 화면으로 돌아갈까요? 지금까지의 응답은 사라집니다.')) {
showStartScreen();
}
};
const track = document.createElement('div');
track.className = 'progress-track';
const fill = document.createElement('div');
fill.className = 'progress-fill';
fill.style.width = `${(step / state.questions.length) * 100}%`;
track.appendChild(fill);
wrapper.append(topbar, track);
return wrapper;
}
renderQuestion() {
const index = state.current;
const question = state.questions[index];
let selected = state.answers[index] ?? null;
let confidence = state.confidences[index] ?? null;
this.replaceChildren(this.renderHeader(index + 1));
const card = document.createElement('div');
card.className = 'card';
const title = document.createElement('p');
title.className = 'question-text';
title.textContent = `${index + 1}. ${question.question}`;
card.appendChild(title);
const list = document.createElement('ul');
list.className = 'options';
const optionButtons = question.options.map((option, i) => {
const item = document.createElement('li');
const button = document.createElement('button');
button.type = 'button';
button.className = 'option';
button.setAttribute('aria-pressed', String(option === selected));
const key = document.createElement('span');
key.className = 'key';
key.textContent = String.fromCharCode(65 + i);
const text = document.createElement('span');
text.textContent = option;
button.append(key, text);
button.onclick = () => {
selected = option;
optionButtons.forEach((b) => b.setAttribute('aria-pressed', 'false'));
button.setAttribute('aria-pressed', 'true');
updateSubmitState();
};
item.appendChild(button);
list.appendChild(item);
return button;
});
card.appendChild(list);
const confidenceBox = document.createElement('div');
confidenceBox.className = 'confidence';
const confidenceLabel = document.createElement('label');
confidenceLabel.textContent = '이 답에 얼마나 확신하나요?';
confidenceBox.append(
confidenceLabel,
createChipGroup(CONFIDENCE, {
selected: confidence,
onSelect: (value) => {
confidence = value;
updateSubmitState();
},
})
);
card.appendChild(confidenceBox);
const actions = document.createElement('div');
actions.className = 'actions';
if (index > 0) {
actions.appendChild(createButton('이전 문제', () => history.back(), 'ghost'));
}
const isLast = index === state.questions.length - 1;
const submitButton = document.createElement('button');
submitButton.className = 'primary';
submitButton.textContent = isLast ? '결과 보기' : '다음 문제';
submitButton.onclick = () => {
state.answers[index] = selected;
state.confidences[index] = confidence;
if (isLast) {
finishQuiz();
} else {
goToQuestion(index + 1);
}
};
actions.appendChild(submitButton);
card.appendChild(actions);
const updateSubmitState = () => {
submitButton.disabled = !(selected && confidence);
};
updateSubmitState();
this.appendChild(card);
this.onkeydown = (event) => {
if (event.target.tagName === 'INPUT') return;
const fromLetter = event.key.toUpperCase().charCodeAt(0) - 65;
const fromNumber = Number(event.key) - 1;
const at = optionButtons[fromLetter] ?? optionButtons[fromNumber];
if (event.key.length === 1 && at) {
at.click();
at.focus();
}
};
this.tabIndex = -1;
this.focus({ preventScroll: true });
}
}
customElements.define('test-page', TestPage);