first commit
This commit is contained in:
@@ -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 |
@@ -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
@@ -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
@@ -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())
|
||||
);
|
||||
@@ -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">문항 수 (1–20)</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);
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user