From 8dfe3b384e7f90ca7fe3cd7f338163431e892b94 Mon Sep 17 00:00:00 2001 From: user Date: Mon, 27 Apr 2026 20:10:46 +0900 Subject: [PATCH] ui: i18n ko/en, auto-fill user id on auth, lang switch, UX polish --- internal/webapp/static/app.js | 157 ++++++++++++++--------- internal/webapp/static/i18n.js | 204 ++++++++++++++++++++++++++++++ internal/webapp/static/index.html | 146 ++++++++++++++------- internal/webapp/static/styles.css | 50 ++++++++ 4 files changed, 453 insertions(+), 104 deletions(-) create mode 100644 internal/webapp/static/i18n.js diff --git a/internal/webapp/static/app.js b/internal/webapp/static/app.js index b215fcc..839506c 100644 --- a/internal/webapp/static/app.js +++ b/internal/webapp/static/app.js @@ -75,13 +75,19 @@ function clearButtonLoading(button) { els.sessionForm.addEventListener("submit", async (event) => { event.preventDefault(); clearError(); - setStatus("Creating diagnostic session...", true); - setButtonLoading(event.submitter || document.querySelector("#start-button"), "Starting..."); + setStatus(t("creatingSession"), true); + setButtonLoading( + event.submitter || document.querySelector("#start-button"), + t("starting") + ); const payload = { user_id: value("#user-id"), target_role: value("#target-role"), - stack: value("#stack").split(",").map((item) => item.trim()).filter(Boolean), + stack: value("#stack") + .split(",") + .map((item) => item.trim()) + .filter(Boolean), interview_timeline: value("#timeline"), }; @@ -96,10 +102,10 @@ els.sessionForm.addEventListener("submit", async (event) => { renderSession(); renderFeedback(); renderProgress(); - setStatus(`Session ${session.id} ready`); + setStatus(t("sessionReady", session.id)); } catch (error) { showError(error.message); - setStatus("Ready"); + setStatus(t("ready")); } finally { clearButtonLoading(document.querySelector("#start-button")); } @@ -115,24 +121,27 @@ els.answerForm.addEventListener("submit", async (event) => { clearError(); if (!state.session || !state.selectedQuestion) return; - setStatus("Submitting answer...", true); - setButtonLoading(event.submitter || els.answerButton, "Grading..."); + 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, - }), - }); + 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(`Answer graded as ${answer.grade.overall}`); + setStatus(t("answerGraded", answer.grade.overall)); } catch (error) { showError(error.message); - setStatus("Session ready"); + setStatus(t("sessionReadyShort")); } finally { clearButtonLoading(els.answerButton); els.answerButton.disabled = !state.selectedQuestion; @@ -142,8 +151,11 @@ els.answerForm.addEventListener("submit", async (event) => { els.materialForm.addEventListener("submit", async (event) => { event.preventDefault(); clearError(); - setStatus("Ingesting material...", true); - setButtonLoading(event.submitter || document.querySelector("#material-button"), "Ingesting..."); + setStatus(t("ingestingMaterial"), true); + setButtonLoading( + event.submitter || document.querySelector("#material-button"), + t("ingesting") + ); try { const result = await request("/api/v1/materials", { @@ -156,10 +168,10 @@ els.materialForm.addEventListener("submit", async (event) => { }); state.ontology = result.snapshot; renderOntology(); - setStatus(`Material ${result.material.id} ingested`); + setStatus(t("materialIngested", result.material.id)); } catch (error) { showError(error.message); - setStatus("Content workspace ready"); + setStatus(t("contentReady")); } finally { clearButtonLoading(document.querySelector("#material-button")); } @@ -168,8 +180,8 @@ els.materialForm.addEventListener("submit", async (event) => { els.assetForm.addEventListener("submit", async (event) => { event.preventDefault(); clearError(); - setStatus("Generating prompt candidate...", true); - setButtonLoading(event.submitter || els.assetButton, "Generating..."); + setStatus(t("generatingPrompt"), true); + setButtonLoading(event.submitter || els.assetButton, t("generating")); try { const prompt = await request("/api/v1/teaching-assets/prompts", { @@ -181,10 +193,10 @@ els.assetForm.addEventListener("submit", async (event) => { }); state.assetPrompt = prompt; renderAssetPrompt(); - setStatus(`Prompt ${prompt.id} generated`); + setStatus(t("promptGenerated", prompt.id)); } catch (error) { showError(error.message); - setStatus("Content workspace ready"); + setStatus(t("contentReady")); } finally { clearButtonLoading(els.assetButton); } @@ -192,7 +204,7 @@ els.assetForm.addEventListener("submit", async (event) => { function renderSession() { if (!state.session) return; - els.title.textContent = `${state.session.target_role} — ${state.session.questions.length} questions`; + els.title.textContent = `${state.session.target_role} — ${state.session.questions.length} ${t("questionsSuffix")}`; els.questions.className = "question-list"; els.questions.innerHTML = ""; @@ -200,13 +212,16 @@ function renderSession() { const button = document.createElement("button"); button.type = "button"; button.className = "question-button"; - button.setAttribute("aria-pressed", String(state.selectedQuestion?.id === question.id)); + button.setAttribute( + "aria-pressed", + String(state.selectedQuestion?.id === question.id) + ); button.innerHTML = `${escapeHTML(question.id)}${escapeHTML(question.prompt)}`; button.addEventListener("click", () => { state.selectedQuestion = question; els.answerText.value = ""; renderSession(); - setStatus(`Selected ${question.id}`); + setStatus(t("selected", question.id)); }); els.questions.append(button); }); @@ -216,7 +231,7 @@ function renderSession() { async function refreshProgress() { if (!state.session) return; - setStatus("Refreshing learning progress...", true); + setStatus(t("refreshingProgress"), true); try { const userID = encodeURIComponent(state.session.user_id); @@ -227,7 +242,7 @@ async function refreshProgress() { ]); state.progress = { memory, readiness, challenge }; renderProgress(); - setStatus("Learning progress updated"); + setStatus(t("progressUpdated")); } catch (error) { showError(error.message); renderProgress(); @@ -238,7 +253,7 @@ function renderProgress() { els.refreshProgress.disabled = !state.session; if (!state.progress) { els.progress.className = "feedback empty-state"; - els.progress.innerHTML = `Answer once to update learner memory and readiness.`; + els.progress.innerHTML = `${t("emptyProgress")}`; return; } @@ -248,17 +263,19 @@ function renderProgress() { els.progress.innerHTML = `
${readiness.readiness_percentage}%
-

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

+

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

-

Concept memory

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

${t("conceptMemory")}

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

Next challenge

+

${t("nextChallenge")}

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

${escapeHTML(challenge.question)}

@@ -268,7 +285,7 @@ function renderProgress() { function renderOntology() { if (!state.ontology) { els.ontology.className = "ontology-view empty-state"; - els.ontology.innerHTML = `Ingest material to inspect ontology candidates.`; + els.ontology.innerHTML = `${t("emptyOntology")}`; return; } @@ -276,21 +293,26 @@ function renderOntology() { els.ontology.className = "ontology-view"; els.ontology.innerHTML = `
- ${concepts.length} concepts - ${(state.ontology.edges || []).length} edges - ${(state.ontology.gaps || []).length} gaps + ${concepts.length} ${t("conceptsSuffix")} + ${(state.ontology.edges || []).length} ${t("edgesSuffix")} + ${(state.ontology.gaps || []).length} ${t("gapsSuffix")}
-

Candidate concepts

-
${concepts.map((item) => { - const cls = reviewClassMap[item.review_state] || "pill-neutral"; - return `${escapeHTML(item.concept.label)} — ${escapeHTML(item.review_state)}`; - }).join("") || "No candidates yet."}
+

${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.assetConcept.innerHTML = concepts - .map((item) => ``) + .map( + (item) => + `` + ) .join(""); els.assetConcept.disabled = concepts.length === 0; els.assetButton.disabled = concepts.length === 0; @@ -299,7 +321,7 @@ function renderOntology() { function renderAssetPrompt() { if (!state.assetPrompt) { els.assetOutput.className = "ontology-view empty-state"; - els.assetOutput.innerHTML = `Generate a prompt to inspect model key, review state, and evidence.`; + els.assetOutput.innerHTML = `${t("emptyAsset")}`; return; } @@ -309,7 +331,7 @@ function renderAssetPrompt() {
${escapeHTML(prompt.model_key)} ${escapeHTML(prompt.review_state)} - verify model id: ${prompt.requires_model_id_verification ? "yes" : "no"} + ${t("verifyModelId")}: ${prompt.requires_model_id_verification ? t("yes") : t("no")}
${escapeHTML(prompt.prompt)}
${evidenceBlock(prompt.source_evidence)} @@ -319,7 +341,7 @@ function renderAssetPrompt() { function renderFeedback() { if (!state.lastAnswer) { els.feedback.className = "feedback empty-state"; - els.feedback.innerHTML = `Submit an answer to see grade, evidence, and follow-up.`; + els.feedback.innerHTML = `${t("emptyFeedback")}`; return; } @@ -329,10 +351,10 @@ function renderFeedback() { els.feedback.innerHTML = `
${escapeHTML(grade.overall)}
-

${escapeHTML(grade.strengths?.[0] || "Answer was graded.")}

+

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

${scoreRows(grade.scores)} - ${listBlock("Gaps", grade.gaps)} + ${listBlock(t("gaps"), grade.gaps)} ${followUpBlock(grade.follow_up)} ${evidenceBlock(grade.evidence)} `; @@ -340,12 +362,14 @@ function renderFeedback() { function scoreRows(scores) { return Object.entries(scores || {}) - .map(([label, score]) => ` + .map( + ([label, score]) => `
${escapeHTML(label.replaceAll("_", " "))} ${score}/4
- `) + ` + ) .join(""); } @@ -356,12 +380,12 @@ function listBlock(title, items = []) { function followUpBlock(followUp) { if (!followUp?.needed) return ""; - return `

Follow-up

${escapeHTML(followUp.question)}

`; + return `

${t("followUp")}

${escapeHTML(followUp.question)}

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

Evidence

`; + return `

${t("evidence")}

`; } window._tutorGoogleCallback = async (response) => { @@ -401,7 +425,9 @@ function renderAuth() { els.loginView.style.display = "none"; els.workspaceView.style.display = "grid"; els.userInfo.textContent = user.email || user.name || "User"; - setStatus(`Signed in as ${user.email || user.name}`); + 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"; @@ -413,7 +439,7 @@ els.logoutButton.addEventListener("click", () => { localStorage.removeItem("tutor_token"); localStorage.removeItem("tutor_user"); renderAuth(); - setStatus("Signed out"); + setStatus(t("signedOut")); }); async function request(url, options = {}) { @@ -456,4 +482,19 @@ function escapeHTML(value) { .replaceAll("'", "'"); } +document.querySelectorAll(".lang-switch").forEach((group) => { + group.addEventListener("click", (e) => { + if (!e.target.dataset.lang) return; + const lang = e.target.dataset.lang; + localStorage.setItem("tutor_lang", lang); + document.documentElement.lang = lang; + updateStaticText(); + document.querySelectorAll(".lang-btn").forEach((btn) => { + btn.classList.toggle("is-active", btn.dataset.lang === lang); + }); + }); +}); + +updateStaticText(); +window.renderAuth = renderAuth; renderAuth(); diff --git a/internal/webapp/static/i18n.js b/internal/webapp/static/i18n.js new file mode 100644 index 0000000..d00b5d4 --- /dev/null +++ b/internal/webapp/static/i18n.js @@ -0,0 +1,204 @@ +const i18n = { + ko: { + eyebrow: "튜터 플랫폼", + titleLogin: "면접 연습", + subtitleLogin: "짧은 연습 루프를 반복하며 면접 준비도를 높여보세요.", + accountDivider: "계정", + legalLogin: + '로그인하면 이용약관개인정보처리방침에 동의하는 것입니다.', + titleWorkspace: "면접 연습", + subtitleWorkspace: + "백엔드 면접 연습을 시작하고, 하나의 답변을 증거로 만드세요.", + userId: "사용자 ID", + targetRole: "목표 직무", + stack: "기술 스택", + timeline: "준비 기간", + startDiagnostic: "진단 시작", + signOut: "로그아웃", + diagnosticEyebrow: "진단", + noActiveSession: "활성 세션 없음", + emptyQuestions: "면접 질문을 불러오려면 진단 세션을 시작하세요.", + answerLabel: "답변", + answerPlaceholder: + "질문을 선택한 후, 구체적인 프로덕션 관점에서 답변하세요.", + submitAnswer: "답변 제출", + contentEyebrow: "콘텐츠 작업", + contentTitle: "소스 → 에셋 프롬프트", + materialTitle: "자료 제목", + sourceType: "소스 유형", + sourceMaterial: "소스 자료", + ingestMaterial: "자료 수집", + emptyOntology: "자료를 수집하면 개념 후보를 확인할 수 있습니다.", + concept: "개념", + assetType: "에셋 유형", + generatePrompt: "프롬프트 생성", + emptyAsset: + "프롬프트를 생성하면 모델 키, 검토 상태, 근거를 확인할 수 있습니다.", + feedbackEyebrow: "피드백", + rubricResult: "채점 결과", + emptyFeedback: + "답변을 제출하면 등급, 근거, 후속 질문을 확인할 수 있습니다.", + progressEyebrow: "진행 상황", + learningState: "학습 상태", + emptyProgress: + "답변을 제출하면 학습자 메모리와 준비도가 업데이트됩니다.", + refresh: "새로고침", + ready: "준비 완료", + creatingSession: "진단 세션 생성 중…", + sessionReady: (id) => `세션 ${id} 준비 완료`, + submittingAnswer: "답변 제출 중…", + answerGraded: (grade) => `답변 등급: ${grade}`, + ingestingMaterial: "자료 수집 중…", + materialIngested: (id) => `자료 ${id} 수집 완료`, + generatingPrompt: "프롬프트 생성 중…", + promptGenerated: (id) => `프롬프트 ${id} 생성 완료`, + refreshingProgress: "학습 진행 상황 새로고침 중…", + progressUpdated: "학습 진행 상황 업데이트 완료", + selected: (id) => `${id} 선택됨`, + contentReady: "콘텐츠 작업 공간 준비 완료", + sessionReadyShort: "세션 준비 완료", + signedInAs: (email) => `${email}님으로 로그인됨`, + signedOut: "로그아웃됨", + followUp: "후속 질문", + evidence: "근거", + gaps: "부족한 점", + conceptMemory: "개념 메모리", + nextChallenge: "다음 도전", + readiness: "준비도", + modelKey: "모델 키", + reviewState: "검토 상태", + verifyModelId: "모델 ID 확인 필요", + yes: "예", + no: "아니오", + questionId: "질문 ID", + starting: "시작 중…", + grading: "채점 중…", + ingesting: "수집 중…", + generating: "생성 중…", + questionsSuffix: "개 질문", + conceptsSuffix: "개 개념", + edgesSuffix: "개 엣지", + gapsSuffix: "개 갭", + 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.', + titleWorkspace: "Interview practice", + subtitleWorkspace: + "Start a focused backend interview loop and turn one answer into evidence.", + userId: "User ID", + targetRole: "Target role", + stack: "Stack", + timeline: "Timeline", + startDiagnostic: "Start diagnostic", + signOut: "Sign out", + diagnosticEyebrow: "Diagnostic", + 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.", + submitAnswer: "Submit answer", + contentEyebrow: "Content operations", + contentTitle: "Source to asset prompt", + materialTitle: "Material title", + sourceType: "Source type", + sourceMaterial: "Source material", + ingestMaterial: "Ingest material", + emptyOntology: "Ingest material to inspect ontology candidates.", + concept: "Concept", + assetType: "Asset type", + generatePrompt: "Generate prompt", + 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.", + progressEyebrow: "Progress", + learningState: "Learning state", + emptyProgress: + "Answer once to update learner memory and readiness.", + refresh: "Refresh", + ready: "Ready", + creatingSession: "Creating diagnostic session…", + sessionReady: (id) => `Session ${id} ready`, + submittingAnswer: "Submitting answer…", + answerGraded: (grade) => `Answer graded as ${grade}`, + ingestingMaterial: "Ingesting material…", + materialIngested: (id) => `Material ${id} ingested`, + generatingPrompt: "Generating prompt candidate…", + promptGenerated: (id) => `Prompt ${id} generated`, + refreshingProgress: "Refreshing learning progress…", + progressUpdated: "Learning progress updated", + selected: (id) => `Selected ${id}`, + contentReady: "Content workspace ready", + sessionReadyShort: "Session ready", + signedInAs: (email) => `Signed in as ${email}`, + signedOut: "Signed out", + followUp: "Follow-up", + evidence: "Evidence", + gaps: "Gaps", + conceptMemory: "Concept memory", + nextChallenge: "Next challenge", + readiness: "readiness", + modelKey: "Model key", + reviewState: "Review state", + verifyModelId: "verify model id", + yes: "yes", + no: "no", + questionId: "question id", + starting: "Starting…", + grading: "Grading…", + ingesting: "Ingesting…", + generating: "Generating…", + questionsSuffix: "questions", + conceptsSuffix: "concepts", + edgesSuffix: "edges", + gapsSuffix: "gaps", + candidateConcepts: "Candidate concepts", + noCandidates: "No candidates yet.", + answerWasGraded: "Answer was graded.", + score: (label, val) => `${label} — ${val}/4`, + }, +}; + +function t(key, ...args) { + const 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; +} + +function updateStaticText() { + const 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; + 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("<")) { + el.innerHTML = text; + } else { + el.textContent = text; + } + }); +} diff --git a/internal/webapp/static/index.html b/internal/webapp/static/index.html index d322860..4a4dbe9 100644 --- a/internal/webapp/static/index.html +++ b/internal/webapp/static/index.html @@ -10,7 +10,7 @@ diff --git a/internal/webapp/static/styles.css b/internal/webapp/static/styles.css index bae1b34..bf38ac2 100644 --- a/internal/webapp/static/styles.css +++ b/internal/webapp/static/styles.css @@ -554,6 +554,56 @@ html[lang="ko"] .login-card .eyebrow { display: block; } +.login-header { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + position: relative; +} + +.login-header .eyebrow { + margin-bottom: 0; +} + +.workspace-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} + +.lang-switch { + display: inline-flex; + gap: 4px; + background: var(--surface-muted); + border-radius: 6px; + padding: 3px; +} + +.lang-btn { + min-height: 28px; + padding: 0 10px; + font-size: 11px; + font-weight: 750; + letter-spacing: 0.04em; + border-radius: 4px; + background: transparent; + color: var(--muted); +} + +.lang-btn:hover:not(:disabled) { + background: rgba(24, 32, 27, 0.04); + color: var(--text); +} + +.lang-btn.is-active { + background: var(--surface); + color: var(--accent); + box-shadow: 0 1px 2px rgba(24, 32, 27, 0.06); +} + .user-bar { display: flex; align-items: center;