diff --git a/internal/webapp/assets_test.go b/internal/webapp/assets_test.go index d4f1457..5c7d274 100644 --- a/internal/webapp/assets_test.go +++ b/internal/webapp/assets_test.go @@ -16,7 +16,7 @@ func TestHandlerServesIndex(t *testing.T) { if rec.Code != http.StatusOK { t.Fatalf("status = %d", rec.Code) } - if !strings.Contains(rec.Body.String(), "Interview practice") { + if !strings.Contains(rec.Body.String(), "Turn answers into evidence") { t.Fatal("expected app shell content") } } diff --git a/internal/webapp/static/app.js b/internal/webapp/static/app.js index def8459..1f58ff8 100644 --- a/internal/webapp/static/app.js +++ b/internal/webapp/static/app.js @@ -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 = '' + escapeHTML(question.id) + '' + 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 '
' + escapeHTML(entry[0].replaceAll("_"," ")) + '' + entry[1] + '/4
'; + }) + .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 = "

" + t("followUp") + "

" + escapeHTML(grade.follow_up.question) + "

"; + } 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 = `${escapeHTML(question.id)}${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 = "

" + title + "

"; +} + +/* ---- 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 = `${t("emptyProgress")}`; + 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 = ` -
-
${readiness.readiness_percentage}%
-

${escapeHTML(memory.profile.target_role)} ${t("readiness")}

-
-
-

${t("conceptMemory")}

-
${mastery - .map((item) => { - const cls = readinessClassMap[item.state] || "pill-neutral"; - return `${escapeHTML(item.concept.label)} — ${escapeHTML(item.state)}`; - }) - .join("")}
-
-
-

${t("nextChallenge")}

-

${escapeHTML(challenge.concept.label)} — ${escapeHTML(challenge.ladder_level)}

-

${escapeHTML(challenge.question)}

-
- `; + 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 '' + escapeHTML(item.concept.label) + ''; + }) + .join("") || '' + t("noCandidates") + ''; + + if (r.challenge && r.challenge.question) { + els.nextChallengeBlock.innerHTML = + '' + escapeHTML(r.challenge.concept.label) + ' · ' + escapeHTML(r.challenge.ladder_level) + '
' + + 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 = `${t("emptyOntology")}`; + els.ontology.innerHTML = '' + t("emptyOntology") + ''; return; } - - const concepts = state.ontology.concepts || []; + var concepts = state.ontology.concepts || []; els.ontology.className = "ontology-view"; - els.ontology.innerHTML = ` -
- ${concepts.length} ${t("conceptsSuffix")} - ${(state.ontology.edges || []).length} ${t("edgesSuffix")} - ${(state.ontology.gaps || []).length} ${t("gapsSuffix")} -
-
-

${t("candidateConcepts")}

-
${concepts - .map((item) => { - const cls = reviewClassMap[item.review_state] || "pill-neutral"; - return `${escapeHTML(item.concept.label)} — ${escapeHTML(item.review_state)}`; - }) - .join("") || t("noCandidates")}
-
- `; + els.ontology.innerHTML = + '
' + + '' + concepts.length + ' ' + t("conceptsSuffix") + '' + + '' + (state.ontology.edges || []).length + ' ' + t("edgesSuffix") + '' + + '' + (state.ontology.gaps || []).length + ' ' + t("gapsSuffix") + '' + + '
' + + '

' + t("candidateConcepts") + '

' + + (concepts.map(function(item) { + var cls = reviewClassMap[item.review_state] || "pill-neutral"; + return '' + escapeHTML(item.concept.label) + ''; + }).join("") || t("noCandidates")) + + '
'; els.assetConcept.innerHTML = concepts - .map( - (item) => - `` - ) + .map(function(item) { return ''; }) .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 = `${t("emptyAsset")}`; + els.assetOutput.innerHTML = '' + t("emptyAsset") + ''; return; } - - const prompt = state.assetPrompt; + var prompt = state.assetPrompt; els.assetOutput.className = "ontology-view"; - els.assetOutput.innerHTML = ` -
- ${escapeHTML(prompt.model_key)} - ${escapeHTML(prompt.review_state)} - ${t("verifyModelId")}: ${prompt.requires_model_id_verification ? t("yes") : t("no")} -
-
${escapeHTML(prompt.prompt)}
- ${evidenceBlock(prompt.source_evidence)} - `; + els.assetOutput.innerHTML = + '
' + + '' + escapeHTML(prompt.model_key) + '' + + '' + escapeHTML(prompt.review_state) + '' + + '' + t("verifyModelId") + ': ' + (prompt.requires_model_id_verification ? t("yes") : t("no")) + '' + + '
' + + '
' + escapeHTML(prompt.prompt) + '
' + + evidenceBlockHtml(prompt.source_evidence); } -function renderFeedback() { - if (!state.lastAnswer) { - els.feedback.className = "feedback empty-state"; - els.feedback.innerHTML = `${t("emptyFeedback")}`; - return; - } - - const grade = state.lastAnswer.grade; - const gradeClass = gradeClassMap[grade.overall] || ""; - els.feedback.className = "feedback"; - els.feedback.innerHTML = ` -
-
${escapeHTML(grade.overall)}
-

${escapeHTML(grade.strengths?.[0] || t("answerWasGraded"))}

-
- ${scoreRows(grade.scores)} - ${listBlock(t("gaps"), grade.gaps)} - ${followUpBlock(grade.follow_up)} - ${evidenceBlock(grade.evidence)} - `; +function evidenceBlockHtml(evidence) { + if (!evidence || !evidence.length) return ""; + return '

' + t("evidence") + '

'; } -function scoreRows(scores) { - return Object.entries(scores || {}) - .map( - ([label, score]) => ` -
- ${escapeHTML(label.replaceAll("_", " "))} - ${score}/4 -
- ` - ) - .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 `

${title}

`; -} - -function followUpBlock(followUp) { - if (!followUp?.needed) return ""; - return `

${t("followUp")}

${escapeHTML(followUp.question)}

`; -} - -function evidenceBlock(evidence = []) { - if (!evidence.length) return ""; - return `

${t("evidence")}

`; -} - -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"); -}); diff --git a/internal/webapp/static/i18n.js b/internal/webapp/static/i18n.js index 8c1b116..f6c9f66 100644 --- a/internal/webapp/static/i18n.js +++ b/internal/webapp/static/i18n.js @@ -1,26 +1,25 @@ var i18n = { ko: { eyebrow: "튜터 플랫폼", - titleLogin: "면접 연습", - subtitleLogin: "짧은 연습 루프를 반복하며 면접 준비도를 높여보세요.", - accountDivider: "계정", - legalLogin: - '로그인하면 이용약관개인정보처리방침에 동의하는 것입니다.', + loginHeading: "답변을 증거로", + loginDesc: "짧은 연습 루프로 면접 준비도를 눈으로 확인하세요.", titleWorkspace: "면접 연습", - subtitleWorkspace: - "백엔드 면접 연습을 시작하고, 하나의 답변을 증거로 만드세요.", + subtitleWorkspace: "백엔드 면접 연습을 시작하고, 하나의 답변을 증거로 만드세요.", + startSession: "진단 세션 시작", + startHint: "면접 질문을 생성하고 첫 답변을 만들어보세요.", userId: "사용자 ID", targetRole: "목표 직무", stack: "기술 스택", timeline: "준비 기간", startDiagnostic: "진단 시작", signOut: "로그아웃", - diagnosticEyebrow: "진단", + questions: "질문 목록", + yourAnswer: "내 답변", + answerHint: "구체적인 프로덕션 관점에서 답변하세요.", noActiveSession: "활성 세션 없음", emptyQuestions: "면접 질문을 불러오려면 진단 세션을 시작하세요.", answerLabel: "답변", - answerPlaceholder: - "질문을 선택한 후, 구체적인 프로덕션 관점에서 답변하세요.", + answerPlaceholder: "질문을 선택한 후, 구체적인 프로덕션 관점에서 답변하세요.", submitAnswer: "답변 제출", contentEyebrow: "콘텐츠 작업", contentTitle: "소스 → 에셋 프롬프트", @@ -32,32 +31,34 @@ var i18n = { concept: "개념", assetType: "에셋 유형", generatePrompt: "프롬프트 생성", - emptyAsset: - "프롬프트를 생성하면 모델 키, 검토 상태, 근거를 확인할 수 있습니다.", + emptyAsset: "프롬프트를 생성하면 모델 키, 검토 상태, 근거를 확인할 수 있습니다.", feedbackEyebrow: "피드백", rubricResult: "채점 결과", - emptyFeedback: - "답변을 제출하면 등급, 근거, 후속 질문을 확인할 수 있습니다.", + feedbackEmpty: "답변을 제출하면 채점 결과가 여기에 표시됩니다.", + emptyFeedback: "답변을 제출하면 등급, 근거, 후속 질문을 확인할 수 있습니다.", progressEyebrow: "진행 상황", learningState: "학습 상태", - emptyProgress: - "답변을 제출하면 학습자 메모리와 준비도가 업데이트됩니다.", + emptyProgress: "답변을 제출하면 학습자 메모리와 준비도가 업데이트됩니다.", refresh: "새로고침", ready: "준비 완료", creatingSession: "진단 세션 생성 중…", - sessionReady: (id) => `세션 ${id} 준비 완료`, + sessionReady: function(id) { return "세션 " + id + " 준비 완료"; }, submittingAnswer: "답변 제출 중…", - answerGraded: (grade) => `답변 등급: ${grade}`, + answerGraded: function(grade) { return "답변 등급: " + grade; }, + answerGradedLabel: "채점 완료", + answerQuestion: "답변 작성", + selectQuestion: "질문 선택", + reviewComplete: "복습 완료", ingestingMaterial: "자료 수집 중…", - materialIngested: (id) => `자료 ${id} 수집 완료`, + materialIngested: function(id) { return "자료 " + id + " 수집 완료"; }, generatingPrompt: "프롬프트 생성 중…", - promptGenerated: (id) => `프롬프트 ${id} 생성 완료`, + promptGenerated: function(id) { return "프롬프트 " + id + " 생성 완료"; }, refreshingProgress: "학습 진행 상황 새로고침 중…", progressUpdated: "학습 진행 상황 업데이트 완료", - selected: (id) => `${id} 선택됨`, + selected: function(id) { return id + " 선택됨"; }, contentReady: "콘텐츠 작업 공간 준비 완료", sessionReadyShort: "세션 준비 완료", - signedInAs: (email) => `${email}님으로 로그인됨`, + signedInAs: function(email) { return email + "님으로 로그인됨"; }, signedOut: "로그아웃됨", followUp: "후속 질문", evidence: "근거", @@ -82,31 +83,28 @@ var i18n = { candidateConcepts: "후보 개념", noCandidates: "아직 후보가 없습니다.", answerWasGraded: "답변이 채점되었습니다.", - score: (label, val) => `${label} — ${val}/4`, }, en: { eyebrow: "Tutor Platform", - titleLogin: "Interview practice", - subtitleLogin: - "Prove you are becoming more interview-ready after each short practice loop.", - accountDivider: "Account", - legalLogin: - 'By signing in, you agree to our Terms and Privacy Policy.', + loginHeading: "Turn answers into evidence", + loginDesc: "Visualize your interview readiness through short practice loops.", titleWorkspace: "Interview practice", - subtitleWorkspace: - "Start a focused backend interview loop and turn one answer into evidence.", + subtitleWorkspace: "Start a focused backend interview loop and turn one answer into evidence.", + startSession: "Start diagnostic session", + startHint: "Generate interview questions and write your first answer.", userId: "User ID", targetRole: "Target role", stack: "Stack", timeline: "Timeline", startDiagnostic: "Start diagnostic", signOut: "Sign out", - diagnosticEyebrow: "Diagnostic", + questions: "Questions", + yourAnswer: "Your answer", + answerHint: "Answer with concrete production reasoning.", noActiveSession: "No active session", emptyQuestions: "Start a diagnostic session to load interview questions.", answerLabel: "Answer", - answerPlaceholder: - "Select a question, then answer with concrete production reasoning.", + answerPlaceholder: "Select a question, then answer with concrete production reasoning.", submitAnswer: "Submit answer", contentEyebrow: "Content operations", contentTitle: "Source to asset prompt", @@ -118,32 +116,34 @@ var i18n = { concept: "Concept", assetType: "Asset type", generatePrompt: "Generate prompt", - emptyAsset: - "Generate a prompt to inspect model key, review state, and evidence.", + emptyAsset: "Generate a prompt to inspect model key, review state, and evidence.", feedbackEyebrow: "Feedback", rubricResult: "Rubric result", - emptyFeedback: - "Submit an answer to see grade, evidence, and follow-up.", + feedbackEmpty: "Submit an answer to see your grade and feedback here.", + emptyFeedback: "Submit an answer to see grade, evidence, and follow-up.", progressEyebrow: "Progress", learningState: "Learning state", - emptyProgress: - "Answer once to update learner memory and readiness.", + emptyProgress: "Answer once to update learner memory and readiness.", refresh: "Refresh", ready: "Ready", creatingSession: "Creating diagnostic session…", - sessionReady: (id) => `Session ${id} ready`, + sessionReady: function(id) { return "Session " + id + " ready"; }, submittingAnswer: "Submitting answer…", - answerGraded: (grade) => `Answer graded as ${grade}`, + answerGraded: function(grade) { return "Answer graded as " + grade; }, + answerGradedLabel: "Graded", + answerQuestion: "Answer", + selectQuestion: "Select", + reviewComplete: "Review done", ingestingMaterial: "Ingesting material…", - materialIngested: (id) => `Material ${id} ingested`, + materialIngested: function(id) { return "Material " + id + " ingested"; }, generatingPrompt: "Generating prompt candidate…", - promptGenerated: (id) => `Prompt ${id} generated`, + promptGenerated: function(id) { return "Prompt " + id + " generated"; }, refreshingProgress: "Refreshing learning progress…", progressUpdated: "Learning progress updated", - selected: (id) => `Selected ${id}`, + selected: function(id) { return "Selected " + id; }, contentReady: "Content workspace ready", sessionReadyShort: "Session ready", - signedInAs: (email) => `Signed in as ${email}`, + signedInAs: function(email) { return "Signed in as " + email; }, signedOut: "Signed out", followUp: "Follow-up", evidence: "Evidence", @@ -168,17 +168,17 @@ var i18n = { candidateConcepts: "Candidate concepts", noCandidates: "No candidates yet.", answerWasGraded: "Answer was graded.", - score: (label, val) => `${label} — ${val}/4`, }, }; -window.t = function (key, ...args) { - const lang = +window.t = function (key) { + var args = Array.prototype.slice.call(arguments, 1); + var lang = localStorage.getItem("tutor_lang") || document.documentElement.lang || "ko"; - const text = i18n[lang]?.[key] ?? i18n["en"]?.[key] ?? key; - return typeof text === "function" ? text(...args) : text; + var text = i18n[lang]?.[key] ?? i18n["en"]?.[key] ?? key; + return typeof text === "function" ? text.apply(null, args) : text; } var questionTexts = { @@ -201,7 +201,7 @@ var questionTexts = { }; window.tq = function (id) { - const lang = + var lang = localStorage.getItem("tutor_lang") || document.documentElement.lang || "ko"; @@ -209,23 +209,29 @@ window.tq = function (id) { }; window.updateStaticText = function () { - const lang = + var lang = localStorage.getItem("tutor_lang") || document.documentElement.lang || "ko"; document.documentElement.lang = lang; - document.querySelectorAll("[data-i18n]").forEach((el) => { - const k = el.dataset.i18n; - const v = i18n[lang]?.[k] ?? i18n["en"]?.[k] ?? ""; - const text = typeof v === "function" ? v() : v; + document.querySelectorAll("[data-i18n]").forEach(function(el) { + var k = el.dataset.i18n; + var v = i18n[lang]?.[k] ?? i18n["en"]?.[k] ?? ""; + var text = typeof v === "function" ? v() : v; if (el.classList.contains("login-divider")) { el.setAttribute("data-label", text); } else if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") { if (el.placeholder !== undefined) el.placeholder = text; - } else if (text.includes("<")) { + } else if (text.indexOf("<") >= 0) { el.innerHTML = text; } else { el.textContent = text; } }); + document.querySelectorAll("[data-i18n-placeholder]").forEach(function(el) { + var k = el.dataset.i18nPlaceholder; + var v = i18n[lang]?.[k] ?? i18n["en"]?.[k] ?? ""; + var text = typeof v === "function" ? v() : v; + el.placeholder = text; + }); } diff --git a/internal/webapp/static/index.html b/internal/webapp/static/index.html index 00f22d4..1413ef9 100644 --- a/internal/webapp/static/index.html +++ b/internal/webapp/static/index.html @@ -1,167 +1,160 @@ - - - - Tutor Platform - - - - - - + + + + Tutor Platform + + + + + + -
-
+ +
+ + +
+ + -
- - -
-
-
-

Diagnostic

-

No active session

-
-
-
- Start a diagnostic session to load interview questions. -
- -
- - - -
- -
-
-
-

Content operations

-

Source to asset prompt

+
+
+ +
-
+ + +

+ + Ready +

+ +
-
+ + + + +
+ + +