Files

546 lines
21 KiB
JavaScript

var state = {
session: null,
selectedQuestion: null,
lastAnswer: null,
progress: null,
ontology: null,
assetPrompt: null,
};
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"),
materialFile: document.querySelector("#material-file"),
fileNameDisplay: document.querySelector("#file-name"),
uploadFileButton: document.querySelector("#upload-file-button"),
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"),
};
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");
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");
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("&","&amp;").replaceAll("<","&lt;").replaceAll(">","&gt;").replaceAll('"',"&quot;").replaceAll("'","&#039;");
}
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"));
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(function(s){return s.trim()}).filter(Boolean),
interview_timeline: "30 days",
lang: localStorage.getItem("tutor_lang") || document.documentElement.lang || "ko",
};
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"));
});
});
function renderSession() {
if (!state.session) return;
els.questionBar.style.display = "block";
els.answerArea.style.display = "block";
els.setupCard.style.display = "none";
els.questions.innerHTML = "";
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"));
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;
});
});
/* ---- 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";
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");
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 = "";
}
renderBlock(els.evidenceBlock, t("evidence"),
(grade.evidence || []).map(function(e){ return e.quote || e.id; }));
}
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>";
}
/* ---- File upload ---- */
els.materialFile.addEventListener("change", function() {
var file = els.materialFile.files[0];
if (file) {
els.fileNameDisplay.textContent = file.name;
els.uploadFileButton.disabled = false;
} else {
els.fileNameDisplay.textContent = "";
els.uploadFileButton.disabled = true;
}
});
els.uploadFileButton.addEventListener("click", function() {
var file = els.materialFile.files[0];
if (!file) return;
clearError();
setStatus(t("ingestingMaterial"), true);
els.uploadFileButton.disabled = true;
var formData = new FormData();
formData.append("file", file);
var title = document.querySelector("#material-title").value;
if (title) formData.append("title", title);
var token = localStorage.getItem("tutor_token");
var lang = localStorage.getItem("tutor_lang") || document.documentElement.lang || "ko";
var headers = {};
if (token) headers["Authorization"] = "Bearer " + token;
fetch("/api/v1/materials/upload", { method:"POST", headers:headers, body:formData })
.then(function(response) {
return response.json().then(function(body) {
if (!response.ok) throw new Error(body.error || "Upload failed: " + response.status);
state.ontology = body.snapshot;
renderOntology();
setStatus(t("materialIngested", body.material.id));
els.materialFile.value = "";
els.fileNameDisplay.textContent = "";
});
})
["catch"](function(error) {
showError(error.message); setStatus(t("contentReady"));
})
["finally"](function() {
els.uploadFileButton.disabled = false;
});
});
/* ---- Progress ---- */
els.refreshProgress.addEventListener("click", function() { clearError(); refreshProgress(); });
function refreshProgress() {
if (!state.session) return Promise.resolve();
setStatus(t("refreshingProgress"), true);
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.progressContent.style.display = "none";
els.progressDivider.style.display = "none";
return;
}
els.progressContent.style.display = "flex";
els.progressDivider.style.display = "block";
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) + ' &middot; ' + 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>';
return;
}
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(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(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>';
return;
}
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>' +
evidenceBlockHtml(prompt.source_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>';
}
/* ---- 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);
});
/* ---- 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"); }
});
};
if (window._tutorPendingGoogleResponse) {
window._tutorGoogleCallback(window._tutorPendingGoogleResponse);
window._tutorPendingGoogleResponse = null;
}
function renderAuth() {
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 = "block";
els.userInfo.textContent = user.email || user.name || "User";
setStatus(t("signedInAs", user.email || user.name));
if (els.loginError) els.loginError.classList.remove("visible");
} else {
els.loginView.style.display = "flex";
els.workspaceView.style.display = "none";
}
}
/* ---- Language ---- */
function setLanguage(lang) {
localStorage.setItem("tutor_lang", lang);
document.documentElement.lang = lang;
updateStaticText();
document.querySelectorAll(".lang-btn").forEach(function(btn) {
btn.classList.toggle("is-active", btn.dataset.lang === lang);
});
if (state.session) renderSession();
renderFeedback();
renderProgress();
renderOntology();
renderAssetPrompt();
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", function() {
localStorage.removeItem("tutor_token"); localStorage.removeItem("tutor_user");
renderAuth(); setStatus(t("signedOut"));
});
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();
}
window.renderAuth = renderAuth;
window.setLanguage = setLanguage;
renderAuth();