ui: i18n ko/en, auto-fill user id on auth, lang switch, UX polish
This commit is contained in:
@@ -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`, {
|
||||
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 = `<span class="question-id">${escapeHTML(question.id)}</span>${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 = `<span class="empty-hint">Answer once to update learner memory and readiness.</span>`;
|
||||
els.progress.innerHTML = `<span class="empty-hint">${t("emptyProgress")}</span>`;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -248,17 +263,19 @@ function renderProgress() {
|
||||
els.progress.innerHTML = `
|
||||
<section>
|
||||
<div class="readiness-value">${readiness.readiness_percentage}%</div>
|
||||
<p class="status-line">${escapeHTML(memory.profile.target_role)} readiness</p>
|
||||
<p class="status-line">${escapeHTML(memory.profile.target_role)} ${t("readiness")}</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Concept memory</h2>
|
||||
<div>${mastery.map((item) => {
|
||||
<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>
|
||||
})
|
||||
.join("")}</div>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Next challenge</h2>
|
||||
<h2>${t("nextChallenge")}</h2>
|
||||
<p class="status-line">${escapeHTML(challenge.concept.label)} — ${escapeHTML(challenge.ladder_level)}</p>
|
||||
<p>${escapeHTML(challenge.question)}</p>
|
||||
</section>
|
||||
@@ -268,7 +285,7 @@ function renderProgress() {
|
||||
function renderOntology() {
|
||||
if (!state.ontology) {
|
||||
els.ontology.className = "ontology-view empty-state";
|
||||
els.ontology.innerHTML = `<span class="empty-hint">Ingest material to inspect ontology candidates.</span>`;
|
||||
els.ontology.innerHTML = `<span class="empty-hint">${t("emptyOntology")}</span>`;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -276,21 +293,26 @@ function renderOntology() {
|
||||
els.ontology.className = "ontology-view";
|
||||
els.ontology.innerHTML = `
|
||||
<div class="summary-strip">
|
||||
<span class="summary-chip">${concepts.length} concepts</span>
|
||||
<span class="summary-chip">${(state.ontology.edges || []).length} edges</span>
|
||||
<span class="summary-chip">${(state.ontology.gaps || []).length} gaps</span>
|
||||
<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>Candidate concepts</h2>
|
||||
<div>${concepts.map((item) => {
|
||||
<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("") || "No candidates yet."}</div>
|
||||
})
|
||||
.join("") || t("noCandidates")}</div>
|
||||
</section>
|
||||
`;
|
||||
|
||||
els.assetConcept.innerHTML = concepts
|
||||
.map((item) => `<option value="${escapeHTML(item.concept.id)}">${escapeHTML(item.concept.label)}</option>`)
|
||||
.map(
|
||||
(item) =>
|
||||
`<option value="${escapeHTML(item.concept.id)}">${escapeHTML(item.concept.label)}</option>`
|
||||
)
|
||||
.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 = `<span class="empty-hint">Generate a prompt to inspect model key, review state, and evidence.</span>`;
|
||||
els.assetOutput.innerHTML = `<span class="empty-hint">${t("emptyAsset")}</span>`;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -309,7 +331,7 @@ function renderAssetPrompt() {
|
||||
<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">verify model id: ${prompt.requires_model_id_verification ? "yes" : "no"}</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)}
|
||||
@@ -319,7 +341,7 @@ function renderAssetPrompt() {
|
||||
function renderFeedback() {
|
||||
if (!state.lastAnswer) {
|
||||
els.feedback.className = "feedback empty-state";
|
||||
els.feedback.innerHTML = `<span class="empty-hint">Submit an answer to see grade, evidence, and follow-up.</span>`;
|
||||
els.feedback.innerHTML = `<span class="empty-hint">${t("emptyFeedback")}</span>`;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -329,10 +351,10 @@ function renderFeedback() {
|
||||
els.feedback.innerHTML = `
|
||||
<div>
|
||||
<div class="grade ${gradeClass}">${escapeHTML(grade.overall)}</div>
|
||||
<p class="status-line">${escapeHTML(grade.strengths?.[0] || "Answer was graded.")}</p>
|
||||
<p class="status-line">${escapeHTML(grade.strengths?.[0] || t("answerWasGraded"))}</p>
|
||||
</div>
|
||||
${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]) => `
|
||||
<div class="metric-row">
|
||||
<span>${escapeHTML(label.replaceAll("_", " "))}</span>
|
||||
<strong>${score}/4</strong>
|
||||
</div>
|
||||
`)
|
||||
`
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
@@ -356,12 +380,12 @@ function listBlock(title, items = []) {
|
||||
|
||||
function followUpBlock(followUp) {
|
||||
if (!followUp?.needed) return "";
|
||||
return `<section><h2>Follow-up</h2><p class="status-line">${escapeHTML(followUp.question)}</p></section>`;
|
||||
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>Evidence</h2><ul class="small-list">${evidence.map((item) => `<li>${escapeHTML(item.quote || item.id)}</li>`).join("")}</ul></section>`;
|
||||
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) => {
|
||||
@@ -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();
|
||||
|
||||
204
internal/webapp/static/i18n.js
Normal file
204
internal/webapp/static/i18n.js
Normal file
@@ -0,0 +1,204 @@
|
||||
const i18n = {
|
||||
ko: {
|
||||
eyebrow: "튜터 플랫폼",
|
||||
titleLogin: "면접 연습",
|
||||
subtitleLogin: "짧은 연습 루프를 반복하며 면접 준비도를 높여보세요.",
|
||||
accountDivider: "계정",
|
||||
legalLogin:
|
||||
'로그인하면 <a href="#">이용약관</a> 및 <a href="#">개인정보처리방침</a>에 동의하는 것입니다.',
|
||||
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 <a href="#">Terms</a> and <a href="#">Privacy Policy</a>.',
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -22,56 +22,90 @@
|
||||
|
||||
<section id="login-view" class="login-view">
|
||||
<div class="login-card">
|
||||
<p class="eyebrow">Tutor Platform</p>
|
||||
<h1>Interview practice</h1>
|
||||
<p class="lede">Prove you are becoming more interview-ready after each short practice loop.</p>
|
||||
<div class="login-divider" data-label="Account"></div>
|
||||
<div class="login-header">
|
||||
<p class="eyebrow" data-i18n="eyebrow">Tutor Platform</p>
|
||||
<div class="lang-switch" role="group" aria-label="Language">
|
||||
<button type="button" data-lang="ko" class="lang-btn is-active">KO</button>
|
||||
<button type="button" data-lang="en" class="lang-btn">EN</button>
|
||||
</div>
|
||||
</div>
|
||||
<h1 data-i18n="titleLogin">Interview practice</h1>
|
||||
<p class="lede" data-i18n="subtitleLogin">
|
||||
Prove you are becoming more interview-ready after each short practice loop.
|
||||
</p>
|
||||
<div class="login-divider" data-i18n="accountDivider" data-label="Account"></div>
|
||||
<div id="auth-area" class="auth-area">
|
||||
<div id="g_id_onload" data-client_id="13671390758-bp1ed6psn43bl86r8a9kv81o40nkea90.apps.googleusercontent.com" data-callback="handleCredentialResponse" data-auto_prompt="false"></div>
|
||||
<div class="g_id_signin" data-type="standard" data-size="large" data-theme="outline" data-text="sign_in_with" data-shape="rectangular" data-logo_alignment="left"></div>
|
||||
<div
|
||||
id="g_id_onload"
|
||||
data-client_id="13671390758-bp1ed6psn43bl86r8a9kv81o40nkea90.apps.googleusercontent.com"
|
||||
data-callback="handleCredentialResponse"
|
||||
data-auto_prompt="false"
|
||||
></div>
|
||||
<div
|
||||
class="g_id_signin"
|
||||
data-type="standard"
|
||||
data-size="large"
|
||||
data-theme="outline"
|
||||
data-text="sign_in_with"
|
||||
data-shape="rectangular"
|
||||
data-logo_alignment="left"
|
||||
></div>
|
||||
</div>
|
||||
<p id="login-error" class="login-error" role="alert"></p>
|
||||
<p class="login-legal">By signing in, you agree to our <a href="#">Terms</a> and <a href="#">Privacy Policy</a>.</p>
|
||||
<p class="login-legal" data-i18n="legalLogin">
|
||||
By signing in, you agree to our <a href="#">Terms</a> and
|
||||
<a href="#">Privacy Policy</a>.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<main id="workspace-view" class="workspace" style="display:none;">
|
||||
<main id="workspace-view" class="workspace" style="display: none">
|
||||
<aside class="setup-pane" aria-label="Diagnostic setup">
|
||||
<p class="eyebrow">Tutor Platform</p>
|
||||
<h1>Interview practice</h1>
|
||||
<p class="lede">Start a focused backend interview loop and turn one answer into evidence.</p>
|
||||
<div class="workspace-header">
|
||||
<p class="eyebrow" data-i18n="eyebrow">Tutor Platform</p>
|
||||
<div class="lang-switch" role="group" aria-label="Language">
|
||||
<button type="button" data-lang="ko" class="lang-btn is-active">KO</button>
|
||||
<button type="button" data-lang="en" class="lang-btn">EN</button>
|
||||
</div>
|
||||
</div>
|
||||
<h1 data-i18n="titleWorkspace">Interview practice</h1>
|
||||
<p class="lede" data-i18n="subtitleWorkspace">
|
||||
Start a focused backend interview loop and turn one answer into evidence.
|
||||
</p>
|
||||
|
||||
<div class="user-bar">
|
||||
<div id="user-info" class="user-info"></div>
|
||||
<button id="logout-button" class="small-button" type="button">Sign out</button>
|
||||
<button id="logout-button" class="small-button" type="button" data-i18n="signOut">
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="session-form" class="stacked-form">
|
||||
<label>
|
||||
User ID
|
||||
<input id="user-id" name="user_id" value="demo-user" autocomplete="off" />
|
||||
<span data-i18n="userId">User ID</span>
|
||||
<input id="user-id" name="user_id" value="" readonly autocomplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
Target role
|
||||
<span data-i18n="targetRole">Target role</span>
|
||||
<input id="target-role" name="target_role" value="junior backend developer" />
|
||||
</label>
|
||||
<label>
|
||||
Stack
|
||||
<span data-i18n="stack">Stack</span>
|
||||
<input id="stack" name="stack" value="go, postgres" />
|
||||
</label>
|
||||
<label>
|
||||
Timeline
|
||||
<span data-i18n="timeline">Timeline</span>
|
||||
<input id="timeline" name="interview_timeline" value="30 days" />
|
||||
</label>
|
||||
<button id="start-button" type="submit">
|
||||
<span class="btn-text">Start diagnostic</span>
|
||||
<span class="btn-text" data-i18n="startDiagnostic">Start diagnostic</span>
|
||||
<span class="btn-spinner" aria-hidden="true"></span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p id="status-line" class="status-line" role="status">
|
||||
<span class="status-icon" aria-hidden="true"></span>
|
||||
<span class="status-text">Ready</span>
|
||||
<span class="status-text" data-i18n="ready">Ready</span>
|
||||
</p>
|
||||
<p id="error-line" class="error-line" role="alert"></p>
|
||||
</aside>
|
||||
@@ -79,19 +113,26 @@
|
||||
<section class="practice-pane" aria-label="Diagnostic practice">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Diagnostic</p>
|
||||
<h2 id="session-title">No active session</h2>
|
||||
<p class="eyebrow" data-i18n="diagnosticEyebrow">Diagnostic</p>
|
||||
<h2 id="session-title" data-i18n="noActiveSession">No active session</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div id="questions" class="question-list empty-state">
|
||||
<span class="empty-hint">Start a diagnostic session to load interview questions.</span>
|
||||
<span class="empty-hint" data-i18n="emptyQuestions"
|
||||
>Start a diagnostic session to load interview questions.</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<form id="answer-form" class="answer-form">
|
||||
<label for="answer-text">Answer</label>
|
||||
<textarea id="answer-text" rows="7" placeholder="Select a question, then answer with concrete production reasoning."></textarea>
|
||||
<label for="answer-text" data-i18n="answerLabel">Answer</label>
|
||||
<textarea
|
||||
id="answer-text"
|
||||
rows="7"
|
||||
data-i18n-placeholder="answerPlaceholder"
|
||||
placeholder="Select a question, then answer with concrete production reasoning."
|
||||
></textarea>
|
||||
<button id="answer-button" type="submit" disabled>
|
||||
<span class="btn-text">Submit answer</span>
|
||||
<span class="btn-text" data-i18n="submitAnswer">Submit answer</span>
|
||||
<span class="btn-spinner" aria-hidden="true"></span>
|
||||
</button>
|
||||
</form>
|
||||
@@ -99,43 +140,47 @@
|
||||
<section class="content-workspace" aria-label="Material and asset workspace">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Content operations</p>
|
||||
<h2>Source to asset prompt</h2>
|
||||
<p class="eyebrow" data-i18n="contentEyebrow">Content operations</p>
|
||||
<h2 data-i18n="contentTitle">Source to asset prompt</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="material-form" class="material-form">
|
||||
<label>
|
||||
Material title
|
||||
<span data-i18n="materialTitle">Material title</span>
|
||||
<input id="material-title" value="Backend interview notes" />
|
||||
</label>
|
||||
<label>
|
||||
Source type
|
||||
<span data-i18n="sourceType">Source type</span>
|
||||
<input id="material-source" value="markdown" />
|
||||
</label>
|
||||
<label class="wide-field">
|
||||
Source material
|
||||
<textarea id="material-body" rows="5">Idempotent API retries need transactions. Cache invalidation uses TTL tradeoffs and database indexes support query plans.</textarea>
|
||||
<span data-i18n="sourceMaterial">Source material</span>
|
||||
<textarea id="material-body" rows="5">
|
||||
Idempotent API retries need transactions. Cache invalidation uses TTL tradeoffs and database indexes support query plans.</textarea
|
||||
>
|
||||
</label>
|
||||
<button id="material-button" type="submit">
|
||||
<span class="btn-text">Ingest material</span>
|
||||
<span class="btn-text" data-i18n="ingestMaterial">Ingest material</span>
|
||||
<span class="btn-spinner" aria-hidden="true"></span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div id="ontology" class="ontology-view empty-state">
|
||||
<span class="empty-hint">Ingest material to inspect ontology candidates.</span>
|
||||
<span class="empty-hint" data-i18n="emptyOntology"
|
||||
>Ingest material to inspect ontology candidates.</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<form id="asset-form" class="asset-form">
|
||||
<label>
|
||||
Concept
|
||||
<span data-i18n="concept">Concept</span>
|
||||
<select id="asset-concept" disabled>
|
||||
<option value="">Select a concept</option>
|
||||
<option value="" data-i18n="concept">Select a concept</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Asset type
|
||||
<span data-i18n="assetType">Asset type</span>
|
||||
<select id="asset-type">
|
||||
<option value="diagram">Diagram</option>
|
||||
<option value="lesson_slice">Lesson slice</option>
|
||||
@@ -144,13 +189,15 @@
|
||||
</select>
|
||||
</label>
|
||||
<button id="asset-button" type="submit" disabled>
|
||||
<span class="btn-text">Generate prompt</span>
|
||||
<span class="btn-text" data-i18n="generatePrompt">Generate prompt</span>
|
||||
<span class="btn-spinner" aria-hidden="true"></span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div id="asset-output" class="ontology-view empty-state">
|
||||
<span class="empty-hint">Generate a prompt to inspect model key, review state, and evidence.</span>
|
||||
<span class="empty-hint" data-i18n="emptyAsset"
|
||||
>Generate a prompt to inspect model key, review state, and evidence.</span
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
@@ -158,25 +205,32 @@
|
||||
<aside class="feedback-pane" aria-label="Feedback">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Feedback</p>
|
||||
<h2>Rubric result</h2>
|
||||
<p class="eyebrow" data-i18n="feedbackEyebrow">Feedback</p>
|
||||
<h2 data-i18n="rubricResult">Rubric result</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div id="feedback" class="feedback empty-state">
|
||||
<span class="empty-hint">Submit an answer to see grade, evidence, and follow-up.</span>
|
||||
<span class="empty-hint" data-i18n="emptyFeedback"
|
||||
>Submit an answer to see grade, evidence, and follow-up.</span
|
||||
>
|
||||
</div>
|
||||
<div class="section-heading progress-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Progress</p>
|
||||
<h2>Learning state</h2>
|
||||
<p class="eyebrow" data-i18n="progressEyebrow">Progress</p>
|
||||
<h2 data-i18n="learningState">Learning state</h2>
|
||||
</div>
|
||||
<button id="refresh-progress" class="small-button" type="button" disabled>Refresh</button>
|
||||
<button id="refresh-progress" class="small-button" type="button" disabled data-i18n="refresh">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div id="progress" class="feedback empty-state">
|
||||
<span class="empty-hint">Answer once to update learner memory and readiness.</span>
|
||||
<span class="empty-hint" data-i18n="emptyProgress"
|
||||
>Answer once to update learner memory and readiness.</span
|
||||
>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
<script src="/assets/i18n.js"></script>
|
||||
<script src="/assets/app.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user