refactor: redesign web UX with progressive disclosure and cleaned layout
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
const state = {
|
||||
var state = {
|
||||
session: null,
|
||||
selectedQuestion: null,
|
||||
lastAnswer: null,
|
||||
@@ -7,410 +7,401 @@ const state = {
|
||||
assetPrompt: null,
|
||||
};
|
||||
|
||||
const els = {
|
||||
loginView: document.querySelector("#login-view"),
|
||||
workspaceView: document.querySelector("#workspace-view"),
|
||||
loginError: document.querySelector("#login-error"),
|
||||
sessionForm: document.querySelector("#session-form"),
|
||||
answerForm: document.querySelector("#answer-form"),
|
||||
answerText: document.querySelector("#answer-text"),
|
||||
answerButton: document.querySelector("#answer-button"),
|
||||
questions: document.querySelector("#questions"),
|
||||
feedback: document.querySelector("#feedback"),
|
||||
progress: document.querySelector("#progress"),
|
||||
refreshProgress: document.querySelector("#refresh-progress"),
|
||||
materialForm: document.querySelector("#material-form"),
|
||||
assetForm: document.querySelector("#asset-form"),
|
||||
ontology: document.querySelector("#ontology"),
|
||||
assetOutput: document.querySelector("#asset-output"),
|
||||
assetConcept: document.querySelector("#asset-concept"),
|
||||
assetButton: document.querySelector("#asset-button"),
|
||||
status: document.querySelector("#status-line"),
|
||||
error: document.querySelector("#error-line"),
|
||||
title: document.querySelector("#session-title"),
|
||||
userInfo: document.querySelector("#user-info"),
|
||||
logoutButton: document.querySelector("#logout-button"),
|
||||
var els = {
|
||||
loginView: document.querySelector("#login-view"),
|
||||
workspaceView: document.querySelector("#workspace-view"),
|
||||
loginError: document.querySelector("#login-error"),
|
||||
sessionForm: document.querySelector("#session-form"),
|
||||
answerForm: document.querySelector("#answer-form"),
|
||||
answerText: document.querySelector("#answer-text"),
|
||||
answerButton: document.querySelector("#answer-button"),
|
||||
questions: document.querySelector("#questions"),
|
||||
setupCard: document.querySelector("#setup-card"),
|
||||
questionBar: document.querySelector("#question-bar"),
|
||||
answerArea: document.querySelector("#answer-area"),
|
||||
feedbackContent: document.querySelector("#feedback-content"),
|
||||
feedbackEmpty: document.querySelector("#feedback-empty"),
|
||||
progressContent: document.querySelector("#progress-content"),
|
||||
progressDivider: document.querySelector("#progress-divider"),
|
||||
refreshProgress: document.querySelector("#refresh-progress"),
|
||||
materialForm: document.querySelector("#material-form"),
|
||||
assetForm: document.querySelector("#asset-form"),
|
||||
ontology: document.querySelector("#ontology"),
|
||||
assetOutput: document.querySelector("#asset-output"),
|
||||
assetConcept: document.querySelector("#asset-concept"),
|
||||
assetButton: document.querySelector("#asset-button"),
|
||||
status: document.querySelector("#status-line"),
|
||||
error: document.querySelector("#error-line"),
|
||||
userInfo: document.querySelector("#user-info"),
|
||||
logoutButton: document.querySelector("#logout-button"),
|
||||
stepIndicator: document.querySelector("#step-indicator"),
|
||||
toolsToggle: document.querySelector("#tools-toggle"),
|
||||
toolsPanel: document.querySelector("#tools-panel"),
|
||||
gradeDisplay: document.querySelector("#grade-display"),
|
||||
gradeStrength: document.querySelector("#grade-strength"),
|
||||
scoreMetrics: document.querySelector("#score-metrics"),
|
||||
gapsBlock: document.querySelector("#gaps-block"),
|
||||
followupBlock: document.querySelector("#followup-block"),
|
||||
evidenceBlock: document.querySelector("#evidence-block"),
|
||||
progressBar: document.querySelector("#progress-bar"),
|
||||
readinessPct: document.querySelector("#readiness-pct"),
|
||||
conceptMemory: document.querySelector("#concept-memory"),
|
||||
nextChallengeBlock:document.querySelector("#next-challenge-block"),
|
||||
};
|
||||
|
||||
const readinessClassMap = {
|
||||
unknown: "pill-neutral",
|
||||
fragile: "pill-weak",
|
||||
improving: "pill-warn",
|
||||
interview_ready: "pill-good",
|
||||
strong_signal: "pill-strong",
|
||||
};
|
||||
|
||||
const reviewClassMap = {
|
||||
candidate: "pill-neutral",
|
||||
reviewed: "pill-good",
|
||||
};
|
||||
|
||||
const gradeClassMap = {
|
||||
miss: "grade-miss",
|
||||
partial: "grade-partial",
|
||||
solid: "grade-solid",
|
||||
strong: "grade-strong",
|
||||
var readinessClassMap = {
|
||||
unknown:"pill-neutral", fragile:"pill-weak", improving:"pill-warn",
|
||||
interview_ready:"pill-good", strong_signal:"pill-strong",
|
||||
};
|
||||
var reviewClassMap = { candidate:"pill-neutral", reviewed:"pill-good" };
|
||||
var gradeClassMap = { miss:"miss", partial:"partial", solid:"solid", strong:"strong" };
|
||||
|
||||
function setButtonLoading(button, loadingText) {
|
||||
button.disabled = true;
|
||||
button.classList.add("is-loading");
|
||||
const textEl = button.querySelector(".btn-text");
|
||||
var textEl = button.querySelector(".btn-text");
|
||||
if (textEl && loadingText) {
|
||||
textEl.dataset.originalText = textEl.textContent;
|
||||
textEl.textContent = loadingText;
|
||||
}
|
||||
}
|
||||
|
||||
function clearButtonLoading(button) {
|
||||
button.disabled = false;
|
||||
button.classList.remove("is-loading");
|
||||
const textEl = button.querySelector(".btn-text");
|
||||
var textEl = button.querySelector(".btn-text");
|
||||
if (textEl && textEl.dataset.originalText) {
|
||||
textEl.textContent = textEl.dataset.originalText;
|
||||
delete textEl.dataset.originalText;
|
||||
}
|
||||
}
|
||||
function setStatus(message, busy) {
|
||||
var textEl = els.status.querySelector(".status-text");
|
||||
if (textEl) textEl.textContent = message;
|
||||
else els.status.textContent = message;
|
||||
els.status.classList.toggle("is-busy", busy);
|
||||
}
|
||||
function showError(message) { els.error.textContent = message; }
|
||||
function clearError() { els.error.textContent = ""; }
|
||||
function escapeHTML(value) {
|
||||
return String(value)
|
||||
.replaceAll("&","&").replaceAll("<","<").replaceAll(">",">").replaceAll('"',""").replaceAll("'","'");
|
||||
}
|
||||
|
||||
els.sessionForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
clearError();
|
||||
function updateStep() {
|
||||
if (!state.session) { els.stepIndicator.textContent = ""; return; }
|
||||
if (state.lastAnswer && state.progress) {
|
||||
els.stepIndicator.textContent = "3/3 " + t("reviewComplete");
|
||||
} else if (state.lastAnswer) {
|
||||
els.stepIndicator.textContent = "2/3 " + t("answerGradedLabel");
|
||||
} else if (state.selectedQuestion) {
|
||||
els.stepIndicator.textContent = "2/3 " + t("answerQuestion");
|
||||
} else {
|
||||
els.stepIndicator.textContent = "1/3 " + t("selectQuestion");
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Session ---- */
|
||||
els.sessionForm.addEventListener("submit", function(event) {
|
||||
event.preventDefault(); clearError();
|
||||
setStatus(t("creatingSession"), true);
|
||||
setButtonLoading(
|
||||
event.submitter || document.querySelector("#start-button"),
|
||||
t("starting")
|
||||
);
|
||||
setButtonLoading(event.submitter || document.querySelector("#start-button"), t("starting"));
|
||||
|
||||
const payload = {
|
||||
user_id: value("#user-id"),
|
||||
var storedUser = JSON.parse(localStorage.getItem("tutor_user") || "{}");
|
||||
var payload = {
|
||||
user_id: storedUser.email || storedUser.id || "anonymous",
|
||||
target_role: value("#target-role"),
|
||||
stack: value("#stack")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
interview_timeline: value("#timeline"),
|
||||
stack: value("#stack").split(",").map(function(s){return s.trim()}).filter(Boolean),
|
||||
interview_timeline: "30 days",
|
||||
lang: localStorage.getItem("tutor_lang") || document.documentElement.lang || "ko",
|
||||
};
|
||||
|
||||
try {
|
||||
const session = await request("/api/v1/diagnostic-sessions", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
request("/api/v1/diagnostic-sessions", { method:"POST", body:JSON.stringify(payload) })
|
||||
.then(function(session) {
|
||||
state.session = session;
|
||||
state.selectedQuestion = session.questions[0] || null;
|
||||
state.lastAnswer = null;
|
||||
renderSession();
|
||||
renderFeedback();
|
||||
renderProgress();
|
||||
setStatus(t("sessionReady", session.id));
|
||||
updateStep();
|
||||
})
|
||||
["catch"](function(error) {
|
||||
showError(error.message); setStatus(t("ready"));
|
||||
})
|
||||
["finally"](function() {
|
||||
clearButtonLoading(document.querySelector("#start-button"));
|
||||
});
|
||||
state.session = session;
|
||||
state.selectedQuestion = session.questions[0] || null;
|
||||
state.lastAnswer = null;
|
||||
renderSession();
|
||||
renderFeedback();
|
||||
renderProgress();
|
||||
setStatus(t("sessionReady", session.id));
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
setStatus(t("ready"));
|
||||
} finally {
|
||||
clearButtonLoading(document.querySelector("#start-button"));
|
||||
}
|
||||
});
|
||||
|
||||
els.refreshProgress.addEventListener("click", async () => {
|
||||
clearError();
|
||||
await refreshProgress();
|
||||
});
|
||||
function renderSession() {
|
||||
if (!state.session) return;
|
||||
els.questionBar.style.display = "block";
|
||||
els.answerArea.style.display = "block";
|
||||
els.setupCard.style.display = "none";
|
||||
els.questions.innerHTML = "";
|
||||
|
||||
els.answerForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
clearError();
|
||||
state.session.questions.forEach(function(question) {
|
||||
var btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "question-tab";
|
||||
btn.setAttribute("role","tab");
|
||||
btn.setAttribute("aria-selected", String(state.selectedQuestion && state.selectedQuestion.id === question.id));
|
||||
btn.innerHTML = '<span class="question-tab-id">' + escapeHTML(question.id) + '</span>' + escapeHTML(tq(question.id) || question.prompt);
|
||||
btn.addEventListener("click", function() {
|
||||
state.selectedQuestion = question;
|
||||
els.answerText.value = "";
|
||||
renderSession();
|
||||
setStatus(t("selected", question.id));
|
||||
updateStep();
|
||||
});
|
||||
els.questions.appendChild(btn);
|
||||
});
|
||||
|
||||
els.answerButton.disabled = !state.selectedQuestion;
|
||||
updateStep();
|
||||
}
|
||||
|
||||
/* ---- Answer ---- */
|
||||
els.answerForm.addEventListener("submit", function(event) {
|
||||
event.preventDefault(); clearError();
|
||||
if (!state.session || !state.selectedQuestion) return;
|
||||
|
||||
setStatus(t("submittingAnswer"), true);
|
||||
setButtonLoading(event.submitter || els.answerButton, t("grading"));
|
||||
|
||||
try {
|
||||
const answer = await request(
|
||||
`/api/v1/diagnostic-sessions/${state.session.id}/answers`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
question_id: state.selectedQuestion.id,
|
||||
answer_text: els.answerText.value,
|
||||
}),
|
||||
}
|
||||
);
|
||||
state.lastAnswer = answer;
|
||||
renderFeedback();
|
||||
await refreshProgress();
|
||||
setStatus(t("answerGraded", answer.grade.overall));
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
setStatus(t("sessionReadyShort"));
|
||||
} finally {
|
||||
clearButtonLoading(els.answerButton);
|
||||
els.answerButton.disabled = !state.selectedQuestion;
|
||||
}
|
||||
request(
|
||||
"/api/v1/diagnostic-sessions/" + state.session.id + "/answers",
|
||||
{ method:"POST", body:JSON.stringify({ question_id:state.selectedQuestion.id, answer_text:els.answerText.value }) }
|
||||
)
|
||||
.then(function(answer) {
|
||||
state.lastAnswer = answer;
|
||||
renderFeedback();
|
||||
refreshProgress();
|
||||
setStatus(t("answerGraded", answer.grade.overall));
|
||||
updateStep();
|
||||
})
|
||||
["catch"](function(error) {
|
||||
showError(error.message); setStatus(t("sessionReadyShort"));
|
||||
})
|
||||
["finally"](function() {
|
||||
clearButtonLoading(els.answerButton);
|
||||
els.answerButton.disabled = !state.selectedQuestion;
|
||||
});
|
||||
});
|
||||
|
||||
els.materialForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
clearError();
|
||||
setStatus(t("ingestingMaterial"), true);
|
||||
setButtonLoading(
|
||||
event.submitter || document.querySelector("#material-button"),
|
||||
t("ingesting")
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await request("/api/v1/materials", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
title: value("#material-title"),
|
||||
source_type: value("#material-source"),
|
||||
body: value("#material-body"),
|
||||
}),
|
||||
});
|
||||
state.ontology = result.snapshot;
|
||||
renderOntology();
|
||||
setStatus(t("materialIngested", result.material.id));
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
setStatus(t("contentReady"));
|
||||
} finally {
|
||||
clearButtonLoading(document.querySelector("#material-button"));
|
||||
/* ---- Feedback (right sidebar) ---- */
|
||||
function renderFeedback() {
|
||||
if (!state.lastAnswer) {
|
||||
els.feedbackEmpty.style.display = "block";
|
||||
els.feedbackContent.style.display = "none";
|
||||
return;
|
||||
}
|
||||
});
|
||||
els.feedbackEmpty.style.display = "none";
|
||||
els.feedbackContent.style.display = "flex";
|
||||
|
||||
els.assetForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
clearError();
|
||||
setStatus(t("generatingPrompt"), true);
|
||||
setButtonLoading(event.submitter || els.assetButton, t("generating"));
|
||||
var grade = state.lastAnswer.grade;
|
||||
var gClass = gradeClassMap[grade.overall] || "";
|
||||
els.gradeDisplay.textContent = grade.overall;
|
||||
els.gradeDisplay.className = "grade-badge " + gClass;
|
||||
els.gradeStrength.textContent = grade.strengths && grade.strengths[0] ? grade.strengths[0] : t("answerWasGraded");
|
||||
|
||||
try {
|
||||
const prompt = await request("/api/v1/teaching-assets/prompts", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
concept_id: els.assetConcept.value,
|
||||
asset_type: value("#asset-type"),
|
||||
}),
|
||||
});
|
||||
state.assetPrompt = prompt;
|
||||
renderAssetPrompt();
|
||||
setStatus(t("promptGenerated", prompt.id));
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
setStatus(t("contentReady"));
|
||||
} finally {
|
||||
clearButtonLoading(els.assetButton);
|
||||
els.scoreMetrics.innerHTML = Object.entries(grade.scores || {})
|
||||
.map(function(entry) {
|
||||
return '<div class="metric-row"><span>' + escapeHTML(entry[0].replaceAll("_"," ")) + '</span><strong>' + entry[1] + '/4</strong></div>';
|
||||
})
|
||||
.join("");
|
||||
|
||||
renderBlock(els.gapsBlock, t("gaps"), grade.gaps);
|
||||
if (grade.follow_up && grade.follow_up.needed) {
|
||||
els.followupBlock.className = "feedback-block has-content";
|
||||
els.followupBlock.innerHTML = "<h4>" + t("followUp") + "</h4><p class='challenge-block'>" + escapeHTML(grade.follow_up.question) + "</p>";
|
||||
} else {
|
||||
els.followupBlock.className = "feedback-block";
|
||||
els.followupBlock.innerHTML = "";
|
||||
}
|
||||
});
|
||||
|
||||
function renderSession() {
|
||||
if (!state.session) return;
|
||||
els.title.textContent = `${state.session.target_role} — ${state.session.questions.length} ${t("questionsSuffix")}`;
|
||||
els.questions.className = "question-list";
|
||||
els.questions.innerHTML = "";
|
||||
|
||||
state.session.questions.forEach((question) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "question-button";
|
||||
button.setAttribute(
|
||||
"aria-pressed",
|
||||
String(state.selectedQuestion?.id === question.id)
|
||||
);
|
||||
button.innerHTML = `<span class="question-id">${escapeHTML(question.id)}</span>${escapeHTML(tq(question.id) || question.prompt)}`;
|
||||
button.addEventListener("click", () => {
|
||||
state.selectedQuestion = question;
|
||||
els.answerText.value = "";
|
||||
renderSession();
|
||||
setStatus(t("selected", question.id));
|
||||
});
|
||||
els.questions.append(button);
|
||||
});
|
||||
|
||||
els.answerButton.disabled = !state.selectedQuestion;
|
||||
renderBlock(els.evidenceBlock, t("evidence"),
|
||||
(grade.evidence || []).map(function(e){ return e.quote || e.id; }));
|
||||
}
|
||||
|
||||
async function refreshProgress() {
|
||||
if (!state.session) return;
|
||||
function renderBlock(el, title, items) {
|
||||
if (!items || !items.length) { el.className = "feedback-block"; el.innerHTML = ""; return; }
|
||||
el.className = "feedback-block has-content";
|
||||
el.innerHTML = "<h4>" + title + "</h4><ul>" +
|
||||
items.map(function(item){ return "<li>" + escapeHTML(item) + "</li>"; }).join("") +
|
||||
"</ul>";
|
||||
}
|
||||
|
||||
/* ---- Progress ---- */
|
||||
els.refreshProgress.addEventListener("click", function() { clearError(); refreshProgress(); });
|
||||
|
||||
function refreshProgress() {
|
||||
if (!state.session) return Promise.resolve();
|
||||
setStatus(t("refreshingProgress"), true);
|
||||
|
||||
try {
|
||||
const userID = encodeURIComponent(state.session.user_id);
|
||||
const [memory, readiness, challenge] = await Promise.all([
|
||||
request(`/api/v1/learners/${userID}/memory`),
|
||||
request(`/api/v1/learners/${userID}/readiness-map`),
|
||||
request(`/api/v1/learners/${userID}/next-challenge`),
|
||||
]);
|
||||
state.progress = { memory, readiness, challenge };
|
||||
renderProgress();
|
||||
setStatus(t("progressUpdated"));
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
renderProgress();
|
||||
}
|
||||
var userID = encodeURIComponent(state.session.user_id);
|
||||
return Promise.all([
|
||||
request("/api/v1/learners/" + userID + "/memory"),
|
||||
request("/api/v1/learners/" + userID + "/readiness-map"),
|
||||
request("/api/v1/learners/" + userID + "/next-challenge"),
|
||||
])
|
||||
.then(function(results) {
|
||||
state.progress = { memory:results[0], readiness:results[1], challenge:results[2] };
|
||||
renderProgress();
|
||||
setStatus(t("progressUpdated"));
|
||||
updateStep();
|
||||
})
|
||||
["catch"](function(error) {
|
||||
showError(error.message); renderProgress();
|
||||
});
|
||||
}
|
||||
|
||||
function renderProgress() {
|
||||
els.refreshProgress.disabled = !state.session;
|
||||
if (!state.progress) {
|
||||
els.progress.className = "feedback empty-state";
|
||||
els.progress.innerHTML = `<span class="empty-hint">${t("emptyProgress")}</span>`;
|
||||
els.progressContent.style.display = "none";
|
||||
els.progressDivider.style.display = "none";
|
||||
return;
|
||||
}
|
||||
els.progressContent.style.display = "flex";
|
||||
els.progressDivider.style.display = "block";
|
||||
|
||||
const { memory, readiness, challenge } = state.progress;
|
||||
const mastery = memory.mastery || [];
|
||||
els.progress.className = "feedback";
|
||||
els.progress.innerHTML = `
|
||||
<section>
|
||||
<div class="readiness-value">${readiness.readiness_percentage}%</div>
|
||||
<p class="status-line">${escapeHTML(memory.profile.target_role)} ${t("readiness")}</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>${t("conceptMemory")}</h2>
|
||||
<div>${mastery
|
||||
.map((item) => {
|
||||
const cls = readinessClassMap[item.state] || "pill-neutral";
|
||||
return `<span class="concept-pill ${cls}">${escapeHTML(item.concept.label)} — ${escapeHTML(item.state)}</span>`;
|
||||
})
|
||||
.join("")}</div>
|
||||
</section>
|
||||
<section>
|
||||
<h2>${t("nextChallenge")}</h2>
|
||||
<p class="status-line">${escapeHTML(challenge.concept.label)} — ${escapeHTML(challenge.ladder_level)}</p>
|
||||
<p>${escapeHTML(challenge.question)}</p>
|
||||
</section>
|
||||
`;
|
||||
var r = state.progress;
|
||||
var pct = r.readiness.readiness_percentage || 0;
|
||||
els.readinessPct.textContent = pct + "%";
|
||||
els.progressBar.style.width = pct + "%";
|
||||
els.progressBar.className = "progress-bar";
|
||||
if (pct >= 70) els.progressBar.classList.add("high");
|
||||
else if (pct >= 40) els.progressBar.classList.add("medium");
|
||||
else if (pct > 0) els.progressBar.classList.add("low");
|
||||
|
||||
var mastery = r.memory.mastery || [];
|
||||
els.conceptMemory.innerHTML = mastery
|
||||
.map(function(item) {
|
||||
var cls = readinessClassMap[item.state] || "pill-neutral";
|
||||
return '<span class="concept-pill ' + cls + '">' + escapeHTML(item.concept.label) + '</span>';
|
||||
})
|
||||
.join("") || '<span class="concept-pill pill-neutral">' + t("noCandidates") + '</span>';
|
||||
|
||||
if (r.challenge && r.challenge.question) {
|
||||
els.nextChallengeBlock.innerHTML =
|
||||
'<strong>' + escapeHTML(r.challenge.concept.label) + ' · ' + escapeHTML(r.challenge.ladder_level) + '</strong><br>' +
|
||||
escapeHTML(r.challenge.question);
|
||||
} else {
|
||||
els.nextChallengeBlock.innerHTML = "";
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Material / Ontology ---- */
|
||||
els.materialForm.addEventListener("submit", function(event) {
|
||||
event.preventDefault(); clearError();
|
||||
setStatus(t("ingestingMaterial"), true);
|
||||
setButtonLoading(event.submitter || document.querySelector("#material-button"), t("ingesting"));
|
||||
|
||||
request("/api/v1/materials", { method:"POST",
|
||||
body:JSON.stringify({ title:value("#material-title"), source_type:value("#material-source"), body:value("#material-body") })
|
||||
})
|
||||
.then(function(result) {
|
||||
state.ontology = result.snapshot;
|
||||
renderOntology();
|
||||
setStatus(t("materialIngested", result.material.id));
|
||||
})
|
||||
["catch"](function(error) { showError(error.message); setStatus(t("contentReady")); })
|
||||
["finally"](function() { clearButtonLoading(document.querySelector("#material-button")); });
|
||||
});
|
||||
|
||||
function renderOntology() {
|
||||
if (!state.ontology) {
|
||||
els.ontology.className = "ontology-view empty-state";
|
||||
els.ontology.innerHTML = `<span class="empty-hint">${t("emptyOntology")}</span>`;
|
||||
els.ontology.innerHTML = '<span class="empty-hint">' + t("emptyOntology") + '</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
const concepts = state.ontology.concepts || [];
|
||||
var concepts = state.ontology.concepts || [];
|
||||
els.ontology.className = "ontology-view";
|
||||
els.ontology.innerHTML = `
|
||||
<div class="summary-strip">
|
||||
<span class="summary-chip">${concepts.length} ${t("conceptsSuffix")}</span>
|
||||
<span class="summary-chip">${(state.ontology.edges || []).length} ${t("edgesSuffix")}</span>
|
||||
<span class="summary-chip">${(state.ontology.gaps || []).length} ${t("gapsSuffix")}</span>
|
||||
</div>
|
||||
<section>
|
||||
<h2>${t("candidateConcepts")}</h2>
|
||||
<div>${concepts
|
||||
.map((item) => {
|
||||
const cls = reviewClassMap[item.review_state] || "pill-neutral";
|
||||
return `<span class="concept-pill ${cls}">${escapeHTML(item.concept.label)} — ${escapeHTML(item.review_state)}</span>`;
|
||||
})
|
||||
.join("") || t("noCandidates")}</div>
|
||||
</section>
|
||||
`;
|
||||
els.ontology.innerHTML =
|
||||
'<div class="summary-strip">' +
|
||||
'<span class="summary-chip">' + concepts.length + ' ' + t("conceptsSuffix") + '</span>' +
|
||||
'<span class="summary-chip">' + (state.ontology.edges || []).length + ' ' + t("edgesSuffix") + '</span>' +
|
||||
'<span class="summary-chip">' + (state.ontology.gaps || []).length + ' ' + t("gapsSuffix") + '</span>' +
|
||||
'</div>' +
|
||||
'<section><h2>' + t("candidateConcepts") + '</h2><div>' +
|
||||
(concepts.map(function(item) {
|
||||
var cls = reviewClassMap[item.review_state] || "pill-neutral";
|
||||
return '<span class="concept-pill ' + cls + '">' + escapeHTML(item.concept.label) + '</span>';
|
||||
}).join("") || t("noCandidates")) +
|
||||
'</div></section>';
|
||||
|
||||
els.assetConcept.innerHTML = concepts
|
||||
.map(
|
||||
(item) =>
|
||||
`<option value="${escapeHTML(item.concept.id)}">${escapeHTML(item.concept.label)}</option>`
|
||||
)
|
||||
.map(function(item) { return '<option value="' + escapeHTML(item.concept.id) + '">' + escapeHTML(item.concept.label) + '</option>'; })
|
||||
.join("");
|
||||
els.assetConcept.disabled = concepts.length === 0;
|
||||
els.assetButton.disabled = concepts.length === 0;
|
||||
}
|
||||
|
||||
/* ---- Asset Prompt ---- */
|
||||
els.assetForm.addEventListener("submit", function(event) {
|
||||
event.preventDefault(); clearError();
|
||||
setStatus(t("generatingPrompt"), true);
|
||||
setButtonLoading(event.submitter || els.assetButton, t("generating"));
|
||||
|
||||
request("/api/v1/teaching-assets/prompts", { method:"POST",
|
||||
body:JSON.stringify({ concept_id:els.assetConcept.value, asset_type:value("#asset-type") })
|
||||
})
|
||||
.then(function(prompt) {
|
||||
state.assetPrompt = prompt;
|
||||
renderAssetPrompt();
|
||||
setStatus(t("promptGenerated", prompt.id));
|
||||
})
|
||||
["catch"](function(error) { showError(error.message); setStatus(t("contentReady")); })
|
||||
["finally"](function() { clearButtonLoading(els.assetButton); });
|
||||
});
|
||||
|
||||
function renderAssetPrompt() {
|
||||
if (!state.assetPrompt) {
|
||||
els.assetOutput.className = "ontology-view empty-state";
|
||||
els.assetOutput.innerHTML = `<span class="empty-hint">${t("emptyAsset")}</span>`;
|
||||
els.assetOutput.innerHTML = '<span class="empty-hint">' + t("emptyAsset") + '</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
const prompt = state.assetPrompt;
|
||||
var prompt = state.assetPrompt;
|
||||
els.assetOutput.className = "ontology-view";
|
||||
els.assetOutput.innerHTML = `
|
||||
<div class="summary-strip">
|
||||
<span class="summary-chip">${escapeHTML(prompt.model_key)}</span>
|
||||
<span class="summary-chip">${escapeHTML(prompt.review_state)}</span>
|
||||
<span class="summary-chip">${t("verifyModelId")}: ${prompt.requires_model_id_verification ? t("yes") : t("no")}</span>
|
||||
</div>
|
||||
<pre class="prompt-text">${escapeHTML(prompt.prompt)}</pre>
|
||||
${evidenceBlock(prompt.source_evidence)}
|
||||
`;
|
||||
els.assetOutput.innerHTML =
|
||||
'<div class="summary-strip">' +
|
||||
'<span class="summary-chip">' + escapeHTML(prompt.model_key) + '</span>' +
|
||||
'<span class="summary-chip">' + escapeHTML(prompt.review_state) + '</span>' +
|
||||
'<span class="summary-chip">' + t("verifyModelId") + ': ' + (prompt.requires_model_id_verification ? t("yes") : t("no")) + '</span>' +
|
||||
'</div>' +
|
||||
'<pre class="prompt-text">' + escapeHTML(prompt.prompt) + '</pre>' +
|
||||
evidenceBlockHtml(prompt.source_evidence);
|
||||
}
|
||||
|
||||
function renderFeedback() {
|
||||
if (!state.lastAnswer) {
|
||||
els.feedback.className = "feedback empty-state";
|
||||
els.feedback.innerHTML = `<span class="empty-hint">${t("emptyFeedback")}</span>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const grade = state.lastAnswer.grade;
|
||||
const gradeClass = gradeClassMap[grade.overall] || "";
|
||||
els.feedback.className = "feedback";
|
||||
els.feedback.innerHTML = `
|
||||
<div>
|
||||
<div class="grade ${gradeClass}">${escapeHTML(grade.overall)}</div>
|
||||
<p class="status-line">${escapeHTML(grade.strengths?.[0] || t("answerWasGraded"))}</p>
|
||||
</div>
|
||||
${scoreRows(grade.scores)}
|
||||
${listBlock(t("gaps"), grade.gaps)}
|
||||
${followUpBlock(grade.follow_up)}
|
||||
${evidenceBlock(grade.evidence)}
|
||||
`;
|
||||
function evidenceBlockHtml(evidence) {
|
||||
if (!evidence || !evidence.length) return "";
|
||||
return '<section><h2>' + t("evidence") + '</h2><ul class="small-list">' +
|
||||
evidence.map(function(item) { return '<li>' + escapeHTML(item.quote || item.id) + '</li>'; }).join("") +
|
||||
'</ul>';
|
||||
}
|
||||
|
||||
function scoreRows(scores) {
|
||||
return Object.entries(scores || {})
|
||||
.map(
|
||||
([label, score]) => `
|
||||
<div class="metric-row">
|
||||
<span>${escapeHTML(label.replaceAll("_", " "))}</span>
|
||||
<strong>${score}/4</strong>
|
||||
</div>
|
||||
`
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
/* ---- Tools toggle ---- */
|
||||
els.toolsToggle.addEventListener("click", function() {
|
||||
var visible = els.toolsPanel.style.display !== "none";
|
||||
els.toolsPanel.style.display = visible ? "none" : "block";
|
||||
els.toolsToggle.classList.toggle("is-active", !visible);
|
||||
});
|
||||
|
||||
function listBlock(title, items = []) {
|
||||
if (!items.length) return "";
|
||||
return `<section><h2>${title}</h2><ul class="small-list">${items.map((item) => `<li>${escapeHTML(item)}</li>`).join("")}</ul></section>`;
|
||||
}
|
||||
|
||||
function followUpBlock(followUp) {
|
||||
if (!followUp?.needed) return "";
|
||||
return `<section><h2>${t("followUp")}</h2><p class="status-line">${escapeHTML(followUp.question)}</p></section>`;
|
||||
}
|
||||
|
||||
function evidenceBlock(evidence = []) {
|
||||
if (!evidence.length) return "";
|
||||
return `<section><h2>${t("evidence")}</h2><ul class="small-list">${evidence.map((item) => `<li>${escapeHTML(item.quote || item.id)}</li>`).join("")}</ul></section>`;
|
||||
}
|
||||
|
||||
window._tutorGoogleCallback = async (response) => {
|
||||
console.log("[auth] Google callback fired");
|
||||
try {
|
||||
const res = await request("/api/v1/auth/google", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ id_token: response.credential }),
|
||||
/* ---- Auth ---- */
|
||||
window._tutorGoogleCallback = function(response) {
|
||||
return request("/api/v1/auth/google", {
|
||||
method:"POST", body:JSON.stringify({ id_token:response.credential }),
|
||||
})
|
||||
.then(function(res) {
|
||||
localStorage.setItem("tutor_token", res.token);
|
||||
localStorage.setItem("tutor_user", JSON.stringify(res.user));
|
||||
if (els.loginError) { els.loginError.textContent = ""; els.loginError.classList.remove("visible"); }
|
||||
renderAuth();
|
||||
})
|
||||
["catch"](function(err) {
|
||||
if (els.loginError) { els.loginError.textContent = err.message; els.loginError.classList.add("visible"); }
|
||||
});
|
||||
console.log("[auth] Backend login success", res.user?.email);
|
||||
localStorage.setItem("tutor_token", res.token);
|
||||
localStorage.setItem("tutor_user", JSON.stringify(res.user));
|
||||
if (els.loginError) {
|
||||
els.loginError.textContent = "";
|
||||
els.loginError.classList.remove("visible");
|
||||
}
|
||||
renderAuth();
|
||||
} catch (err) {
|
||||
console.error("[auth] Backend login failed", err);
|
||||
if (els.loginError) {
|
||||
els.loginError.textContent = err.message;
|
||||
els.loginError.classList.add("visible");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (window._tutorPendingGoogleResponse) {
|
||||
@@ -419,16 +410,13 @@ if (window._tutorPendingGoogleResponse) {
|
||||
}
|
||||
|
||||
function renderAuth() {
|
||||
const user = JSON.parse(localStorage.getItem("tutor_user") || "null");
|
||||
const token = localStorage.getItem("tutor_token");
|
||||
console.log("[auth] renderAuth", { hasUser: !!user, hasToken: !!token });
|
||||
var user = JSON.parse(localStorage.getItem("tutor_user") || "null");
|
||||
var token = localStorage.getItem("tutor_token");
|
||||
if (user && token) {
|
||||
els.loginView.style.display = "none";
|
||||
els.workspaceView.style.display = "grid";
|
||||
els.workspaceView.style.display = "block";
|
||||
els.userInfo.textContent = user.email || user.name || "User";
|
||||
setStatus(t("signedInAs", user.email || user.name));
|
||||
const userIdInput = document.querySelector("#user-id");
|
||||
if (userIdInput) userIdInput.value = user.email || user.id || "";
|
||||
if (els.loginError) els.loginError.classList.remove("visible");
|
||||
} else {
|
||||
els.loginView.style.display = "flex";
|
||||
@@ -436,11 +424,12 @@ function renderAuth() {
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Language ---- */
|
||||
function setLanguage(lang) {
|
||||
localStorage.setItem("tutor_lang", lang);
|
||||
document.documentElement.lang = lang;
|
||||
updateStaticText();
|
||||
document.querySelectorAll(".lang-btn").forEach((btn) => {
|
||||
document.querySelectorAll(".lang-btn").forEach(function(btn) {
|
||||
btn.classList.toggle("is-active", btn.dataset.lang === lang);
|
||||
});
|
||||
if (state.session) renderSession();
|
||||
@@ -448,85 +437,57 @@ function setLanguage(lang) {
|
||||
renderProgress();
|
||||
renderOntology();
|
||||
renderAssetPrompt();
|
||||
const user = JSON.parse(localStorage.getItem("tutor_user") || "null");
|
||||
const token = localStorage.getItem("tutor_token");
|
||||
if (user && token) {
|
||||
setStatus(t("signedInAs", user.email || user.name));
|
||||
} else {
|
||||
setStatus(t("ready"));
|
||||
}
|
||||
var user = JSON.parse(localStorage.getItem("tutor_user") || "null");
|
||||
var token = localStorage.getItem("tutor_token");
|
||||
if (user && token) { setStatus(t("signedInAs", user.email || user.name)); }
|
||||
else { setStatus(t("ready")); }
|
||||
updateStep();
|
||||
}
|
||||
|
||||
els.logoutButton.addEventListener("click", () => {
|
||||
localStorage.removeItem("tutor_token");
|
||||
localStorage.removeItem("tutor_user");
|
||||
renderAuth();
|
||||
setStatus(t("signedOut"));
|
||||
els.logoutButton.addEventListener("click", function() {
|
||||
localStorage.removeItem("tutor_token"); localStorage.removeItem("tutor_user");
|
||||
renderAuth(); setStatus(t("signedOut"));
|
||||
});
|
||||
|
||||
async function request(url, options = {}) {
|
||||
const token = localStorage.getItem("tutor_token");
|
||||
const lang = localStorage.getItem("tutor_lang") || document.documentElement.lang || "ko";
|
||||
const headers = { "Content-Type": "application/json", "X-Lang": lang };
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
const response = await fetch(url, { headers, ...options });
|
||||
const body = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(body.error || `Request failed: ${response.status}`);
|
||||
}
|
||||
return body;
|
||||
document.querySelectorAll(".lang-switch").forEach(function(group) {
|
||||
group.addEventListener("click", function(e) {
|
||||
if (!e.target.dataset.lang) return;
|
||||
setLanguage(e.target.dataset.lang);
|
||||
});
|
||||
});
|
||||
|
||||
/* ---- Init ---- */
|
||||
if (!localStorage.getItem("tutor_lang")) {
|
||||
var browserLang = navigator.language || navigator.userLanguage || "";
|
||||
localStorage.setItem("tutor_lang", browserLang.toLowerCase().startsWith("ko") ? "ko" : "en");
|
||||
document.documentElement.lang = localStorage.getItem("tutor_lang");
|
||||
}
|
||||
|
||||
updateStaticText();
|
||||
var savedLang = localStorage.getItem("tutor_lang") || document.documentElement.lang || "ko";
|
||||
document.querySelectorAll(".lang-btn").forEach(function(btn) {
|
||||
btn.classList.toggle("is-active", btn.dataset.lang === savedLang);
|
||||
});
|
||||
|
||||
/* ---- Helpers ---- */
|
||||
function request(url, options) {
|
||||
var token = localStorage.getItem("tutor_token");
|
||||
var lang = localStorage.getItem("tutor_lang") || document.documentElement.lang || "ko";
|
||||
var headers = { "Content-Type":"application/json", "X-Lang":lang };
|
||||
if (token) headers["Authorization"] = "Bearer " + token;
|
||||
return fetch(url, Object.assign({ headers:headers }, options || {}))
|
||||
.then(function(response) {
|
||||
return response.json().then(function(body) {
|
||||
if (!response.ok) throw new Error(body.error || "Request failed: " + response.status);
|
||||
return body;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function value(selector) {
|
||||
return document.querySelector(selector).value.trim();
|
||||
}
|
||||
|
||||
function setStatus(message, busy = false) {
|
||||
const textEl = els.status.querySelector(".status-text");
|
||||
if (textEl) textEl.textContent = message;
|
||||
else els.status.textContent = message;
|
||||
els.status.classList.toggle("is-busy", busy);
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
els.error.textContent = message;
|
||||
}
|
||||
|
||||
function clearError() {
|
||||
els.error.textContent = "";
|
||||
}
|
||||
|
||||
function escapeHTML(value) {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
document.querySelectorAll(".lang-switch").forEach((group) => {
|
||||
group.addEventListener("click", (e) => {
|
||||
if (!e.target.dataset.lang) return;
|
||||
setLanguage(e.target.dataset.lang);
|
||||
});
|
||||
});
|
||||
|
||||
if (!localStorage.getItem("tutor_lang")) {
|
||||
const browserLang = navigator.language || navigator.userLanguage || "";
|
||||
const lang = browserLang.toLowerCase().startsWith("ko") ? "ko" : "en";
|
||||
localStorage.setItem("tutor_lang", lang);
|
||||
document.documentElement.lang = lang;
|
||||
}
|
||||
|
||||
updateStaticText();
|
||||
const savedLang = localStorage.getItem("tutor_lang") || document.documentElement.lang || "ko";
|
||||
document.querySelectorAll(".lang-btn").forEach((btn) => {
|
||||
btn.classList.toggle("is-active", btn.dataset.lang === savedLang);
|
||||
});
|
||||
window.renderAuth = renderAuth;
|
||||
window.setLanguage = setLanguage;
|
||||
renderAuth();
|
||||
requestAnimationFrame(() => {
|
||||
document.documentElement.classList.add("is-ready");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user